lockin 0.1.0__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,10 @@
1
+ # Python-generated files
2
+ __pycache__/
3
+ *.py[oc]
4
+ build/
5
+ dist/
6
+ wheels/
7
+ *.egg-info
8
+
9
+ # Virtual environments
10
+ .venv
@@ -0,0 +1 @@
1
+ 3.11
lockin-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,9 @@
1
+ Metadata-Version: 2.4
2
+ Name: lockin
3
+ Version: 0.1.0
4
+ Summary: CLI focus blocker for macOS — block distracting websites and apps
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: psutil>=5.9.0
7
+ Requires-Dist: requests>=2.32.5
8
+ Requires-Dist: rich>=13.0.0
9
+ Requires-Dist: typer>=0.21.1
lockin-0.1.0/README.md ADDED
File without changes
@@ -0,0 +1,3 @@
1
+ """Lockin — CLI focus blocker for macOS."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,75 @@
1
+ """macOS app detection and process killing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ import psutil
9
+
10
+ APP_DIRS = [
11
+ Path("/Applications"),
12
+ Path.home() / "Applications",
13
+ ]
14
+
15
+
16
+ def list_installed_apps() -> list[str]:
17
+ """Scan /Applications and ~/Applications for .app bundles."""
18
+ apps: list[str] = []
19
+ for app_dir in APP_DIRS:
20
+ if not app_dir.exists():
21
+ continue
22
+ for entry in sorted(app_dir.iterdir()):
23
+ if entry.suffix == ".app" and entry.is_dir():
24
+ apps.append(entry.stem)
25
+ return apps
26
+
27
+
28
+ def _quit_app_graceful(app_name: str) -> bool:
29
+ """Try to quit an app gracefully via osascript."""
30
+ result = subprocess.run(
31
+ ["osascript", "-e", f'quit app "{app_name}"'],
32
+ capture_output=True,
33
+ text=True,
34
+ )
35
+ return result.returncode == 0
36
+
37
+
38
+ def _kill_app_forceful(app_name: str) -> bool:
39
+ """Forcefully kill an app via killall."""
40
+ result = subprocess.run(
41
+ ["killall", app_name],
42
+ capture_output=True,
43
+ text=True,
44
+ )
45
+ return result.returncode == 0
46
+
47
+
48
+ def kill_app(app_name: str) -> bool:
49
+ """Kill a running app — try graceful first, then forceful."""
50
+ if _quit_app_graceful(app_name):
51
+ return True
52
+ return _kill_app_forceful(app_name)
53
+
54
+
55
+ def is_app_running(app_name: str) -> bool:
56
+ """Check if an app is currently running."""
57
+ app_name_lower = app_name.lower()
58
+ for proc in psutil.process_iter(["name"]):
59
+ try:
60
+ proc_name = proc.info["name"]
61
+ if proc_name and app_name_lower in proc_name.lower():
62
+ return True
63
+ except (psutil.NoSuchProcess, psutil.AccessDenied):
64
+ continue
65
+ return False
66
+
67
+
68
+ def kill_blocked_apps(app_names: list[str]) -> list[str]:
69
+ """Kill all blocked apps that are currently running. Returns list of killed app names."""
70
+ killed: list[str] = []
71
+ for app_name in app_names:
72
+ if is_app_running(app_name):
73
+ if kill_app(app_name):
74
+ killed.append(app_name)
75
+ return killed
@@ -0,0 +1,128 @@
1
+ """/etc/hosts manipulation, DNS cache flushing, and chflags protection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import subprocess
6
+ from pathlib import Path
7
+
8
+ HOSTS_FILE = Path("/etc/hosts")
9
+ BLOCK_START = "# >>> LOCKIN BLOCK START >>>"
10
+ BLOCK_END = "# <<< LOCKIN BLOCK END <<<"
11
+
12
+
13
+ def _run(cmd: list[str], check: bool = False) -> subprocess.CompletedProcess:
14
+ return subprocess.run(cmd, capture_output=True, text=True, check=check)
15
+
16
+
17
+ def _read_hosts() -> str:
18
+ return HOSTS_FILE.read_text()
19
+
20
+
21
+ def _get_block_entries(domains: list[str]) -> str:
22
+ """Generate /etc/hosts block entries."""
23
+ lines = [BLOCK_START]
24
+ for domain in sorted(set(domains)):
25
+ if domain: # skip empty strings
26
+ lines.append(f"0.0.0.0 {domain}")
27
+ lines.append(BLOCK_END)
28
+ return "\n".join(lines)
29
+
30
+
31
+ def _strip_existing_blocks(content: str) -> str:
32
+ """Remove any existing lockin block section from hosts content."""
33
+ lines = content.splitlines()
34
+ result: list[str] = []
35
+ inside_block = False
36
+ for line in lines:
37
+ if line.strip() == BLOCK_START:
38
+ inside_block = True
39
+ continue
40
+ if line.strip() == BLOCK_END:
41
+ inside_block = False
42
+ continue
43
+ if not inside_block:
44
+ result.append(line)
45
+ # Remove trailing blank lines from our section
46
+ while result and result[-1].strip() == "":
47
+ result.pop()
48
+ return "\n".join(result)
49
+
50
+
51
+ def remove_immutable_flag() -> bool:
52
+ """Remove the system immutable flag from /etc/hosts."""
53
+ result = _run(["chflags", "noschg", str(HOSTS_FILE)])
54
+ return result.returncode == 0
55
+
56
+
57
+ def set_immutable_flag() -> bool:
58
+ """Set the system immutable flag on /etc/hosts to prevent edits."""
59
+ result = _run(["chflags", "schg", str(HOSTS_FILE)])
60
+ return result.returncode == 0
61
+
62
+
63
+ def flush_dns_cache() -> None:
64
+ """Flush the macOS DNS cache."""
65
+ _run(["dscacheutil", "-flushcache"])
66
+ _run(["killall", "-HUP", "mDNSResponder"])
67
+
68
+
69
+ def apply_blocks(domains: list[str]) -> bool:
70
+ """Write domain blocks to /etc/hosts and protect the file.
71
+
72
+ Returns True if blocks were applied successfully.
73
+ """
74
+ if not domains:
75
+ return True
76
+
77
+ remove_immutable_flag()
78
+
79
+ try:
80
+ current = _read_hosts()
81
+ clean = _strip_existing_blocks(current)
82
+ block_entries = _get_block_entries(domains)
83
+ new_content = clean + "\n\n" + block_entries + "\n"
84
+ HOSTS_FILE.write_text(new_content)
85
+ except PermissionError:
86
+ return False
87
+
88
+ set_immutable_flag()
89
+ flush_dns_cache()
90
+ return True
91
+
92
+
93
+ def remove_blocks() -> bool:
94
+ """Remove all lockin blocks from /etc/hosts.
95
+
96
+ Returns True if blocks were removed successfully.
97
+ """
98
+ remove_immutable_flag()
99
+
100
+ try:
101
+ current = _read_hosts()
102
+ clean = _strip_existing_blocks(current)
103
+ # Ensure file ends with a newline
104
+ if not clean.endswith("\n"):
105
+ clean += "\n"
106
+ HOSTS_FILE.write_text(clean)
107
+ except PermissionError:
108
+ return False
109
+
110
+ flush_dns_cache()
111
+ return True
112
+
113
+
114
+ def are_blocks_applied(domains: list[str]) -> bool:
115
+ """Check if the expected blocks are present in /etc/hosts."""
116
+ if not domains:
117
+ return True
118
+ try:
119
+ content = _read_hosts()
120
+ except PermissionError:
121
+ return False
122
+ return BLOCK_START in content and BLOCK_END in content
123
+
124
+
125
+ def is_immutable() -> bool:
126
+ """Check if /etc/hosts has the system immutable flag set."""
127
+ result = _run(["ls", "-lO", str(HOSTS_FILE)])
128
+ return "schg" in result.stdout
@@ -0,0 +1,453 @@
1
+ """Typer CLI commands — all user-facing commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ from typing import Optional
8
+
9
+ import typer
10
+ from typing_extensions import Annotated
11
+
12
+ from lockin import __version__
13
+ from lockin.config import Config, Profile, Schedule, load_config, save_config
14
+ from lockin.presets import PRESETS, get_preset, list_presets
15
+ from lockin.session import create_session, get_active_session, load_session, SESSION_FILE
16
+ from lockin.apps import list_installed_apps
17
+ from lockin.daemon import install_daemon, uninstall_daemon, is_daemon_installed
18
+ from lockin.blocker import apply_blocks, remove_blocks
19
+ from lockin.ui import (
20
+ console,
21
+ print_error,
22
+ print_info,
23
+ print_success,
24
+ print_warning,
25
+ show_always_blocked,
26
+ show_apps,
27
+ show_presets,
28
+ show_profile_detail,
29
+ show_profiles,
30
+ show_schedules,
31
+ show_status,
32
+ )
33
+
34
+ app = typer.Typer(
35
+ name="lockin",
36
+ help="CLI focus blocker for macOS — block distracting websites and apps.",
37
+ no_args_is_help=False,
38
+ invoke_without_command=True,
39
+ )
40
+
41
+ profile_app = typer.Typer(help="Manage blocking profiles.")
42
+ schedule_app = typer.Typer(help="Manage auto-start schedules.")
43
+ app.add_typer(profile_app, name="profile")
44
+ app.add_typer(schedule_app, name="schedule")
45
+
46
+
47
+ def _parse_duration(duration_str: str) -> int:
48
+ """Parse duration string like '2h', '30m', '1h30m', '90s' into seconds."""
49
+ pattern = r"(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s)?"
50
+ match = re.fullmatch(pattern, duration_str.strip())
51
+ if not match or not any(match.groups()):
52
+ raise typer.BadParameter(
53
+ f"Invalid duration '{duration_str}'. Use format like: 2h, 30m, 1h30m, 90s"
54
+ )
55
+ hours = int(match.group(1) or 0)
56
+ minutes = int(match.group(2) or 0)
57
+ seconds = int(match.group(3) or 0)
58
+ total = hours * 3600 + minutes * 60 + seconds
59
+ if total <= 0:
60
+ raise typer.BadParameter("Duration must be greater than 0.")
61
+ return total
62
+
63
+
64
+ def _require_root(action: str = "This action") -> None:
65
+ if os.geteuid() != 0:
66
+ print_error(f"{action} requires root privileges. Run with sudo.")
67
+ raise typer.Exit(1)
68
+
69
+
70
+ # -- Main command (status) --
71
+
72
+
73
+ @app.callback()
74
+ def main_callback(
75
+ ctx: typer.Context,
76
+ version: Annotated[
77
+ bool, typer.Option("--version", "-v", help="Show version and exit.")
78
+ ] = False,
79
+ ) -> None:
80
+ """Lockin — CLI focus blocker for macOS."""
81
+ if version:
82
+ console.print(f"lockin v{__version__}")
83
+ raise typer.Exit()
84
+ # If no subcommand given, show status
85
+ if ctx.invoked_subcommand is None:
86
+ session = load_session()
87
+ if session and session.verify() and not session.is_expired:
88
+ show_status(session)
89
+ else:
90
+ show_status(None)
91
+
92
+
93
+ @app.command()
94
+ def status() -> None:
95
+ """Show current session status."""
96
+ session = load_session()
97
+ if session and session.verify() and not session.is_expired:
98
+ show_status(session)
99
+ else:
100
+ show_status(None)
101
+
102
+
103
+ # -- Start / Stop --
104
+
105
+
106
+ @app.command()
107
+ def start(
108
+ profile_name: Annotated[str, typer.Argument(help="Profile name to activate.")],
109
+ duration: Annotated[
110
+ str, typer.Option("--duration", "-d", help="Session duration (e.g. 2h, 30m, 1h30m).")
111
+ ] = "1h",
112
+ ) -> None:
113
+ """Start a focus session."""
114
+ _require_root("Starting a focus session")
115
+
116
+ # Check for existing active session
117
+ active = get_active_session()
118
+ if active:
119
+ print_error(
120
+ f"A session is already active (profile: {active.profile_name}, "
121
+ f"remaining: {int(active.remaining_seconds)}s). Cannot start another."
122
+ )
123
+ raise typer.Exit(1)
124
+
125
+ config = load_config()
126
+ profile = config.profiles.get(profile_name)
127
+ if profile is None:
128
+ print_error(f"Profile '{profile_name}' not found. Create one with: lockin profile create {profile_name} --preset social")
129
+ raise typer.Exit(1)
130
+
131
+ duration_seconds = _parse_duration(duration)
132
+ blocked_domains = profile.resolve_domains()
133
+ blocked_apps = profile.resolve_apps()
134
+
135
+ # Add always-blocked items
136
+ from lockin.presets import SUBDOMAIN_PREFIXES
137
+
138
+ for site in config.always_blocked.sites:
139
+ for prefix in SUBDOMAIN_PREFIXES:
140
+ d = f"{prefix}{site}"
141
+ if d not in blocked_domains:
142
+ blocked_domains.append(d)
143
+ for a in config.always_blocked.apps:
144
+ if a not in blocked_apps:
145
+ blocked_apps.append(a)
146
+
147
+ if not blocked_domains and not blocked_apps:
148
+ print_warning("This profile has nothing to block. Add presets or custom sites first.")
149
+ raise typer.Exit(1)
150
+
151
+ # Check if daemon is installed
152
+ if not is_daemon_installed():
153
+ print_warning("Watchdog daemon is not installed. Installing now...")
154
+ if not install_daemon():
155
+ print_error("Failed to install watchdog daemon.")
156
+ raise typer.Exit(1)
157
+ print_success("Watchdog daemon installed.")
158
+
159
+ # Apply blocks
160
+ if blocked_domains:
161
+ if not apply_blocks(blocked_domains):
162
+ print_error("Failed to apply website blocks. Are you running as root?")
163
+ raise typer.Exit(1)
164
+
165
+ # Kill blocked apps immediately
166
+ from lockin.apps import kill_blocked_apps
167
+
168
+ killed = kill_blocked_apps(blocked_apps)
169
+ if killed:
170
+ print_info(f"Killed blocked apps: {', '.join(killed)}")
171
+
172
+ # Create signed session
173
+ sess = create_session(
174
+ profile_name=profile_name,
175
+ duration_seconds=duration_seconds,
176
+ blocked_domains=blocked_domains,
177
+ blocked_apps=blocked_apps,
178
+ )
179
+
180
+ from lockin.ui import format_duration
181
+
182
+ print_success(f"Focus session started!")
183
+ print_info(f"Profile: {profile_name}")
184
+ print_info(f"Duration: {format_duration(duration_seconds)}")
185
+ print_info(f"Blocking: {len(blocked_domains)} domains, {len(blocked_apps)} apps")
186
+ print_warning("This session cannot be stopped until the timer expires.")
187
+
188
+
189
+ @app.command()
190
+ def stop(
191
+ force: Annotated[
192
+ bool, typer.Option("--force", "-f", help="Force stop (NOT ALLOWED during active sessions).")
193
+ ] = False,
194
+ ) -> None:
195
+ """Stop an active session. REFUSED during active sessions — this is by design."""
196
+ session = load_session()
197
+
198
+ if session is None:
199
+ print_info("No active session to stop.")
200
+ return
201
+
202
+ if session.verify() and not session.is_expired:
203
+ # Active valid session — refuse to stop
204
+ from lockin.ui import format_duration
205
+
206
+ remaining = session.remaining_seconds
207
+ print_error("Cannot stop an active focus session. This is by design.")
208
+ print_error(f"Remaining: {format_duration(remaining)}")
209
+ print_warning("The session will end automatically when the timer expires.")
210
+ if force:
211
+ print_error("Even --force cannot stop an active session. Stay focused!")
212
+ raise typer.Exit(1)
213
+
214
+ # Session is expired or invalid — clean up
215
+ print_info("Session has expired. Cleaning up...")
216
+ remove_blocks()
217
+ from lockin.session import delete_session
218
+
219
+ delete_session()
220
+ print_success("Session cleaned up.")
221
+
222
+
223
+ # -- Presets --
224
+
225
+
226
+ @app.command("preset")
227
+ def preset_list() -> None:
228
+ """Show built-in presets."""
229
+ show_presets(list_presets())
230
+
231
+
232
+ # -- Profile management --
233
+
234
+
235
+ @profile_app.command("list")
236
+ def profile_list() -> None:
237
+ """List all profiles."""
238
+ config = load_config()
239
+ show_profiles(config.profiles)
240
+
241
+
242
+ @profile_app.command("create")
243
+ def profile_create(
244
+ name: Annotated[str, typer.Argument(help="Profile name.")],
245
+ preset: Annotated[
246
+ Optional[list[str]],
247
+ typer.Option("--preset", "-p", help="Preset category to include (can repeat)."),
248
+ ] = None,
249
+ site: Annotated[
250
+ Optional[list[str]],
251
+ typer.Option("--site", "-s", help="Custom domain to block (can repeat)."),
252
+ ] = None,
253
+ block_app: Annotated[
254
+ Optional[list[str]],
255
+ typer.Option("--app", "-a", help="App name to block (can repeat)."),
256
+ ] = None,
257
+ ) -> None:
258
+ """Create a new blocking profile."""
259
+ config = load_config()
260
+
261
+ if name in config.profiles:
262
+ print_error(f"Profile '{name}' already exists. Delete it first or choose another name.")
263
+ raise typer.Exit(1)
264
+
265
+ presets = preset or []
266
+ for p in presets:
267
+ if p not in PRESETS:
268
+ print_error(f"Unknown preset '{p}'. Available: {', '.join(PRESETS.keys())}")
269
+ raise typer.Exit(1)
270
+
271
+ profile = Profile(
272
+ name=name,
273
+ presets=presets,
274
+ custom_sites=site or [],
275
+ blocked_apps=block_app or [],
276
+ )
277
+ config.profiles[name] = profile
278
+ save_config(config)
279
+
280
+ print_success(f"Profile '{name}' created.")
281
+ show_profile_detail(profile)
282
+
283
+
284
+ @profile_app.command("show")
285
+ def profile_show(
286
+ name: Annotated[str, typer.Argument(help="Profile name to show.")],
287
+ ) -> None:
288
+ """Show details of a profile."""
289
+ config = load_config()
290
+ profile = config.profiles.get(name)
291
+ if profile is None:
292
+ print_error(f"Profile '{name}' not found.")
293
+ raise typer.Exit(1)
294
+ show_profile_detail(profile)
295
+
296
+
297
+ @profile_app.command("delete")
298
+ def profile_delete(
299
+ name: Annotated[str, typer.Argument(help="Profile name to delete.")],
300
+ ) -> None:
301
+ """Delete a profile."""
302
+ config = load_config()
303
+ if name not in config.profiles:
304
+ print_error(f"Profile '{name}' not found.")
305
+ raise typer.Exit(1)
306
+ del config.profiles[name]
307
+ save_config(config)
308
+ print_success(f"Profile '{name}' deleted.")
309
+
310
+
311
+ # -- Always-blocked --
312
+
313
+
314
+ @app.command("block")
315
+ def block_domain(
316
+ domain: Annotated[str, typer.Argument(help="Domain to always block.")],
317
+ ) -> None:
318
+ """Add a domain to the always-blocked list."""
319
+ config = load_config()
320
+ if domain in config.always_blocked.sites:
321
+ print_warning(f"'{domain}' is already in the always-blocked list.")
322
+ return
323
+ config.always_blocked.sites.append(domain)
324
+ save_config(config)
325
+ print_success(f"Added '{domain}' to always-blocked list.")
326
+
327
+
328
+ @app.command("unblock")
329
+ def unblock_domain(
330
+ domain: Annotated[str, typer.Argument(help="Domain to remove from always-blocked.")],
331
+ ) -> None:
332
+ """Remove a domain from the always-blocked list."""
333
+ config = load_config()
334
+ if domain not in config.always_blocked.sites:
335
+ print_error(f"'{domain}' is not in the always-blocked list.")
336
+ raise typer.Exit(1)
337
+ config.always_blocked.sites.remove(domain)
338
+ save_config(config)
339
+ print_success(f"Removed '{domain}' from always-blocked list.")
340
+
341
+
342
+ # -- Schedules --
343
+
344
+
345
+ @schedule_app.command("list")
346
+ def schedule_list() -> None:
347
+ """List all schedules."""
348
+ config = load_config()
349
+ show_schedules(config.schedules)
350
+
351
+
352
+ @schedule_app.command("create")
353
+ def schedule_create(
354
+ name: Annotated[str, typer.Argument(help="Schedule name.")],
355
+ profile_name: Annotated[str, typer.Option("--profile", "-p", help="Profile to activate.")],
356
+ days: Annotated[str, typer.Option("--days", "-D", help="Comma-separated days (mon,tue,wed,thu,fri,sat,sun).")],
357
+ start_time: Annotated[str, typer.Option("--start", "-s", help="Start time (HH:MM).")] = "09:00",
358
+ duration_minutes: Annotated[int, typer.Option("--duration", "-d", help="Duration in minutes.")] = 120,
359
+ ) -> None:
360
+ """Create an auto-start schedule."""
361
+ config = load_config()
362
+
363
+ if name in config.schedules:
364
+ print_error(f"Schedule '{name}' already exists.")
365
+ raise typer.Exit(1)
366
+
367
+ if profile_name not in config.profiles:
368
+ print_error(f"Profile '{profile_name}' not found. Create it first.")
369
+ raise typer.Exit(1)
370
+
371
+ day_map = {
372
+ "mon": "Monday", "tue": "Tuesday", "wed": "Wednesday",
373
+ "thu": "Thursday", "fri": "Friday", "sat": "Saturday", "sun": "Sunday",
374
+ }
375
+ day_list: list[str] = []
376
+ for d in days.split(","):
377
+ d = d.strip().lower()
378
+ if d not in day_map:
379
+ print_error(f"Invalid day '{d}'. Use: mon,tue,wed,thu,fri,sat,sun")
380
+ raise typer.Exit(1)
381
+ day_list.append(day_map[d])
382
+
383
+ schedule = Schedule(
384
+ name=name,
385
+ profile=profile_name,
386
+ days=day_list,
387
+ start_time=start_time,
388
+ duration_minutes=duration_minutes,
389
+ )
390
+ config.schedules[name] = schedule
391
+ save_config(config)
392
+ print_success(f"Schedule '{name}' created.")
393
+
394
+
395
+ @schedule_app.command("delete")
396
+ def schedule_delete(
397
+ name: Annotated[str, typer.Argument(help="Schedule name to delete.")],
398
+ ) -> None:
399
+ """Delete a schedule."""
400
+ config = load_config()
401
+ if name not in config.schedules:
402
+ print_error(f"Schedule '{name}' not found.")
403
+ raise typer.Exit(1)
404
+ del config.schedules[name]
405
+ save_config(config)
406
+ print_success(f"Schedule '{name}' deleted.")
407
+
408
+
409
+ # -- Apps --
410
+
411
+
412
+ @app.command("apps")
413
+ def apps_list() -> None:
414
+ """List detected macOS apps."""
415
+ installed = list_installed_apps()
416
+ show_apps(installed)
417
+
418
+
419
+ # -- Daemon install --
420
+
421
+
422
+ @app.command("install")
423
+ def install_cmd() -> None:
424
+ """Install the launchd watchdog daemon."""
425
+ _require_root("Installing the daemon")
426
+ if install_daemon():
427
+ print_success("Watchdog daemon installed and loaded.")
428
+ print_info("The daemon will enforce focus sessions and prevent tampering.")
429
+ else:
430
+ print_error("Failed to install the watchdog daemon.")
431
+ raise typer.Exit(1)
432
+
433
+
434
+ @app.command("uninstall")
435
+ def uninstall_cmd() -> None:
436
+ """Uninstall the launchd watchdog daemon."""
437
+ _require_root("Uninstalling the daemon")
438
+
439
+ # Refuse if there's an active session
440
+ active = get_active_session()
441
+ if active:
442
+ print_error("Cannot uninstall while a focus session is active.")
443
+ raise typer.Exit(1)
444
+
445
+ if uninstall_daemon():
446
+ print_success("Watchdog daemon uninstalled.")
447
+ else:
448
+ print_error("Failed to uninstall the watchdog daemon.")
449
+ raise typer.Exit(1)
450
+
451
+
452
+ def main() -> None:
453
+ app()