sys-buddy 1.2.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.
- sys_buddy/__init__.py +22 -0
- sys_buddy/admin.py +358 -0
- sys_buddy/api.py +844 -0
- sys_buddy/audit.py +34 -0
- sys_buddy/cli.py +419 -0
- sys_buddy/config.py +68 -0
- sys_buddy/contracts.py +200 -0
- sys_buddy/db.py +310 -0
- sys_buddy/gui.py +270 -0
- sys_buddy/gui_app.html +1389 -0
- sys_buddy/http_middleware.py +94 -0
- sys_buddy/identity.py +115 -0
- sys_buddy/join.html +456 -0
- sys_buddy/middleware.py +137 -0
- sys_buddy/onboarding.py +509 -0
- sys_buddy/pairing.py +293 -0
- sys_buddy/readiness.py +302 -0
- sys_buddy/rules.py +107 -0
- sys_buddy/server.py +61 -0
- sys_buddy/service.py +467 -0
- sys_buddy/slack.py +55 -0
- sys_buddy/state.py +1364 -0
- sys_buddy/todos.py +730 -0
- sys_buddy/tools.py +722 -0
- sys_buddy/ui.html +1346 -0
- sys_buddy/updates.py +110 -0
- sys_buddy-1.2.0.dist-info/METADATA +250 -0
- sys_buddy-1.2.0.dist-info/RECORD +31 -0
- sys_buddy-1.2.0.dist-info/WHEEL +4 -0
- sys_buddy-1.2.0.dist-info/entry_points.txt +2 -0
- sys_buddy-1.2.0.dist-info/licenses/LICENSE +21 -0
sys_buddy/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""sys-buddy — an authenticated, contract-enforcing MCP broker for cross-human
|
|
2
|
+
AI agent collaboration.
|
|
3
|
+
|
|
4
|
+
``__version__`` is derived from the installed package metadata (which pip/uv write
|
|
5
|
+
from ``pyproject.toml`` at install time), NOT a second hand-edited literal. There is
|
|
6
|
+
therefore ONE place to bump the version — ``pyproject.toml`` — and the two can never
|
|
7
|
+
silently disagree. ``tests/test_version.py`` asserts the two stay in lockstep.
|
|
8
|
+
|
|
9
|
+
The fallback matters: running straight from a source checkout that was never installed
|
|
10
|
+
(no ``.dist-info``) raises ``PackageNotFoundError``. That should never happen in the
|
|
11
|
+
uv workflow (``uv run`` syncs first), but a bare ``python src/...`` would hit it, and a
|
|
12
|
+
version banner is not worth crashing the import over.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
__version__ = version("sys-buddy")
|
|
21
|
+
except PackageNotFoundError: # running from an uninstalled source tree
|
|
22
|
+
__version__ = "0.0.0+unknown"
|
sys_buddy/admin.py
ADDED
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
"""Host-side admin operations (SPEC §9): create tasks, mint invites, issue and
|
|
2
|
+
revoke credentials, close tasks.
|
|
3
|
+
|
|
4
|
+
These run on the *host's* machine, against the same SQLite file the broker serves,
|
|
5
|
+
so — unlike the messaging tools, which are handed an open connection and a resolved
|
|
6
|
+
identity — each function opens its own connection via ``db.connect()``. That mirrors
|
|
7
|
+
the ``tools.py`` ``_op_*`` helpers and matches how ``cli.py`` calls them (no ``conn``
|
|
8
|
+
argument threaded through the CLI).
|
|
9
|
+
|
|
10
|
+
Guiding principle: the broker enforces, agents request. Raw tokens and invite codes
|
|
11
|
+
never touch the database — only their sha256 (SPEC §9). A leaked db reveals nothing
|
|
12
|
+
that can be replayed.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import sqlite3
|
|
21
|
+
import time
|
|
22
|
+
|
|
23
|
+
from . import audit, service, todos
|
|
24
|
+
from .db import connect
|
|
25
|
+
from .identity import new_invite_code, new_viewer_token, sha256_hex
|
|
26
|
+
|
|
27
|
+
# Single-use invites live 15 minutes (SPEC §9). Short enough that a code lingering
|
|
28
|
+
# in a Slack scrollback is dead by the time anyone scans for it.
|
|
29
|
+
INVITE_TTL_SECONDS = 15 * 60
|
|
30
|
+
|
|
31
|
+
# Cap on the slug part of an auto-derived id — keeps ids short enough to read and
|
|
32
|
+
# type while still recognisably echoing the title.
|
|
33
|
+
_SLUG_MAX = 40
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def new_task_id(title: str) -> str:
|
|
37
|
+
"""Derive a task id from a human ``title``: a slug plus a short random suffix.
|
|
38
|
+
|
|
39
|
+
Humans should only have to type a Title; the id is machine-friendly and unique.
|
|
40
|
+
The slug is lowercase with non-alphanumerics collapsed to single hyphens (e.g.
|
|
41
|
+
"new API" → "new-api"); an empty slug (a title with no word characters) falls
|
|
42
|
+
back to a generic base. The 4-hex-char suffix (16 bits of entropy) keeps two
|
|
43
|
+
same-titled tasks apart without the caller having to invent an id.
|
|
44
|
+
"""
|
|
45
|
+
slug = re.sub(r"[^a-z0-9]+", "-", (title or "").lower()).strip("-")[:_SLUG_MAX].strip("-")
|
|
46
|
+
base = slug or "task"
|
|
47
|
+
return f"{base}-{secrets.token_hex(2)}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fmt_time(ts: float) -> str:
|
|
51
|
+
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(ts))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _write_event(conn: sqlite3.Connection, task_id: str, kind: str, detail: dict) -> None:
|
|
55
|
+
"""Append an audit row. Every state-changing admin action leaves a trace so the
|
|
56
|
+
dashboard's event log (SPEC §11) and both humans can reconstruct what happened."""
|
|
57
|
+
conn.execute(
|
|
58
|
+
"INSERT INTO events (task_id, kind, detail_json, created_at) VALUES (?,?,?,?)",
|
|
59
|
+
(task_id, kind, json.dumps(detail), time.time()),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create_task(
|
|
64
|
+
id: str | None,
|
|
65
|
+
*,
|
|
66
|
+
title: str,
|
|
67
|
+
roles: list[str],
|
|
68
|
+
mode: str = "contract",
|
|
69
|
+
same_machine: bool = False,
|
|
70
|
+
staging_url: str | None = None,
|
|
71
|
+
) -> dict:
|
|
72
|
+
"""Create a task in the ``open`` state with the given fixed cast of roles.
|
|
73
|
+
|
|
74
|
+
``mode`` selects the workflow: ``'contract'`` (the default) runs the full
|
|
75
|
+
propose/lock/deploy state machine; ``'debug'`` is a lightweight mode where two
|
|
76
|
+
buddies just fix a problem and mark it resolved, with no contract required.
|
|
77
|
+
|
|
78
|
+
``same_machine`` records the task's CONNECTIVITY (not the broker's auth mode):
|
|
79
|
+
True only when the host proved everything lives on one box. It relaxes the
|
|
80
|
+
``staging_url`` rules for this task, so it defaults to False — a caller that
|
|
81
|
+
doesn't say gets the strict remote validation. ``staging_url`` is the host-chosen
|
|
82
|
+
deployment target the producer agent inherits when it proposes a contract.
|
|
83
|
+
|
|
84
|
+
``id`` may be falsy (``None``/``""``): the id is then derived from ``title`` via
|
|
85
|
+
:func:`new_task_id`, so a human only has to supply a Title. An explicit id is
|
|
86
|
+
used verbatim, and a duplicate explicit id is rejected explicitly (rather than
|
|
87
|
+
surfacing a raw sqlite IntegrityError) so the CLI can print an actionable message.
|
|
88
|
+
"""
|
|
89
|
+
if mode not in ("contract", "debug"):
|
|
90
|
+
raise ValueError(f"unknown mode {mode!r}; expected 'contract' or 'debug'")
|
|
91
|
+
# Normalise + validate the cast: trim, no blanks, no duplicates. The fixed-cast
|
|
92
|
+
# rule allows one live agent per role, so a duplicate role is nonsensical — reject
|
|
93
|
+
# it at creation rather than silently storing a role that can never be filled twice.
|
|
94
|
+
roles = [r.strip() for r in roles]
|
|
95
|
+
if not roles or any(not r for r in roles):
|
|
96
|
+
raise ValueError("a task needs at least one non-empty role")
|
|
97
|
+
if len(roles) != len(set(roles)):
|
|
98
|
+
raise ValueError("task roles must be unique (no duplicates)")
|
|
99
|
+
# `broker` is the broker's OWN voice: it authors pushes like contract_locked, and
|
|
100
|
+
# both the agent envelope and the dashboard thread attribute them to that role. A
|
|
101
|
+
# seat literally named 'broker' would be indistinguishable from the broker itself,
|
|
102
|
+
# so the name is reserved.
|
|
103
|
+
if any(r.lower() == service.BROKER_ROLE for r in roles):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
f"'{service.BROKER_ROLE}' is reserved for the broker's own notifications — "
|
|
106
|
+
f"pick another role name"
|
|
107
|
+
)
|
|
108
|
+
if mode == "contract" and len(roles) < 2:
|
|
109
|
+
# Model B: the producer is whoever proposes the contract (no hardcoded role).
|
|
110
|
+
# A contract still needs at least two roles — one to produce and one to build
|
|
111
|
+
# against it — else the workflow can never reach a check/verify. Debug tasks
|
|
112
|
+
# skip the state machine, so a single role is fine there.
|
|
113
|
+
raise ValueError("a contract task needs at least two roles (a producer and someone who builds against it)")
|
|
114
|
+
conn = connect()
|
|
115
|
+
try:
|
|
116
|
+
if not id:
|
|
117
|
+
# Derive from the title. Regenerate on the (vanishingly unlikely) suffix
|
|
118
|
+
# collision so an auto-id never fails the way an explicit duplicate does.
|
|
119
|
+
id = new_task_id(title)
|
|
120
|
+
while conn.execute("SELECT 1 FROM tasks WHERE id = ?", (id,)).fetchone() is not None:
|
|
121
|
+
id = new_task_id(title)
|
|
122
|
+
elif conn.execute("SELECT 1 FROM tasks WHERE id = ?", (id,)).fetchone() is not None:
|
|
123
|
+
raise ValueError(f"task '{id}' already exists")
|
|
124
|
+
now = time.time()
|
|
125
|
+
staging_url = (staging_url or "").strip() or None
|
|
126
|
+
conn.execute(
|
|
127
|
+
"INSERT INTO tasks (id, title, state, mode, roles_json, same_machine, staging_url, "
|
|
128
|
+
"created_at) VALUES (?,?,?,?,?,?,?,?)",
|
|
129
|
+
(
|
|
130
|
+
id, title, "open", mode, json.dumps(list(roles)),
|
|
131
|
+
1 if same_machine else 0, staging_url, now,
|
|
132
|
+
),
|
|
133
|
+
)
|
|
134
|
+
_write_event(conn, id, "task", {"text": f"Task created: {id}"})
|
|
135
|
+
conn.commit()
|
|
136
|
+
return {
|
|
137
|
+
"id": id,
|
|
138
|
+
"state": "open",
|
|
139
|
+
"title": title,
|
|
140
|
+
"roles": list(roles),
|
|
141
|
+
"mode": mode,
|
|
142
|
+
"same_machine": bool(same_machine),
|
|
143
|
+
"staging_url": staging_url,
|
|
144
|
+
}
|
|
145
|
+
finally:
|
|
146
|
+
conn.close()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def mint_invite(task: str, role: str) -> tuple[str, str]:
|
|
150
|
+
"""Generate a single-use invite code for ``role`` on ``task``.
|
|
151
|
+
|
|
152
|
+
Only the code's sha256 is stored; the raw code is returned to the caller once
|
|
153
|
+
and never persisted. Validates that the task exists and the role is one the task
|
|
154
|
+
actually declared — you cannot invite a role into a cast it has no seat for.
|
|
155
|
+
|
|
156
|
+
Returns ``(raw_code, human_readable_expiry)``.
|
|
157
|
+
"""
|
|
158
|
+
conn = connect()
|
|
159
|
+
try:
|
|
160
|
+
row = conn.execute("SELECT roles_json FROM tasks WHERE id = ?", (task,)).fetchone()
|
|
161
|
+
if row is None:
|
|
162
|
+
raise ValueError(f"unknown task '{task}'")
|
|
163
|
+
roles = json.loads(row["roles_json"])
|
|
164
|
+
if role not in roles:
|
|
165
|
+
raise ValueError(f"role '{role}' is not one of task '{task}' roles: {', '.join(roles)}")
|
|
166
|
+
|
|
167
|
+
code = new_invite_code(task)
|
|
168
|
+
now = time.time()
|
|
169
|
+
expires_at = now + INVITE_TTL_SECONDS
|
|
170
|
+
conn.execute(
|
|
171
|
+
"INSERT INTO invites (task_id, role, code_hash, created_at, expires_at, used_at) "
|
|
172
|
+
"VALUES (?,?,?,?,?,NULL)",
|
|
173
|
+
(task, role, sha256_hex(code), now, expires_at),
|
|
174
|
+
)
|
|
175
|
+
conn.commit()
|
|
176
|
+
return code, _fmt_time(expires_at)
|
|
177
|
+
finally:
|
|
178
|
+
conn.close()
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def issue_host_viewer(label: str) -> str:
|
|
182
|
+
"""Create an all-tasks (``task_id = NULL``) viewer and return its RAW token.
|
|
183
|
+
|
|
184
|
+
The host holds a distinct credential that sees every task, as opposed to a
|
|
185
|
+
buddy's per-task viewer (SPEC §9). Only the sha256 is stored.
|
|
186
|
+
"""
|
|
187
|
+
conn = connect()
|
|
188
|
+
try:
|
|
189
|
+
token = new_viewer_token()
|
|
190
|
+
conn.execute(
|
|
191
|
+
"INSERT INTO viewers (task_id, label, token_hash, created_at) VALUES (NULL,?,?,?)",
|
|
192
|
+
(label, sha256_hex(token), time.time()),
|
|
193
|
+
)
|
|
194
|
+
conn.commit()
|
|
195
|
+
return token
|
|
196
|
+
finally:
|
|
197
|
+
conn.close()
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def revoke_agent(name: str, task: str | None = None) -> int:
|
|
201
|
+
"""Revoke live agents named ``name``. Returns how many were revoked.
|
|
202
|
+
|
|
203
|
+
Because ``name`` is buddy-chosen at pairing, the same name can exist on more than
|
|
204
|
+
one task; pass ``task`` to scope the revocation so a host doesn't collaterally
|
|
205
|
+
kill a same-named agent on an unrelated task. Only live agents
|
|
206
|
+
(``revoked_at IS NULL``) are touched, so re-running is a no-op.
|
|
207
|
+
"""
|
|
208
|
+
conn = connect()
|
|
209
|
+
try:
|
|
210
|
+
now = time.time()
|
|
211
|
+
if task is None:
|
|
212
|
+
cur = conn.execute(
|
|
213
|
+
"UPDATE agents SET revoked_at = ? WHERE name = ? AND revoked_at IS NULL",
|
|
214
|
+
(now, name),
|
|
215
|
+
)
|
|
216
|
+
else:
|
|
217
|
+
cur = conn.execute(
|
|
218
|
+
"UPDATE agents SET revoked_at = ? WHERE name = ? AND task_id = ? "
|
|
219
|
+
"AND revoked_at IS NULL",
|
|
220
|
+
(now, name, task),
|
|
221
|
+
)
|
|
222
|
+
conn.commit()
|
|
223
|
+
audit.event("revoke_agent", name=name, task=task or "*", count=cur.rowcount)
|
|
224
|
+
return cur.rowcount
|
|
225
|
+
finally:
|
|
226
|
+
conn.close()
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def revoke_viewer(label: str, task: str | None = None) -> int:
|
|
230
|
+
"""Revoke live viewers with ``label`` (optionally scoped to ``task``). Returns
|
|
231
|
+
how many were revoked."""
|
|
232
|
+
conn = connect()
|
|
233
|
+
try:
|
|
234
|
+
now = time.time()
|
|
235
|
+
if task is None:
|
|
236
|
+
cur = conn.execute(
|
|
237
|
+
"UPDATE viewers SET revoked_at = ? WHERE label = ? AND revoked_at IS NULL",
|
|
238
|
+
(now, label),
|
|
239
|
+
)
|
|
240
|
+
else:
|
|
241
|
+
cur = conn.execute(
|
|
242
|
+
"UPDATE viewers SET revoked_at = ? WHERE label = ? AND task_id = ? "
|
|
243
|
+
"AND revoked_at IS NULL",
|
|
244
|
+
(now, label, task),
|
|
245
|
+
)
|
|
246
|
+
conn.commit()
|
|
247
|
+
audit.event("revoke_viewer", label=label, task=task or "*", count=cur.rowcount)
|
|
248
|
+
return cur.rowcount
|
|
249
|
+
finally:
|
|
250
|
+
conn.close()
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def close_task(task: str) -> None:
|
|
254
|
+
"""Close a task and revoke ALL its agents and viewers (SPEC §9: "close kills
|
|
255
|
+
everything for that task").
|
|
256
|
+
|
|
257
|
+
One atomic sweep: stamp ``closed_at``, then revoke every still-live agent and
|
|
258
|
+
per-task viewer. Instant and total — no credential scoped to this task survives.
|
|
259
|
+
"""
|
|
260
|
+
conn = connect()
|
|
261
|
+
try:
|
|
262
|
+
if conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task,)).fetchone() is None:
|
|
263
|
+
raise ValueError(f"unknown task '{task}'")
|
|
264
|
+
now = time.time()
|
|
265
|
+
conn.execute("UPDATE tasks SET closed_at = ? WHERE id = ?", (now, task))
|
|
266
|
+
conn.execute(
|
|
267
|
+
"UPDATE agents SET revoked_at = ? WHERE task_id = ? AND revoked_at IS NULL",
|
|
268
|
+
(now, task),
|
|
269
|
+
)
|
|
270
|
+
conn.execute(
|
|
271
|
+
"UPDATE viewers SET revoked_at = ? WHERE task_id = ? AND revoked_at IS NULL",
|
|
272
|
+
(now, task),
|
|
273
|
+
)
|
|
274
|
+
# Burn any invite that hasn't been redeemed yet — otherwise a buddy could
|
|
275
|
+
# redeem a still-valid invite AFTER close and get live access to a closed
|
|
276
|
+
# task (SPEC §9: close kills everything for that task).
|
|
277
|
+
conn.execute(
|
|
278
|
+
"UPDATE invites SET used_at = ? WHERE task_id = ? AND used_at IS NULL",
|
|
279
|
+
(now, task),
|
|
280
|
+
)
|
|
281
|
+
_write_event(conn, task, "task", {"text": f"Task closed: {task}"})
|
|
282
|
+
conn.commit()
|
|
283
|
+
finally:
|
|
284
|
+
conn.close()
|
|
285
|
+
audit.event("task_closed", task=task)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _assert_task(conn: sqlite3.Connection, task: str) -> None:
|
|
289
|
+
"""Fail with the task id, not with "no todo N" — the host mistyped one of two args
|
|
290
|
+
and has to be told which."""
|
|
291
|
+
if conn.execute("SELECT 1 FROM tasks WHERE id = ?", (task,)).fetchone() is None:
|
|
292
|
+
raise ValueError(f"unknown task '{task}'")
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def list_todos(task: str) -> tuple[list[dict], dict | None]:
|
|
296
|
+
"""Every todo on ``task`` plus its rollup, for the host's CLI view.
|
|
297
|
+
|
|
298
|
+
Returns ``(todos, rollup)``; the rollup is ``None`` when the task has no live todos
|
|
299
|
+
(``todos.rollup``), i.e. exactly when the task still runs its own state machine.
|
|
300
|
+
Read-only — the host's one WRITE here is :func:`host_drop_todo`.
|
|
301
|
+
"""
|
|
302
|
+
conn = connect()
|
|
303
|
+
try:
|
|
304
|
+
_assert_task(conn, task)
|
|
305
|
+
return todos.get_todos(conn, task), todos.rollup(conn, task)
|
|
306
|
+
finally:
|
|
307
|
+
conn.close()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def host_drop_todo(task: str, todo: int, reason: str) -> tuple[dict, dict | None]:
|
|
311
|
+
"""Drop a todo unilaterally, as the HOST. The escape hatch, and the only one.
|
|
312
|
+
|
|
313
|
+
A mutual ``drop_todo`` needs every named party's consent — including the party whose
|
|
314
|
+
human went offline and is the whole reason you want it gone — so that path deadlocks
|
|
315
|
+
on exactly the person who is missing. This is why the escape hatch is HUMAN and lives
|
|
316
|
+
here and in the desktop app rather than as a tool: no peer may ever remove a peer,
|
|
317
|
+
or the moment one objects to a shape the other removes it and locks without the
|
|
318
|
+
dissent ("both sides sign" quietly becomes "whoever proposes wins").
|
|
319
|
+
|
|
320
|
+
``todos.host_drop_todo`` posts the who/why to the task thread as the BROKER's own
|
|
321
|
+
seat, so the absent party's agent finds an explanation instead of vanished work.
|
|
322
|
+
|
|
323
|
+
Returns ``(dropped_todo, task_rollup)`` — the rollup so the caller can print what the
|
|
324
|
+
task now reports, and ``None`` there when the last live todo just went (the task is
|
|
325
|
+
back on its own state machine).
|
|
326
|
+
"""
|
|
327
|
+
conn = connect()
|
|
328
|
+
try:
|
|
329
|
+
_assert_task(conn, task)
|
|
330
|
+
result = todos.host_drop_todo(conn, task, todo, reason)
|
|
331
|
+
roll = todos.rollup(conn, task)
|
|
332
|
+
finally:
|
|
333
|
+
conn.close()
|
|
334
|
+
audit.event("todo_dropped", task=task, todo=result["id"], by=todos.HOST)
|
|
335
|
+
return result, roll
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def list_tasks() -> list[dict]:
|
|
339
|
+
"""All tasks, newest first, with the fields the CLI printer needs."""
|
|
340
|
+
conn = connect()
|
|
341
|
+
try:
|
|
342
|
+
rows = conn.execute(
|
|
343
|
+
"SELECT id, title, state, roles_json, strikes, created_at, closed_at "
|
|
344
|
+
"FROM tasks ORDER BY created_at DESC"
|
|
345
|
+
).fetchall()
|
|
346
|
+
return [
|
|
347
|
+
{
|
|
348
|
+
"id": r["id"],
|
|
349
|
+
"title": r["title"],
|
|
350
|
+
"state": r["state"],
|
|
351
|
+
"roles": json.loads(r["roles_json"]),
|
|
352
|
+
"strikes": r["strikes"],
|
|
353
|
+
"closed": r["closed_at"] is not None,
|
|
354
|
+
}
|
|
355
|
+
for r in rows
|
|
356
|
+
]
|
|
357
|
+
finally:
|
|
358
|
+
conn.close()
|