Brain4Agent 0.0.1__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.
- brain4agent/__init__.py +38 -0
- brain4agent/__main__.py +8 -0
- brain4agent/cli.py +402 -0
- brain4agent/evidence.py +320 -0
- brain4agent/extract.py +230 -0
- brain4agent/graph.py +100 -0
- brain4agent/judge.py +160 -0
- brain4agent/llm.py +144 -0
- brain4agent/mcp_server.py +437 -0
- brain4agent/read.py +135 -0
- brain4agent/simhash.py +90 -0
- brain4agent/store.py +1190 -0
- brain4agent/tokenize.py +430 -0
- brain4agent-0.0.1.dist-info/METADATA +702 -0
- brain4agent-0.0.1.dist-info/RECORD +19 -0
- brain4agent-0.0.1.dist-info/WHEEL +5 -0
- brain4agent-0.0.1.dist-info/entry_points.txt +2 -0
- brain4agent-0.0.1.dist-info/licenses/LICENSE +202 -0
- brain4agent-0.0.1.dist-info/top_level.txt +1 -0
brain4agent/__init__.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Brain4Agent -- long-term memory for agents.
|
|
2
|
+
|
|
3
|
+
Chinese-first, single SQLite file, no model in the core path.
|
|
4
|
+
|
|
5
|
+
from brain4agent import MemoryStore
|
|
6
|
+
|
|
7
|
+
with MemoryStore("brain.db") as store:
|
|
8
|
+
sid = store.new_session()
|
|
9
|
+
store.add("中文查询搜不到记忆", session_id=sid, role="user")
|
|
10
|
+
store.add("根因在 keywords.py 第 443 行", session_id=sid, role="assistant")
|
|
11
|
+
|
|
12
|
+
result = store.search("中文查询为什么搜不到")
|
|
13
|
+
print(result.best().missing_terms)
|
|
14
|
+
|
|
15
|
+
The search returns evidence, not a verdict: whether a session answers the
|
|
16
|
+
question is the caller's call. See docs/decisions.md D-011 for why.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from brain4agent.evidence import Evidence, JudgedResult, SearchResult, describe
|
|
20
|
+
from brain4agent.judge import Judge, JudgeError
|
|
21
|
+
from brain4agent.store import Memory, MemoryStore
|
|
22
|
+
from brain4agent.tokenize import BM25, tokenize, word_tokens
|
|
23
|
+
|
|
24
|
+
__version__ = "0.0.1"
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"BM25",
|
|
28
|
+
"Evidence",
|
|
29
|
+
"Judge",
|
|
30
|
+
"JudgeError",
|
|
31
|
+
"JudgedResult",
|
|
32
|
+
"Memory",
|
|
33
|
+
"MemoryStore",
|
|
34
|
+
"SearchResult",
|
|
35
|
+
"describe",
|
|
36
|
+
"tokenize",
|
|
37
|
+
"word_tokens",
|
|
38
|
+
]
|
brain4agent/__main__.py
ADDED
brain4agent/cli.py
ADDED
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Command line entry point.
|
|
2
|
+
|
|
3
|
+
python -m brain4agent add "中文查询搜不到记忆" --role user
|
|
4
|
+
python -m brain4agent search "中文查询为什么搜不到"
|
|
5
|
+
python -m brain4agent stats
|
|
6
|
+
|
|
7
|
+
The search output deliberately prints ``missing`` when there is one. It is the
|
|
8
|
+
single most useful thing this tool can tell a person: not that it found nothing,
|
|
9
|
+
but which word in the question the corpus never contained.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import contextlib
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from brain4agent.evidence import describe
|
|
22
|
+
from brain4agent.read import read as read_memory
|
|
23
|
+
from brain4agent.store import MemoryStore
|
|
24
|
+
|
|
25
|
+
DEFAULT_DB = "brain.db"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def default_db() -> Path:
|
|
29
|
+
"""``BRAIN4AGENT_DB`` if set, else ``./brain.db``.
|
|
30
|
+
|
|
31
|
+
Relative-by-default on purpose: a memory database that quietly writes to the
|
|
32
|
+
home directory is hard to find and easy to lose.
|
|
33
|
+
"""
|
|
34
|
+
env = os.environ.get("BRAIN4AGENT_DB")
|
|
35
|
+
return Path(env) if env else Path(DEFAULT_DB)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _open(args: argparse.Namespace) -> MemoryStore:
|
|
39
|
+
return MemoryStore(args.db)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_add(args: argparse.Namespace) -> int:
|
|
43
|
+
store = _open(args)
|
|
44
|
+
try:
|
|
45
|
+
sid = args.session or store.new_session()
|
|
46
|
+
store.add(args.content, session_id=sid, role=args.role, tags=tuple(args.tags))
|
|
47
|
+
print(f"stored in session {sid}")
|
|
48
|
+
return 0
|
|
49
|
+
finally:
|
|
50
|
+
store.close()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_remember(args: argparse.Namespace) -> int:
|
|
54
|
+
"""Store a turn by distilling it into durable memories.
|
|
55
|
+
|
|
56
|
+
Slower than add by design: one model call per turn. The trade is that a
|
|
57
|
+
week of chit-chat does not end up competing with real memories at search
|
|
58
|
+
time. Without an API key this degrades to storing the raw turn.
|
|
59
|
+
"""
|
|
60
|
+
store = _open(args)
|
|
61
|
+
try:
|
|
62
|
+
ids = store.remember(
|
|
63
|
+
args.content,
|
|
64
|
+
session_id=args.session,
|
|
65
|
+
role=args.role,
|
|
66
|
+
tags=tuple(args.tags),
|
|
67
|
+
)
|
|
68
|
+
if not ids:
|
|
69
|
+
print("nothing durable in that turn -- not stored")
|
|
70
|
+
return 0
|
|
71
|
+
first = store._by_session_memory(ids[0])
|
|
72
|
+
where = first.session_id if first else args.session or "?"
|
|
73
|
+
print(f"stored {len(ids)} memories in session {where}")
|
|
74
|
+
for mid in ids:
|
|
75
|
+
mem = store._by_session_memory(mid)
|
|
76
|
+
if mem:
|
|
77
|
+
print(f" [{mem.kind} {mem.importance:.1f}] {mem.content[:90]}")
|
|
78
|
+
return 0
|
|
79
|
+
finally:
|
|
80
|
+
store.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def cmd_exchange(args: argparse.Namespace) -> int:
|
|
84
|
+
store = _open(args)
|
|
85
|
+
try:
|
|
86
|
+
sid = store.add_exchange(user=args.user, assistant=args.assistant, session_id=args.session)
|
|
87
|
+
print(f"stored exchange in session {sid}")
|
|
88
|
+
return 0
|
|
89
|
+
finally:
|
|
90
|
+
store.close()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _read_prompt() -> str:
|
|
94
|
+
"""Read a prompt from stdin, whatever encoding the shell handed us.
|
|
95
|
+
|
|
96
|
+
Windows is the reason this exists. ``sys.stdin.read()`` decodes with the
|
|
97
|
+
locale codec (cp936 on a Chinese system), so UTF-8 bytes arrive as mojibake
|
|
98
|
+
and a Chinese prompt matches nothing. PowerShell may also hand over UTF-16
|
|
99
|
+
with a BOM when it is piping a string rather than a file. Neither failure is
|
|
100
|
+
loud: the search just returns nothing.
|
|
101
|
+
"""
|
|
102
|
+
raw = sys.stdin.buffer.read()
|
|
103
|
+
if not raw.strip():
|
|
104
|
+
return ""
|
|
105
|
+
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
|
|
106
|
+
return raw.decode("utf-16", errors="replace").strip()
|
|
107
|
+
return raw.decode("utf-8-sig", errors="replace").strip()
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def cmd_recall_context(args: argparse.Namespace) -> int:
|
|
111
|
+
"""Print memory relevant to a prompt read from stdin, ready to inject.
|
|
112
|
+
|
|
113
|
+
MCP is pull-only: a server cannot volunteer a memory, so an agent sees one
|
|
114
|
+
only when it decides to call ``b4a_recall``. That covers "the user asked
|
|
115
|
+
about something from before" and misses "the task is about to touch
|
|
116
|
+
something we have context on". A client can close that gap with a hook --
|
|
117
|
+
run this before handing the prompt to the model and prepend the output --
|
|
118
|
+
but only if the output is already shaped for injection and silent when
|
|
119
|
+
there is nothing worth saying.
|
|
120
|
+
|
|
121
|
+
Silence is the common case. A hook that emits "no memories found" every turn
|
|
122
|
+
trains the model to skip the block entirely.
|
|
123
|
+
"""
|
|
124
|
+
prompt = _read_prompt()
|
|
125
|
+
if not prompt:
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
store = _open(args)
|
|
129
|
+
blocks: list[str] = []
|
|
130
|
+
try:
|
|
131
|
+
result = store.search(prompt, top_k=args.top_k, tags=tuple(args.tags) or None)
|
|
132
|
+
if result.empty:
|
|
133
|
+
return 0
|
|
134
|
+
|
|
135
|
+
header = (
|
|
136
|
+
"## Possibly relevant memory\n\n"
|
|
137
|
+
"Excerpts from earlier sessions on this machine, retrieved by keyword\n"
|
|
138
|
+
"match. They may be about a different task. Use them only if they help,\n"
|
|
139
|
+
"and say when you are relying on one.\n"
|
|
140
|
+
)
|
|
141
|
+
used = len(header)
|
|
142
|
+
for e in result.evidence:
|
|
143
|
+
text = store.session_text(e.session_id).strip()
|
|
144
|
+
if not text:
|
|
145
|
+
continue
|
|
146
|
+
block = f"\n[{e.session_id} · coverage {e.coverage:.2f}]\n{text}"
|
|
147
|
+
if used + len(block) > args.max_chars:
|
|
148
|
+
remaining = args.max_chars - used
|
|
149
|
+
if remaining < 200:
|
|
150
|
+
break
|
|
151
|
+
block = block[:remaining] + "\n…"
|
|
152
|
+
blocks.append(block)
|
|
153
|
+
used += len(block)
|
|
154
|
+
if used >= args.max_chars:
|
|
155
|
+
break
|
|
156
|
+
finally:
|
|
157
|
+
store.close()
|
|
158
|
+
|
|
159
|
+
if not blocks:
|
|
160
|
+
return 0
|
|
161
|
+
sys.stdout.write(header + "".join(blocks) + "\n")
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_search(args: argparse.Namespace) -> int:
|
|
166
|
+
store = _open(args)
|
|
167
|
+
try:
|
|
168
|
+
result = store.search(args.query, top_k=args.top_k, tags=tuple(args.tags) or None)
|
|
169
|
+
if result.empty:
|
|
170
|
+
print(f"no evidence for {args.query!r}")
|
|
171
|
+
print(f"({result.sessions_searched} sessions searched)")
|
|
172
|
+
if result.corpus_missing:
|
|
173
|
+
# The strongest thing an empty result can say.
|
|
174
|
+
print(f"corpus has no: {', '.join(result.corpus_missing)}")
|
|
175
|
+
return 1
|
|
176
|
+
|
|
177
|
+
print(describe(result))
|
|
178
|
+
for e in result.evidence:
|
|
179
|
+
print()
|
|
180
|
+
print(f"--- {e.session_id} score={e.score:.2f} coverage={e.coverage:.2f} ---")
|
|
181
|
+
if e.missing_terms:
|
|
182
|
+
print(f"this session lacks: {', '.join(e.missing_terms)}")
|
|
183
|
+
print(store.session_text(e.session_id))
|
|
184
|
+
return 0
|
|
185
|
+
finally:
|
|
186
|
+
store.close()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def cmd_import(args: argparse.Namespace) -> int:
|
|
190
|
+
"""Bulk-load turns from JSONL, one object per line.
|
|
191
|
+
|
|
192
|
+
Expected shape, all keys optional except content:
|
|
193
|
+
|
|
194
|
+
{"session_id": "s1", "role": "assistant", "content": "..."}
|
|
195
|
+
|
|
196
|
+
A missing session_id starts a new session. Imported as one transaction --
|
|
197
|
+
a thousand individual commits costs more than the thousand writes.
|
|
198
|
+
"""
|
|
199
|
+
path = Path(args.path)
|
|
200
|
+
if not path.exists():
|
|
201
|
+
print(f"no such file: {path}", file=sys.stderr)
|
|
202
|
+
return 1
|
|
203
|
+
|
|
204
|
+
store = _open(args)
|
|
205
|
+
try:
|
|
206
|
+
turns = 0
|
|
207
|
+
sessions: set[str] = set()
|
|
208
|
+
# One transaction for the whole file. Any bad line aborts the import and
|
|
209
|
+
# rolls back -- a half-imported history is worse than none, because the
|
|
210
|
+
# caller cannot tell how far it got.
|
|
211
|
+
try:
|
|
212
|
+
with store.batch(), path.open("r", encoding="utf-8-sig") as fh:
|
|
213
|
+
# utf-8-sig, and streamed rather than read whole: hand-written
|
|
214
|
+
# files often carry a BOM, and an import is the one operation
|
|
215
|
+
# likely to be pointed at a large file.
|
|
216
|
+
for lineno, line in enumerate(fh, 1):
|
|
217
|
+
line = line.strip()
|
|
218
|
+
if not line:
|
|
219
|
+
continue
|
|
220
|
+
try:
|
|
221
|
+
obj = json.loads(line)
|
|
222
|
+
except json.JSONDecodeError as exc:
|
|
223
|
+
raise ValueError(f"line {lineno}: {exc}") from exc
|
|
224
|
+
if not isinstance(obj, dict):
|
|
225
|
+
raise ValueError(
|
|
226
|
+
f"line {lineno}: expected a JSON object, got {type(obj).__name__}"
|
|
227
|
+
)
|
|
228
|
+
content = obj.get("content")
|
|
229
|
+
if not isinstance(content, str):
|
|
230
|
+
raise ValueError(f"line {lineno}: 'content' must be a string")
|
|
231
|
+
content = content.strip()
|
|
232
|
+
if not content:
|
|
233
|
+
continue
|
|
234
|
+
sid = obj.get("session_id") or store.new_session()
|
|
235
|
+
store.add(content, session_id=sid, role=obj.get("role") or "user")
|
|
236
|
+
sessions.add(sid)
|
|
237
|
+
turns += 1
|
|
238
|
+
except ValueError as exc:
|
|
239
|
+
print(f"import aborted, nothing written: {exc}", file=sys.stderr)
|
|
240
|
+
return 1
|
|
241
|
+
print(f"imported {turns} turns across {len(sessions)} sessions")
|
|
242
|
+
return 0
|
|
243
|
+
finally:
|
|
244
|
+
store.close()
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def cmd_ask(args: argparse.Namespace) -> int:
|
|
248
|
+
"""Retrieve then read: an answer instead of a pile of sessions."""
|
|
249
|
+
store = _open(args)
|
|
250
|
+
try:
|
|
251
|
+
result = read_memory(store, args.query, top_k=args.top_k)
|
|
252
|
+
if result.answer is None:
|
|
253
|
+
print(
|
|
254
|
+
"no model configured (set BRAIN4AGENT_LLM_KEY); use 'search' to get the evidence",
|
|
255
|
+
file=sys.stderr,
|
|
256
|
+
)
|
|
257
|
+
return 1
|
|
258
|
+
print(result.answer)
|
|
259
|
+
return 0
|
|
260
|
+
finally:
|
|
261
|
+
store.close()
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def cmd_sessions(args: argparse.Namespace) -> int:
|
|
265
|
+
store = _open(args)
|
|
266
|
+
try:
|
|
267
|
+
if not store._by_session:
|
|
268
|
+
print("(no sessions)")
|
|
269
|
+
return 0
|
|
270
|
+
for sid in store._by_session:
|
|
271
|
+
turns = store.session(sid)
|
|
272
|
+
first = turns[0].content[:40].replace("\n", " ")
|
|
273
|
+
print(f"{sid} {len(turns):2d} turns {first}")
|
|
274
|
+
return 0
|
|
275
|
+
finally:
|
|
276
|
+
store.close()
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def cmd_stats(args: argparse.Namespace) -> int:
|
|
280
|
+
store = _open(args)
|
|
281
|
+
try:
|
|
282
|
+
st = store.stats()
|
|
283
|
+
print(f"turns {st['turns']}")
|
|
284
|
+
print(f"sessions {st['sessions']}")
|
|
285
|
+
print(f"terms {st['index_terms']}")
|
|
286
|
+
print(f"db {args.db}")
|
|
287
|
+
return 0
|
|
288
|
+
finally:
|
|
289
|
+
store.close()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def cmd_prune(args: argparse.Namespace) -> int:
|
|
293
|
+
"""Keep only the most recently written sessions."""
|
|
294
|
+
store = _open(args)
|
|
295
|
+
try:
|
|
296
|
+
before = store.stats()["sessions"]
|
|
297
|
+
removed = store.prune(keep_sessions=args.keep)
|
|
298
|
+
print(f"removed {removed} sessions, {before} -> {store.stats()['sessions']}")
|
|
299
|
+
return 0
|
|
300
|
+
finally:
|
|
301
|
+
store.close()
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def cmd_forget(args: argparse.Namespace) -> int:
|
|
305
|
+
store = _open(args)
|
|
306
|
+
try:
|
|
307
|
+
removed = store.forget_session(args.session)
|
|
308
|
+
print(f"removed {removed} turns from {args.session}")
|
|
309
|
+
return 0 if removed else 1
|
|
310
|
+
finally:
|
|
311
|
+
store.close()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
315
|
+
p = argparse.ArgumentParser(
|
|
316
|
+
prog="brain4agent",
|
|
317
|
+
description="Long-term memory for agents. Chinese-first, no model in the core path.",
|
|
318
|
+
)
|
|
319
|
+
p.add_argument("--db", type=Path, default=default_db(), help="database path")
|
|
320
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
321
|
+
|
|
322
|
+
a = sub.add_parser("add", help="store one turn")
|
|
323
|
+
a.add_argument("content")
|
|
324
|
+
a.add_argument("--session", "-s", default=None)
|
|
325
|
+
a.add_argument("--role", default="user", choices=["user", "assistant", "system"])
|
|
326
|
+
a.add_argument("--tag", action="append", dest="tags", default=[], help="label; repeatable")
|
|
327
|
+
a.set_defaults(func=cmd_add)
|
|
328
|
+
|
|
329
|
+
r = sub.add_parser("remember", help="distil a turn into durable memories")
|
|
330
|
+
r.add_argument("content")
|
|
331
|
+
r.add_argument("--session", "-s", default=None)
|
|
332
|
+
r.add_argument("--role", default="user", choices=["user", "assistant", "system"])
|
|
333
|
+
r.add_argument("--tag", action="append", dest="tags", default=[], help="label; repeatable")
|
|
334
|
+
r.set_defaults(func=cmd_remember)
|
|
335
|
+
|
|
336
|
+
e = sub.add_parser("exchange", help="store a user/assistant pair as one session")
|
|
337
|
+
e.add_argument("user")
|
|
338
|
+
e.add_argument("assistant")
|
|
339
|
+
e.add_argument("--session", "-s", default=None)
|
|
340
|
+
e.set_defaults(func=cmd_exchange)
|
|
341
|
+
|
|
342
|
+
s = sub.add_parser("search", help="search and show evidence")
|
|
343
|
+
s.add_argument("query")
|
|
344
|
+
s.add_argument("--top-k", "-k", type=int, default=3)
|
|
345
|
+
s.add_argument("--tag", action="append", dest="tags", default=[], help="filter; repeatable")
|
|
346
|
+
s.set_defaults(func=cmd_search)
|
|
347
|
+
|
|
348
|
+
rc = sub.add_parser(
|
|
349
|
+
"recall-context", help="print memory relevant to a prompt on stdin (for hooks)"
|
|
350
|
+
)
|
|
351
|
+
rc.add_argument("--top-k", "-k", type=int, default=3)
|
|
352
|
+
rc.add_argument("--max-chars", type=int, default=1500)
|
|
353
|
+
rc.add_argument("--tag", action="append", dest="tags", default=[])
|
|
354
|
+
rc.set_defaults(func=cmd_recall_context)
|
|
355
|
+
|
|
356
|
+
q = sub.add_parser("ask", help="retrieve and answer in one step")
|
|
357
|
+
q.add_argument("query")
|
|
358
|
+
q.add_argument("--top-k", "-k", type=int, default=5)
|
|
359
|
+
q.set_defaults(func=cmd_ask)
|
|
360
|
+
|
|
361
|
+
i = sub.add_parser("import", help="bulk-load turns from JSONL")
|
|
362
|
+
i.add_argument("path")
|
|
363
|
+
i.set_defaults(func=cmd_import)
|
|
364
|
+
|
|
365
|
+
sub.add_parser("sessions", help="list sessions").set_defaults(func=cmd_sessions)
|
|
366
|
+
sub.add_parser("stats", help="show counts").set_defaults(func=cmd_stats)
|
|
367
|
+
|
|
368
|
+
pr = sub.add_parser("prune", help="keep only the newest N sessions")
|
|
369
|
+
pr.add_argument("--keep", type=int, required=True)
|
|
370
|
+
pr.set_defaults(func=cmd_prune)
|
|
371
|
+
|
|
372
|
+
f = sub.add_parser("forget", help="delete a session")
|
|
373
|
+
f.add_argument("session")
|
|
374
|
+
f.set_defaults(func=cmd_forget)
|
|
375
|
+
|
|
376
|
+
return p
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _force_utf8_output() -> None:
|
|
380
|
+
"""Write UTF-8 to stdout/stderr, and never crash on an unencodable character.
|
|
381
|
+
|
|
382
|
+
On a Chinese Windows console the default encoding is cp936, so printing a
|
|
383
|
+
memory containing an emoji raised UnicodeEncodeError and exited non-zero
|
|
384
|
+
after the work had already succeeded. errors="replace" keeps the exit code
|
|
385
|
+
honest at the cost of a garbled glyph.
|
|
386
|
+
|
|
387
|
+
Note this cannot fix argv: Python decoded the command line with the locale
|
|
388
|
+
encoding before this ran. Set PYTHONUTF8=1 on Windows for full support.
|
|
389
|
+
"""
|
|
390
|
+
for stream in (sys.stdout, sys.stderr):
|
|
391
|
+
with contextlib.suppress(AttributeError, ValueError): # exotic streams
|
|
392
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def main(argv: list[str] | None = None) -> int:
|
|
396
|
+
_force_utf8_output()
|
|
397
|
+
args = build_parser().parse_args(argv)
|
|
398
|
+
return int(args.func(args))
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
if __name__ == "__main__":
|
|
402
|
+
sys.exit(main())
|