msgsearch 0.1.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.
msgsearch/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """Search your iMessage history by meaning, entirely on your own machine.
2
+
3
+ The pipeline, in the order data flows through it:
4
+
5
+ extract -> chat.db rows become readable messages, decoding the
6
+ `attributedBody` blob that holds ~86% of them
7
+ chunk -> messages become conversation windows, then the smaller
8
+ passages that actually get embedded
9
+ index -> passages become vectors; windows become a keyword index
10
+ search -> a query becomes ranked windows, via keyword search, vector
11
+ search, rank fusion and optional reranking
12
+
13
+ Nothing here sends message text anywhere. Both models run locally.
14
+ """
15
+
16
+ __version__ = "0.1.0"
17
+
18
+ __all__ = ["__version__"]
@@ -0,0 +1,59 @@
1
+ """Decode the `message.attributedBody` blob (Apple NSArchiver 'typedstream').
2
+
3
+ ~86% of rows in chat.db have text IS NULL and carry their content here instead.
4
+
5
+ Layout, per hexdump of a real row:
6
+
7
+ 04 0b "streamtyped" 81 e8 03 ... "NSString" 01 94 84 01 2b <len> <utf8 bytes>
8
+ '+' ^ varint length
9
+
10
+ The 0x2b ('+') marker introduces a length-prefixed byte string. Length is one
11
+ byte when < 0x80, otherwise 0x81 => uint16 LE, 0x82 => uint32 LE.
12
+ """
13
+
14
+ import struct
15
+
16
+ _MARKER = b"NSString"
17
+
18
+
19
+ def _read_len(buf, i):
20
+ """Return (length, next_index) for the varint length at buf[i]."""
21
+ n = buf[i]
22
+ if n < 0x80:
23
+ return n, i + 1
24
+ if n == 0x81:
25
+ return struct.unpack_from("<H", buf, i + 1)[0], i + 3
26
+ if n == 0x82:
27
+ return struct.unpack_from("<I", buf, i + 1)[0], i + 5
28
+ return None, i + 1
29
+
30
+
31
+ def decode(blob):
32
+ """Extract the message text from an attributedBody blob, or None."""
33
+ if not blob:
34
+ return None
35
+ buf = bytes(blob)
36
+
37
+ start = buf.find(_MARKER)
38
+ if start == -1:
39
+ return None
40
+ i = buf.find(b"\x2b", start + len(_MARKER))
41
+ if i == -1:
42
+ return None
43
+
44
+ length, i = _read_len(buf, i + 1)
45
+ if not length or i + length > len(buf):
46
+ return None
47
+
48
+ raw = buf[i : i + length]
49
+ # Runs of the string are UTF-8; a leading BOM-ish 0xff 0xfe marks UTF-16.
50
+ if raw[:2] == b"\xff\xfe":
51
+ return raw[2:].decode("utf-16-le", errors="replace")
52
+ return raw.decode("utf-8", errors="replace")
53
+
54
+
55
+ def message_text(text, blob):
56
+ """Prefer the plain `text` column, fall back to decoding the blob."""
57
+ if text is not None:
58
+ return text
59
+ return decode(blob)
msgsearch/chunk.py ADDED
@@ -0,0 +1,268 @@
1
+ """Group messages into conversation windows.
2
+
3
+ Embedding messages one at a time does not work for this problem. The thing you
4
+ want to find is often a reply whose text has nothing in common with your query:
5
+ someone asks for a login, and two minutes later a message arrives containing an
6
+ email address and a password but neither the word "login" nor the name of the
7
+ service. Searched alone, that message is unreachable. Searched as part of the
8
+ exchange it belongs to, it is easy to find.
9
+
10
+ So the unit of retrieval is a window: a run of messages in one conversation with
11
+ no long pause in it. Windows are cut where a real pause happened, and only split
12
+ further when they are too long for the embedding model to read.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from collections import defaultdict
18
+ from collections.abc import Iterable, Iterator, Sequence
19
+ from dataclasses import dataclass
20
+ from datetime import datetime
21
+ from itertools import pairwise
22
+
23
+ from . import config, tagging
24
+ from .extract import Message
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Window:
29
+ window_id: str
30
+ chat_id: int
31
+ chat_label: str
32
+ start: datetime
33
+ end: datetime
34
+ messages: tuple[Message, ...]
35
+
36
+ # What the embedding model reads. Link-only messages are left out because
37
+ # a bare URL has no meaning for the model to work with.
38
+ embed_text: str
39
+
40
+ # What keyword search reads, and what is shown to the user. Nothing is left
41
+ # out here, so a URL someone sent is still findable by typing part of it.
42
+ search_text: str
43
+
44
+ # (message rowid, start offset, end offset) into search_text, so a result can
45
+ # highlight the message that matched while displaying its whole context.
46
+ offsets: tuple[tuple[int, int, int], ...]
47
+
48
+ speakers: tuple[str, ...]
49
+ tags: frozenset
50
+
51
+ @property
52
+ def is_one_sided(self) -> bool:
53
+ """True if only one person spoke, which is usually a link dump."""
54
+ return len(set(self.speakers)) <= 1
55
+
56
+
57
+ def _line_cost(message: Message) -> int:
58
+ """Characters a message contributes once rendered as `Speaker: text`."""
59
+ return len(message.speaker) + 2 + len(message.text) + 1
60
+
61
+
62
+ def _rendered_cost(messages: Sequence[Message]) -> int:
63
+ """Length of these messages once rendered, including the date headers.
64
+
65
+ The headers have to be counted here. Leaving them out lets a window creep
66
+ past the budget by a few characters per day it spans.
67
+ """
68
+ total = 0
69
+ day = None
70
+ for message in messages:
71
+ this_day = message.timestamp.strftime("%Y-%m-%d")
72
+ if this_day != day:
73
+ total += len(f"[{this_day}]\n")
74
+ day = this_day
75
+ total += _line_cost(message)
76
+ return total
77
+
78
+
79
+ def _render(messages: Sequence[Message]) -> tuple[str, tuple[tuple[int, int, int], ...]]:
80
+ """Render messages as dated, speaker-labelled lines.
81
+
82
+ Returns the text and, for each message, where its body starts and ends inside
83
+ that text.
84
+ """
85
+ parts: list[str] = []
86
+ offsets: list[tuple[int, int, int]] = []
87
+ position = 0
88
+ current_day = None
89
+
90
+ for message in messages:
91
+ day = message.timestamp.strftime("%Y-%m-%d")
92
+ if day != current_day:
93
+ header = f"[{day}]\n"
94
+ parts.append(header)
95
+ position += len(header)
96
+ current_day = day
97
+
98
+ prefix = f"{message.speaker}: "
99
+ line = f"{prefix}{message.text}\n"
100
+ body_start = position + len(prefix)
101
+ parts.append(line)
102
+ position += len(line)
103
+ offsets.append((message.rowid, body_start, position - 1))
104
+
105
+ return "".join(parts), tuple(offsets)
106
+
107
+
108
+ def _split_on_pauses(
109
+ messages: Sequence[Message], gap_seconds: int
110
+ ) -> list[list[Message]]:
111
+ """Cut a conversation wherever nobody spoke for longer than `gap_seconds`."""
112
+ groups: list[list[Message]] = []
113
+ current: list[Message] = [messages[0]]
114
+
115
+ for previous, message in pairwise(messages):
116
+ if (message.timestamp - previous.timestamp).total_seconds() > gap_seconds:
117
+ groups.append(current)
118
+ current = []
119
+ current.append(message)
120
+
121
+ groups.append(current)
122
+ return groups
123
+
124
+
125
+ def _split_oversized(
126
+ messages: Sequence[Message], budget_chars: int, overlap: int
127
+ ) -> list[list[Message]]:
128
+ """Break a window that is too long for the embedding model to read.
129
+
130
+ Only windows over the budget are touched, which on real data is around 1% of
131
+ them. Each piece after the first repeats the last couple of messages of the
132
+ previous piece, so an exchange sitting on a boundary stays intact in one of
133
+ the two pieces.
134
+ """
135
+ if _rendered_cost(messages) <= budget_chars:
136
+ return [list(messages)]
137
+
138
+ pieces: list[list[Message]] = []
139
+ current: list[Message] = []
140
+
141
+ for message in messages:
142
+ # Cost is recomputed against the real rendering rather than tracked
143
+ # incrementally, because a date header appears only on the first message
144
+ # of each day and that depends on where the piece boundaries fall. These
145
+ # windows are small, so the repeated work is not worth optimising away.
146
+ if current and _rendered_cost([*current, message]) > budget_chars:
147
+ pieces.append(current)
148
+
149
+ # Carry a little context forward, but never so much that the overlap
150
+ # alone fills the next piece.
151
+ tail = list(current[-overlap:]) if overlap else []
152
+ if _rendered_cost(tail) > budget_chars // 2:
153
+ tail = []
154
+ current = tail
155
+
156
+ current.append(message)
157
+
158
+ if current:
159
+ pieces.append(current)
160
+ return pieces
161
+
162
+
163
+ def build_window(messages: Sequence[Message]) -> Window:
164
+ search_text, offsets = _render(messages)
165
+
166
+ # Link-only messages are dropped from the embedded text only. They stay in
167
+ # search_text, in the offsets and in the displayed result.
168
+ speaking = [m for m in messages if not tagging.is_bare_url(m.text)]
169
+ embed_text, _ = _render(speaking) if speaking else ("", ())
170
+
171
+ first = messages[0]
172
+ return Window(
173
+ window_id=f"{first.chat_id}:{first.rowid}",
174
+ chat_id=first.chat_id,
175
+ chat_label=first.chat_label,
176
+ start=first.timestamp,
177
+ end=messages[-1].timestamp,
178
+ messages=tuple(messages),
179
+ embed_text=embed_text,
180
+ search_text=search_text,
181
+ offsets=offsets,
182
+ speakers=tuple(m.speaker for m in messages),
183
+ tags=frozenset().union(*(tagging.tags(m.text) for m in messages)),
184
+ )
185
+
186
+
187
+ @dataclass(frozen=True)
188
+ class Passage:
189
+ """A small slice of a window, and the thing that actually gets embedded.
190
+
191
+ A window is the right unit to show a person, because it carries the context
192
+ that makes a result make sense. It is the wrong unit to embed, because a
193
+ single vector for thirty messages on eight topics represents none of them
194
+ well. Passages slide across the window in small overlapping steps so that
195
+ every message sits near the middle of at least one of them, and each passage
196
+ remembers which window it belongs to.
197
+ """
198
+
199
+ passage_id: str
200
+ window_id: str
201
+ chat_id: int
202
+ text: str
203
+ rowids: tuple[int, ...]
204
+
205
+
206
+ def passages(
207
+ window: Window, size: int | None = None, stride: int | None = None
208
+ ) -> list[Passage]:
209
+ """Slice a window into overlapping passages for embedding."""
210
+ size = size if size is not None else config.PASSAGE_MESSAGES
211
+ stride = stride if stride is not None else config.PASSAGE_STRIDE
212
+
213
+ # Link-only messages are dropped here for the same reason they are dropped
214
+ # from embed_text: a bare URL gives the model nothing to work with.
215
+ speaking = [m for m in window.messages if not tagging.is_bare_url(m.text)]
216
+ if not speaking:
217
+ return []
218
+
219
+ if len(speaking) <= size:
220
+ starts = [0]
221
+ else:
222
+ starts = list(range(0, len(speaking) - size + 1, stride))
223
+ # Make sure the final messages are covered even when the stride does not
224
+ # divide the window evenly.
225
+ last = len(speaking) - size
226
+ if starts[-1] != last:
227
+ starts.append(last)
228
+
229
+ out = []
230
+ for start in starts:
231
+ selected = speaking[start : start + size]
232
+ text, _ = _render(selected)
233
+ out.append(
234
+ Passage(
235
+ passage_id=f"{window.window_id}#{start}",
236
+ window_id=window.window_id,
237
+ chat_id=window.chat_id,
238
+ text=text,
239
+ rowids=tuple(m.rowid for m in selected),
240
+ )
241
+ )
242
+ return out
243
+
244
+
245
+ def windows(
246
+ messages: Iterable[Message],
247
+ gap_seconds: int | None = None,
248
+ token_budget: int | None = None,
249
+ overlap: int | None = None,
250
+ ) -> Iterator[Window]:
251
+ """Group messages into conversation windows, oldest first."""
252
+ gap_seconds = gap_seconds if gap_seconds is not None else config.WINDOW_GAP_SECONDS
253
+ token_budget = (
254
+ token_budget if token_budget is not None else config.WINDOW_TOKEN_BUDGET
255
+ )
256
+ overlap = overlap if overlap is not None else config.WINDOW_OVERLAP_MESSAGES
257
+ budget_chars = token_budget * config.CHARS_PER_TOKEN
258
+
259
+ by_chat: dict[int, list[Message]] = defaultdict(list)
260
+ for message in messages:
261
+ by_chat[message.chat_id].append(message)
262
+
263
+ for chat_id in sorted(by_chat):
264
+ conversation = sorted(by_chat[chat_id], key=lambda m: m.timestamp)
265
+ for group in _split_on_pauses(conversation, gap_seconds):
266
+ for piece in _split_oversized(group, budget_chars, overlap):
267
+ if piece:
268
+ yield build_window(piece)
msgsearch/cli.py ADDED
@@ -0,0 +1,304 @@
1
+ """Command line interface.
2
+
3
+ All argument parsing lives here so the other modules stay importable libraries
4
+ with no opinion about how they are invoked. One command with subcommands, rather
5
+ than a directory of scripts, because it gives people a single thing to remember
6
+ and a single place to find help:
7
+
8
+ msgsearch doctor check this machine is set up correctly
9
+ msgsearch explore what is in your database
10
+ msgsearch index build the search index
11
+ msgsearch search "a query" search it
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import sys
18
+ from pathlib import Path
19
+
20
+ from . import __version__, config
21
+
22
+
23
+ def _add_index_command(subparsers) -> None:
24
+ parser = subparsers.add_parser(
25
+ "index",
26
+ help="build the search index",
27
+ description="Read the message database, group messages into conversation "
28
+ "windows, embed the passages inside them, and write the index.",
29
+ )
30
+ parser.add_argument(
31
+ "--chat",
32
+ default=config.TESTBED_CHAT,
33
+ metavar="ID",
34
+ help="restrict to one conversation, by phone number or email "
35
+ "(default: $MSGSEARCH_TESTBED_CHAT, or all conversations)",
36
+ )
37
+ parser.add_argument("--index-dir", metavar="DIR", help="where to write the index")
38
+ parser.add_argument("--batch-size", type=int, default=32, metavar="N")
39
+ parser.add_argument(
40
+ "--rebuild",
41
+ action="store_true",
42
+ help="re-embed everything instead of reusing unchanged passages",
43
+ )
44
+ parser.set_defaults(handler=_run_index)
45
+
46
+
47
+ def _run_index(args) -> int:
48
+ from .index import build
49
+
50
+ build(
51
+ chat_identifier=args.chat,
52
+ index_dir=args.index_dir,
53
+ batch_size=args.batch_size,
54
+ rebuild=args.rebuild,
55
+ )
56
+ return 0
57
+
58
+
59
+ def _add_search_command(subparsers) -> None:
60
+ parser = subparsers.add_parser(
61
+ "search",
62
+ help="search the index",
63
+ description="Find conversations by meaning as well as by keyword.",
64
+ )
65
+ parser.add_argument("query", nargs="+", help="what you are looking for")
66
+ parser.add_argument("--limit", type=int, default=config.DEFAULT_LIMIT, metavar="N")
67
+ parser.add_argument(
68
+ "--chat", metavar="TEXT", help="restrict to matching conversations"
69
+ )
70
+ parser.add_argument(
71
+ "--from", dest="from_", metavar="WHO", help="restrict to a speaker"
72
+ )
73
+ parser.add_argument("--after", metavar="YYYY-MM-DD")
74
+ parser.add_argument("--before", metavar="YYYY-MM-DD")
75
+ parser.add_argument(
76
+ "--type",
77
+ metavar="TAG",
78
+ help="restrict by shape: credential, credential_talk, email, phone, url, address",
79
+ )
80
+ parser.add_argument("--index-dir", metavar="DIR")
81
+ parser.add_argument("--snippet", type=int, default=1200, metavar="CHARS")
82
+ parser.add_argument(
83
+ "--full", action="store_true", help="show the whole window, not just the match"
84
+ )
85
+ parser.add_argument(
86
+ "--rerank",
87
+ dest="no_rerank",
88
+ action="store_false",
89
+ default=not config.RERANK_ENABLED,
90
+ help="run the cross-encoder (off by default; it measurably hurts)",
91
+ )
92
+ parser.add_argument("--no-rerank", dest="no_rerank", action="store_true")
93
+ parser.add_argument("--no-dense", action="store_true", help="keyword search only")
94
+ parser.add_argument("--no-bm25", action="store_true", help="vector search only")
95
+ parser.set_defaults(handler=_run_search)
96
+
97
+
98
+ def _run_search(args) -> int:
99
+ from .search import format_result, search
100
+
101
+ query = " ".join(args.query)
102
+ try:
103
+ results = search(query, args)
104
+ except FileNotFoundError as error:
105
+ print(error, file=sys.stderr)
106
+ return 1
107
+
108
+ if not results:
109
+ print("no results")
110
+ return 0
111
+
112
+ print(f"{len(results)} result(s) for {query!r}")
113
+ for position, result in enumerate(results, start=1):
114
+ print(format_result(position, result, args.snippet, full=args.full))
115
+ return 0
116
+
117
+
118
+ def _add_contacts_command(subparsers) -> None:
119
+ parser = subparsers.add_parser(
120
+ "contacts",
121
+ help="map phone numbers and emails to names",
122
+ description="Speakers appear by name rather than by phone number, which "
123
+ "reads better and embeds better. Names come from an alias file you "
124
+ "control; this command creates and inspects it.",
125
+ )
126
+ parser.add_argument(
127
+ "--import",
128
+ dest="import_path",
129
+ metavar="FILE.vcf",
130
+ help="merge names from a vCard export (Contacts.app: File -> Export)",
131
+ )
132
+ parser.add_argument(
133
+ "--from-addressbook",
134
+ action="store_true",
135
+ help="merge names from macOS Contacts (needs Contacts permission)",
136
+ )
137
+ parser.add_argument(
138
+ "--template",
139
+ type=int,
140
+ nargs="?",
141
+ const=25,
142
+ metavar="N",
143
+ help="write an alias file stub for the N busiest unnamed handles, for you "
144
+ "to fill in (default 25)",
145
+ )
146
+ parser.add_argument(
147
+ "--unresolved",
148
+ type=int,
149
+ nargs="?",
150
+ const=25,
151
+ metavar="N",
152
+ help="list the N busiest handles that still have no name (default 25)",
153
+ )
154
+ parser.set_defaults(handler=_run_contacts)
155
+
156
+
157
+ def _run_contacts(args) -> int:
158
+ from . import contacts as contacts_module
159
+ from .extract import connect
160
+
161
+ path = contacts_module.aliases_path()
162
+ mapping = contacts_module.load_mapping()
163
+
164
+ imported = {}
165
+ if args.import_path:
166
+ imported = contacts_module.read_vcard_file(Path(args.import_path))
167
+ print(f"read {len(imported):,} handles from {args.import_path}")
168
+ elif args.from_addressbook:
169
+ try:
170
+ imported = contacts_module.read_addressbook()
171
+ except (PermissionError, FileNotFoundError) as error:
172
+ print(error, file=sys.stderr)
173
+ return 1
174
+ print(f"read {len(imported):,} handles from macOS Contacts")
175
+
176
+ if imported:
177
+ added = {k: v for k, v in imported.items() if k not in mapping}
178
+ mapping.update(imported)
179
+ contacts_module.save(mapping, path)
180
+ print(f"wrote {len(mapping):,} names to {path} ({len(added):,} new)")
181
+
182
+ # Report each source separately, because "no names" has two very different
183
+ # causes: the Contacts permission is not granted, or it is granted and simply
184
+ # has no entry for these handles.
185
+ try:
186
+ from_contacts = contacts_module.read_addressbook()
187
+ contacts_status = f"{len(from_contacts):,} entries"
188
+ except PermissionError:
189
+ from_contacts = {}
190
+ contacts_status = (
191
+ "not permitted — grant Contacts access to the app running msgsearch "
192
+ "under System Settings > Privacy & Security > Contacts"
193
+ )
194
+ except (FileNotFoundError, OSError) as error:
195
+ from_contacts = {}
196
+ contacts_status = str(error)
197
+
198
+ lookup = contacts_module.Contacts.load()
199
+ db = connect()
200
+ try:
201
+ volumes = contacts_module.handle_volumes(db)
202
+ finally:
203
+ db.close()
204
+
205
+ named = [(h, n) for h, n in volumes if lookup.name(h)]
206
+ unnamed = [(h, n) for h, n in volumes if not lookup.name(h)]
207
+ covered = sum(n for _, n in named)
208
+ total = sum(n for _, n in volumes) or 1
209
+
210
+ overrides = {k: v for k, v in mapping.items() if v}
211
+ print(f"\nmacOS Contacts : {contacts_status}")
212
+ print(f"alias file : {len(overrides):,} names in {path}")
213
+ print(
214
+ f"resolved : {len(named):,} of {len(volumes):,} handles, "
215
+ f"covering {100 * covered / total:.1f}% of received messages"
216
+ )
217
+
218
+ if args.template:
219
+ stub = dict(mapping)
220
+ for handle, _ in unnamed[: args.template]:
221
+ stub.setdefault(handle, "")
222
+ contacts_module.save(stub, path)
223
+ print(
224
+ f"\nwrote a stub for {min(args.template, len(unnamed))} handles to {path}"
225
+ "\nFill in the names and re-run 'msgsearch index'. Entries left empty "
226
+ "are ignored."
227
+ )
228
+ return 0
229
+
230
+ if args.unresolved:
231
+ print(f"\nbusiest handles with no name (top {args.unresolved}):")
232
+ for handle, count in unnamed[: args.unresolved]:
233
+ print(f" {count:>7,} {handle}")
234
+ print(f"\nAdd them to {path} as a JSON object of handle -> name.")
235
+ return 0
236
+
237
+
238
+ def _add_doctor_command(subparsers) -> None:
239
+ parser = subparsers.add_parser(
240
+ "doctor",
241
+ help="check that this machine is set up correctly",
242
+ description="Verify the interpreter, PyTorch, the message database, the "
243
+ "embedding model and the index, and say how to fix whatever is wrong. "
244
+ "Prints no message content.",
245
+ )
246
+ parser.set_defaults(handler=_run_doctor)
247
+
248
+
249
+ def _run_doctor(args) -> int:
250
+ from .doctor import run
251
+
252
+ return run()
253
+
254
+
255
+ def _add_explore_command(subparsers) -> None:
256
+ parser = subparsers.add_parser(
257
+ "explore",
258
+ help="report what is in a message database",
259
+ description="Structural reconnaissance: message counts, how many rows hide "
260
+ "their text in attributedBody, the date range, and the busiest chats. "
261
+ "Prints no message content.",
262
+ )
263
+ parser.set_defaults(handler=_run_explore)
264
+
265
+
266
+ def _run_explore(args) -> int:
267
+ from .explore import main as explore_main
268
+
269
+ explore_main()
270
+ return 0
271
+
272
+
273
+ def build_parser() -> argparse.ArgumentParser:
274
+ parser = argparse.ArgumentParser(
275
+ prog="msgsearch",
276
+ description="Search your iMessage history by meaning, entirely on your "
277
+ "own machine.",
278
+ epilog="Run 'msgsearch <command> --help' for details of a command.",
279
+ )
280
+ parser.add_argument("--version", action="version", version=f"msgsearch {__version__}")
281
+
282
+ subparsers = parser.add_subparsers(dest="command", metavar="<command>")
283
+ _add_contacts_command(subparsers)
284
+ _add_doctor_command(subparsers)
285
+ _add_explore_command(subparsers)
286
+ _add_index_command(subparsers)
287
+ _add_search_command(subparsers)
288
+ return parser
289
+
290
+
291
+ def main(argv: list[str] | None = None) -> int:
292
+ parser = build_parser()
293
+ args = parser.parse_args(argv)
294
+ if not getattr(args, "handler", None):
295
+ parser.print_help()
296
+ return 1
297
+ try:
298
+ return args.handler(args)
299
+ except KeyboardInterrupt:
300
+ return 130
301
+
302
+
303
+ if __name__ == "__main__":
304
+ raise SystemExit(main())