@vibelord/lanes 1.3.0 → 1.5.1

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,456 @@
1
+ #!/usr/bin/env python3
2
+ """throttle-pulse --once|--loop [--control URL] [--state-dir DIR] [--token-file FILE] — the Throttle daemon's live facts,
3
+ read where the daemon runs (W11, spec docs/superpowers/specs/2026-10-11-lanes-fleet-dashboard-design.md §2.1).
4
+
5
+ The daemon binds its own container's 127.0.0.1 (DEF-1525): a host poller cannot reach it. This script is shipped by
6
+ `lanes install-container` into ~/.lz/bin/ of every workspace container and runs, as a loop, only in the daemon's
7
+ container (`tmux -L lanes-pulse` session `pulse`, started by install-container). Every PULSE_INTERVAL seconds it GETs,
8
+ on the control port (`--control`, else http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT, else :19181):
9
+
10
+ open /pools/<pool>/ready for each of POOLS (a 503 still carries the JSON body: the pool is not ready)
11
+ /metrics (Prometheus text: four `throttle_*{pool="…"}` series)
12
+ tokened /pools/<pool>/status for each of POOLS with `Authorization: Bearer <token>` — only while the token
13
+ file exists (~/.lz/throttle-token, mode 0600; `lanes throttle adopt-token <container>` writes it). The
14
+ token is the LOCAL proxy bearer (`throttle_…`), never a provider credential. Every guarded control route
15
+ goes through the daemon's ControlAuth with no loopback exemption, so without it /status answers 401.
16
+
17
+ and writes ~/.lz/state/throttle.json (the shape below, by rename) only when a value changed or HEARTBEAT seconds passed
18
+ since the file's `at` — `at` is the liveness heartbeat lanes-statusd ages:
19
+
20
+ {"v": 1, "at": <epoch s>, "up": bool, "tokened": bool,
21
+ "pools": {"claude"|"codex"|"kimi": {"configured", "usable", "cooling", "exhausted", "benched", "auth_failed",
22
+ "overflow_ready", "pressure_phase", "spill_fraction", "overflow_in_flight", "upstream_429_total",
23
+ "worst_7d", "worst_5h", "soonest_reset"}},
24
+ "overflow": {"claude"|"codex"|"kimi": {"spend_today_usd", "budget_usd", "fraction", "alert_level_pct"}},
25
+ "error": null | "<why the daemon did not answer>"}
26
+
27
+ `cooling` is the remainder configured − usable − exhausted − auth_failed − disabled − billing/org-blocked − unhealthy,
28
+ floored at 0 (the daemon's buckets can overlap); `benched` is `model_benched_subscriptions`. `pressure_phase` is the
29
+ daemon's own word for the gauge (0 `closed` — calm —, 1 `spilling`, 2 `elevated`). `worst_7d` / `worst_5h` are the highest measured
30
+ utilization (0–1) over the pool's subscription accounts, `soonest_reset` the earliest future reset (epoch s) of either
31
+ window; they and `overflow` stay null without the token. `up` is false only when no open GET answered: the previous
32
+ pools are then kept, `error` says why. Why a source failed goes to stderr (the tmux pane, the journal) each time it
33
+ changes, never into the file and never the token.
34
+
35
+ POOLS are claude, codex and (W12, Lanes 1.5.0) kimi: the daemon's `/pools/:pool/ready` and `/status` routes are
36
+ generic (`src/proxy.rs` `scoped_pool_state`), and a daemon with no Kimi account answers `/pools/kimi/ready` with
37
+ `configured_subscriptions: 0` — a pool with nothing configured, not a failure. Kimi has no paid overflow lane (the
38
+ daemon keeps its `api_overflow` at the default, and its /status still renders a zero overflow block), so only
39
+ MONEY_POOLS carry overflow money: `overflow.kimi` is always nulls.
40
+
41
+ GET only, 3 s timeout, 1 MiB body cap, no redirect followed (one would carry the bearer elsewhere), no proxy from the
42
+ environment. Stdlib only. Exit: --once 0 when the daemon answered, 1 when not, 2 on a usage error; --loop runs until
43
+ SIGTERM/SIGINT.
44
+ """
45
+ import argparse
46
+ import json
47
+ import os
48
+ import re
49
+ import signal
50
+ import stat
51
+ import sys
52
+ import tempfile
53
+ import time
54
+ import urllib.error
55
+ import urllib.request
56
+ from pathlib import Path
57
+
58
+ SCHEMA = 1
59
+ PULSE_INTERVAL = 10 # seconds between two cycles of --loop
60
+ HEARTBEAT = 60 # throttle.json is rewritten at least this often (its `at`)
61
+ TIMEOUT = 3 # seconds per GET
62
+ MAX_BODY = 1 << 20 # bytes: a bigger answer is not the daemon's
63
+ TOKEN_MAX = 4096 # bytes: a token file is one short line
64
+ DEFAULT_PORT = 19181
65
+ POOLS = ('claude', 'codex', 'kimi')
66
+ MONEY_POOLS = frozenset({'claude', 'codex'}) # the pools with a paid overflow lane; kimi's money is always null
67
+ # The gauge's numbers (src/proxy.rs render_metrics: 0=calm,1=spilling,2=elevated) as the words the daemon's own JSON
68
+ # uses for them (src/pressure.rs PressureSnapshot.phase: "closed" is calm), which economy.json and the sidebar read.
69
+ PHASES = {0: 'closed', 1: 'spilling', 2: 'elevated'}
70
+ SUBSCRIPTION_PROVIDERS = frozenset({'anthropic', 'openai', 'kimi'}) # src/proxy.rs is_subscription_provider
71
+ SERIES = {'throttle_pool_spill_fraction': 'spill_fraction', 'throttle_overflow_in_flight': 'overflow_in_flight',
72
+ 'throttle_upstream_429_total': 'upstream_429_total', 'throttle_pool_pressure_phase': 'pressure_phase'}
73
+ POOL_KEYS = ('configured', 'usable', 'cooling', 'exhausted', 'benched', 'auth_failed', 'overflow_ready',
74
+ 'pressure_phase', 'spill_fraction', 'overflow_in_flight', 'upstream_429_total', 'worst_7d', 'worst_5h',
75
+ 'soonest_reset')
76
+ OVERFLOW_KEYS = ('spend_today_usd', 'budget_usd', 'fraction', 'alert_level_pct')
77
+ TOKENED_POOL_KEYS = ('worst_7d', 'worst_5h', 'soonest_reset')
78
+ STATE_FILE = 'throttle.json'
79
+ METRIC_LINE = re.compile(r'^([a-zA-Z_:][a-zA-Z0-9_:]*)\{([^}]*)\}\s+(\S+)')
80
+ LABEL = re.compile(r'([a-zA-Z_][a-zA-Z0-9_]*)="((?:[^"\\]|\\.)*)"')
81
+
82
+
83
+ def empty_pool():
84
+ return dict.fromkeys(POOL_KEYS)
85
+
86
+
87
+ def empty_overflow():
88
+ return dict.fromkeys(OVERFLOW_KEYS)
89
+
90
+
91
+ def empty_document():
92
+ return {'v': SCHEMA, 'at': None, 'up': False, 'tokened': False,
93
+ 'pools': {pool: empty_pool() for pool in POOLS},
94
+ 'overflow': {pool: empty_overflow() for pool in POOLS}, 'error': None}
95
+
96
+
97
+ # ------------------------------------------------------------------ HTTP
98
+
99
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
100
+ def redirect_request(self, *_args, **_kwargs): # a redirect would carry the bearer elsewhere: refuse it
101
+ return None
102
+
103
+
104
+ def default_opener():
105
+ """urllib with no proxy from the environment and no redirect followed."""
106
+ return urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect()).open
107
+
108
+
109
+ class Failed(Exception):
110
+ """A GET that gave no usable answer; str() is a short, secret-free reason."""
111
+
112
+
113
+ def failure_class(error):
114
+ if isinstance(error, urllib.error.HTTPError):
115
+ return f'HTTP {error.code}'
116
+ if isinstance(error, urllib.error.URLError):
117
+ reason = error.reason
118
+ return failure_class(reason) if isinstance(reason, BaseException) else f'unreachable: {reason}'
119
+ if isinstance(error, ConnectionRefusedError):
120
+ return 'connection refused'
121
+ if isinstance(error, (TimeoutError,)) or type(error).__name__ == 'timeout':
122
+ return 'timed out'
123
+ if isinstance(error, OSError):
124
+ return f'unreachable: {error.strerror or type(error).__name__}'
125
+ return type(error).__name__
126
+
127
+
128
+ def _capped(stream):
129
+ body = stream.read(MAX_BODY + 1)
130
+ if len(body) > MAX_BODY:
131
+ raise Failed(f'body over {MAX_BODY >> 20} MiB')
132
+ return body
133
+
134
+
135
+ def get(control, path, token=None, opener=None, accept_status=()):
136
+ """GET <control><path> -> the body bytes. An HTTP status in `accept_status` (the 503 of a pool that is not ready)
137
+ still yields its body; any other failure raises Failed. The token goes into the header only."""
138
+ request = urllib.request.Request(control + path, method='GET')
139
+ if token:
140
+ request.add_header('Authorization', f'Bearer {token}')
141
+ try:
142
+ with (opener or default_opener())(request, timeout=TIMEOUT) as response:
143
+ return _capped(response)
144
+ except urllib.error.HTTPError as error:
145
+ if error.code in accept_status:
146
+ try:
147
+ return _capped(error)
148
+ finally:
149
+ error.close()
150
+ error.close()
151
+ raise Failed(failure_class(error)) from None
152
+ except Failed:
153
+ raise
154
+ except Exception as error: # the daemon may be down, restarting or slow: the pulse degrades, never dies
155
+ raise Failed(failure_class(error)) from None
156
+
157
+
158
+ def get_json(control, path, token=None, opener=None, accept_status=()):
159
+ body = get(control, path, token, opener, accept_status)
160
+ try:
161
+ doc = json.loads(body)
162
+ except ValueError:
163
+ raise Failed('not JSON') from None
164
+ if not isinstance(doc, dict):
165
+ raise Failed('not a JSON object')
166
+ return doc
167
+
168
+
169
+ # ------------------------------------------------------------------ parsing
170
+
171
+ def _count(value):
172
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
173
+
174
+
175
+ def _number(value):
176
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or value != value:
177
+ return None
178
+ return value
179
+
180
+
181
+ def _fraction(value):
182
+ value = _number(value)
183
+ return None if value is None else round(float(value), 3)
184
+
185
+
186
+ def from_ready(doc):
187
+ """The counts of one /pools/<pool>/ready body."""
188
+ configured, usable = _count(doc.get('configured_subscriptions')), _count(doc.get('usable_subscriptions'))
189
+ exhausted, auth_failed = _count(doc.get('exhausted_subscriptions')), _count(doc.get('auth_failed_subscriptions'))
190
+ others = sum(_count(doc.get(key)) for key in ('disabled_subscriptions', 'billing_blocked_subscriptions',
191
+ 'organization_blocked_subscriptions', 'unhealthy_subscriptions'))
192
+ pressure = doc.get('pressure') if isinstance(doc.get('pressure'), dict) else {}
193
+ phase = pressure.get('phase')
194
+ return {'configured': configured, 'usable': usable,
195
+ 'cooling': max(0, configured - usable - exhausted - auth_failed - others),
196
+ 'exhausted': exhausted, 'benched': _count(doc.get('model_benched_subscriptions')),
197
+ 'auth_failed': auth_failed, 'overflow_ready': doc.get('overflow_ready') is True,
198
+ 'pressure_phase': phase if isinstance(phase, str) and phase in PHASES.values() else None}
199
+
200
+
201
+ def parse_metrics(text):
202
+ """{pool: {key: value}} of the four series this pulse reads (SERIES); any other line is ignored."""
203
+ found = {}
204
+ for line in text.splitlines():
205
+ match = METRIC_LINE.match(line)
206
+ if not match or match.group(1) not in SERIES:
207
+ continue
208
+ labels = dict(LABEL.findall(match.group(2)))
209
+ pool = labels.get('pool')
210
+ if pool not in POOLS:
211
+ continue
212
+ try:
213
+ value = float(match.group(3))
214
+ except ValueError:
215
+ continue
216
+ if value != value: # NaN
217
+ continue
218
+ key = SERIES[match.group(1)]
219
+ if key == 'pressure_phase':
220
+ value = PHASES.get(int(value)) if value.is_integer() else None
221
+ elif key == 'spill_fraction':
222
+ value = round(value, 3)
223
+ else:
224
+ value = int(value) if value.is_integer() and value >= 0 else None
225
+ found.setdefault(pool, {})[key] = value
226
+ return found
227
+
228
+
229
+ def from_status(doc, now):
230
+ """(pool facts, overflow facts) of one tokened /pools/<pool>/status body. Worst = the highest MEASURED utilization
231
+ over the pool's subscription accounts (a field the daemon lists in `unknown_fields` is a placeholder 0, skipped);
232
+ soonest reset = the earliest reset of either window still in the future."""
233
+ worst = {'5h': None, '7d': None}
234
+ resets = []
235
+ for account in doc.get('accounts') or []:
236
+ if not isinstance(account, dict) or account.get('provider') not in SUBSCRIPTION_PROVIDERS:
237
+ continue
238
+ unknown = account.get('unknown_fields') if isinstance(account.get('unknown_fields'), list) else []
239
+ for window in ('5h', '7d'):
240
+ value = _number(account.get(f'utilization_{window}'))
241
+ if value is not None and f'utilization_{window}' not in unknown:
242
+ worst[window] = value if worst[window] is None else max(worst[window], value)
243
+ reset = account.get(f'reset_{window}')
244
+ if isinstance(reset, int) and not isinstance(reset, bool) and reset > now:
245
+ resets.append(reset)
246
+ overflow = doc.get('overflow') if isinstance(doc.get('overflow'), dict) else {}
247
+ alert = overflow.get('budget_alert_level_pct')
248
+ return ({'worst_7d': _fraction(worst['7d']), 'worst_5h': _fraction(worst['5h']),
249
+ 'soonest_reset': min(resets) if resets else None},
250
+ {'spend_today_usd': _number(overflow.get('spend_today_usd')),
251
+ 'budget_usd': _number(overflow.get('effective_daily_budget_usd')),
252
+ 'fraction': _fraction(overflow.get('spent_fraction')),
253
+ 'alert_level_pct': alert if isinstance(alert, int) and not isinstance(alert, bool) else None})
254
+
255
+
256
+ # ------------------------------------------------------------------ token and file
257
+
258
+ def read_token(path):
259
+ """(token or None, why or None). Absent is (None, None): the opt-in was never made. A file that is not a regular
260
+ file, is readable by group/other, is empty or too long is (None, why) — never used, never printed."""
261
+ try:
262
+ fd = os.open(str(path), os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
263
+ except FileNotFoundError:
264
+ return None, None
265
+ except OSError as error:
266
+ return None, f'token file unreadable ({error.strerror or type(error).__name__})'
267
+ try:
268
+ info = os.fstat(fd)
269
+ if not stat.S_ISREG(info.st_mode):
270
+ return None, 'token file is not a regular file'
271
+ if info.st_mode & 0o077:
272
+ return None, f'token file mode {stat.S_IMODE(info.st_mode):04o}, want 0600'
273
+ data = os.read(fd, TOKEN_MAX + 1)
274
+ except OSError as error:
275
+ return None, f'token file unreadable ({error.strerror or type(error).__name__})'
276
+ finally:
277
+ os.close(fd)
278
+ try:
279
+ token = data.decode('ascii').strip()
280
+ except UnicodeDecodeError:
281
+ return None, 'token file is not ASCII'
282
+ if not token or len(data) > TOKEN_MAX or any(c.isspace() for c in token):
283
+ return None, 'token file holds no single token'
284
+ return token, None
285
+
286
+
287
+ def read_previous(path):
288
+ try:
289
+ doc = json.loads(Path(path).read_text())
290
+ except (OSError, ValueError):
291
+ return None
292
+ return doc if isinstance(doc, dict) and doc.get('v') == SCHEMA else None
293
+
294
+
295
+ def write_atomic(path, document):
296
+ path = Path(path)
297
+ path.parent.mkdir(parents=True, exist_ok=True)
298
+ fd, temporary = tempfile.mkstemp(prefix=f'.{path.name}.', dir=str(path.parent))
299
+ try:
300
+ with os.fdopen(fd, 'w') as handle:
301
+ handle.write(json.dumps(document, separators=(',', ':')) + '\n')
302
+ os.fchmod(handle.fileno(), 0o600)
303
+ os.replace(temporary, path)
304
+ except BaseException:
305
+ try:
306
+ os.unlink(temporary)
307
+ except OSError:
308
+ pass
309
+ raise
310
+
311
+
312
+ def _without_at(document):
313
+ return {key: value for key, value in (document or {}).items() if key != 'at'}
314
+
315
+
316
+ # ------------------------------------------------------------------ one cycle
317
+
318
+ class Pulse:
319
+ """One pulse: its last document (seeded from the file, so a restart keeps the pools) and its last journal line."""
320
+
321
+ def __init__(self, control, state_dir, token_file, opener=None, clock=time.time, journal=None):
322
+ self.control, self.path, self.token_file = control.rstrip('/'), Path(state_dir) / STATE_FILE, token_file
323
+ self.opener, self.clock = opener, clock
324
+ self.journal = journal or (lambda line: print(f'throttle-pulse: {line}', file=sys.stderr, flush=True))
325
+ previous = read_previous(self.path)
326
+ self.document = previous if previous else empty_document()
327
+ self.said = None
328
+
329
+ def observe(self):
330
+ """GET every source once -> (document without `at`, [problems]). A pool whose source did not answer keeps its
331
+ previous facts (the file's age tells a reader how old they are)."""
332
+ now = int(self.clock())
333
+ old_pools, old_overflow = self.document.get('pools') or {}, self.document.get('overflow') or {}
334
+ pools = {pool: dict(empty_pool(), **{k: v for k, v in (old_pools.get(pool) or {}).items() if k in POOL_KEYS})
335
+ for pool in POOLS}
336
+ overflow = {pool: dict(empty_overflow(), **{k: v for k, v in (old_overflow.get(pool) or {}).items()
337
+ if k in OVERFLOW_KEYS}) for pool in POOLS}
338
+ problems, answered, first_failure = [], False, None
339
+ for pool in POOLS:
340
+ path = f'/pools/{pool}/ready'
341
+ try:
342
+ pools[pool].update(from_ready(get_json(self.control, path, opener=self.opener, accept_status=(503,))))
343
+ answered = True
344
+ except Failed as error:
345
+ first_failure = first_failure or str(error)
346
+ problems.append(f'{path}: {error}')
347
+ try:
348
+ series = parse_metrics(get(self.control, '/metrics', opener=self.opener).decode('utf-8', 'replace'))
349
+ answered = True
350
+ for pool in POOLS:
351
+ for key in SERIES.values():
352
+ if key == 'pressure_phase' and series.get(pool, {}).get(key) is None:
353
+ continue # the ready body's own phase (when it carries one) stands
354
+ pools[pool][key] = series.get(pool, {}).get(key)
355
+ except Failed as error:
356
+ first_failure = first_failure or str(error)
357
+ problems.append(f'/metrics: {error}')
358
+ token, why = read_token(self.token_file)
359
+ if why:
360
+ problems.append(why)
361
+ tokened = False
362
+ for pool in POOLS:
363
+ if not token: # no opt-in, or a token file that is not usable: burn and money are never shown
364
+ pools[pool].update(dict.fromkeys(TOKENED_POOL_KEYS))
365
+ overflow[pool] = empty_overflow()
366
+ elif answered:
367
+ path = f'/pools/{pool}/status'
368
+ try:
369
+ facts, money = from_status(get_json(self.control, path, token, self.opener), now)
370
+ except Failed as error: # the last burn and money of this pool are kept
371
+ problems.append(f'{path}: {error}')
372
+ continue
373
+ pools[pool].update(facts)
374
+ overflow[pool] = money if pool in MONEY_POOLS else empty_overflow()
375
+ tokened = True
376
+ if not answered:
377
+ tokened = bool(token) and self.document.get('tokened') is True
378
+ document = {'v': SCHEMA, 'up': answered, 'tokened': tokened, 'pools': pools, 'overflow': overflow,
379
+ 'error': None if answered else first_failure}
380
+ return document, problems
381
+
382
+ def cycle(self):
383
+ """One observation; throttle.json rewritten when something but `at` changed or HEARTBEAT seconds passed since
384
+ the file's `at` -> True when it wrote."""
385
+ document, problems = self.observe()
386
+ line = '; '.join(problems) or 'ok'
387
+ if line != self.said and (self.said is not None or problems):
388
+ self.journal(line)
389
+ self.said = line
390
+ now = int(self.clock())
391
+ on_disk = read_previous(self.path)
392
+ at = on_disk.get('at') if on_disk else None
393
+ if (isinstance(at, int) and 0 <= now - at < HEARTBEAT
394
+ and _without_at(on_disk) == _without_at(json.loads(json.dumps(document)))):
395
+ self.document = on_disk
396
+ return False
397
+ full = {'v': SCHEMA, 'at': now, 'up': document['up'], 'tokened': document['tokened'],
398
+ 'pools': document['pools'], 'overflow': document['overflow'], 'error': document['error']}
399
+ write_atomic(self.path, full)
400
+ self.document = full
401
+ return True
402
+
403
+
404
+ def run_loop(pulse, interval=PULSE_INTERVAL, sleep=time.sleep, clock=time.monotonic, cycles=None):
405
+ """Run `pulse.cycle()` every `interval` seconds (the time a cycle took is subtracted); `cycles` bounds it (tests)."""
406
+ done = 0
407
+ while cycles is None or done < cycles:
408
+ started = clock()
409
+ try:
410
+ pulse.cycle()
411
+ except OSError as error: # an unwritable state dir is journalled; the loop keeps going
412
+ pulse.journal(f'state not written ({error.strerror or type(error).__name__})')
413
+ done += 1
414
+ if cycles is not None and done >= cycles:
415
+ break
416
+ sleep(max(0.0, interval - (clock() - started)))
417
+ return done
418
+
419
+
420
+ def default_control(env):
421
+ raw = env.get('LANES_THROTTLE_CONTROL_PORT') or str(DEFAULT_PORT)
422
+ if not raw.isdigit() or not 0 < int(raw) < 65536:
423
+ raise ValueError(f'LANES_THROTTLE_CONTROL_PORT must be a port number, not {raw!r}')
424
+ return f'http://127.0.0.1:{int(raw)}'
425
+
426
+
427
+ def main(argv=None, env=None):
428
+ env = os.environ if env is None else env
429
+ parser = argparse.ArgumentParser(prog='throttle-pulse', description=__doc__.split('\n\n', 1)[0])
430
+ mode = parser.add_mutually_exclusive_group(required=True)
431
+ mode.add_argument('--once', action='store_true', help='one cycle: write when changed, print the document')
432
+ mode.add_argument('--loop', action='store_true', help=f'a cycle every {PULSE_INTERVAL} s until SIGTERM')
433
+ parser.add_argument('--control', help='control base URL (default http://127.0.0.1:$LANES_THROTTLE_CONTROL_PORT, '
434
+ f'else :{DEFAULT_PORT})')
435
+ parser.add_argument('--state-dir', default='~/.lz/state')
436
+ parser.add_argument('--token-file', default='~/.lz/throttle-token')
437
+ args = parser.parse_args(argv)
438
+ try:
439
+ control = args.control or default_control(env)
440
+ except ValueError as error:
441
+ parser.error(str(error))
442
+ if not re.match(r'^https?://[^/?#\s]+$', control.rstrip('/')):
443
+ parser.error(f'--control must be an http(s) base URL, not {control!r}')
444
+ pulse = Pulse(control, os.path.expanduser(args.state_dir), os.path.expanduser(args.token_file))
445
+ if args.once:
446
+ pulse.cycle()
447
+ print(json.dumps(pulse.document, indent=1))
448
+ return 0 if pulse.document.get('up') else 1
449
+ for signum in (signal.SIGTERM, signal.SIGINT):
450
+ signal.signal(signum, lambda *_: sys.exit(0))
451
+ run_loop(pulse)
452
+ return 0
453
+
454
+
455
+ if __name__ == '__main__':
456
+ sys.exit(main())