remote-coder 0.4.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.
- app/__init__.py +3 -0
- app/admin/__init__.py +0 -0
- app/admin/advanced_settings.py +88 -0
- app/admin/database_browser.py +301 -0
- app/admin/router.py +528 -0
- app/admin/static/i18n.js +401 -0
- app/admin/static/icons/advanced.svg +8 -0
- app/admin/static/icons/database.svg +5 -0
- app/admin/static/icons/download.svg +3 -0
- app/admin/static/icons/home.svg +4 -0
- app/admin/static/icons/logs.svg +3 -0
- app/admin/static/icons/projects.svg +5 -0
- app/admin/static/summary.js +73 -0
- app/admin/templates/admin.html +511 -0
- app/admin/templates/advanced.html +635 -0
- app/admin/templates/database.html +880 -0
- app/admin/templates/logs.html +686 -0
- app/admin/templates/projects.html +878 -0
- app/ai/__init__.py +0 -0
- app/ai/base.py +129 -0
- app/ai/claude.py +20 -0
- app/ai/codex.py +34 -0
- app/ai/factory.py +27 -0
- app/ai/gemini.py +20 -0
- app/ai/model_catalog.py +47 -0
- app/ai/usage.py +134 -0
- app/cli.py +238 -0
- app/config.py +130 -0
- app/git/__init__.py +0 -0
- app/git/ai_commit.py +88 -0
- app/git/branch_naming.py +21 -0
- app/git/commit_message.py +279 -0
- app/git/service.py +669 -0
- app/jobs/__init__.py +0 -0
- app/jobs/manager.py +770 -0
- app/jobs/schemas.py +116 -0
- app/jobs/store.py +334 -0
- app/main.py +265 -0
- app/models.py +20 -0
- app/monitoring/__init__.py +10 -0
- app/monitoring/code.py +161 -0
- app/monitoring/events.py +33 -0
- app/monitoring/git.py +103 -0
- app/monitoring/log_buffer.py +245 -0
- app/monitoring/memory.py +19 -0
- app/monitoring/model.py +598 -0
- app/projects/__init__.py +19 -0
- app/projects/registry.py +384 -0
- app/security/__init__.py +0 -0
- app/security/auth.py +19 -0
- app/system_startup.py +34 -0
- app/telegram/__init__.py +0 -0
- app/telegram/bot_instances.py +67 -0
- app/telegram/commands/__init__.py +64 -0
- app/telegram/commands/base.py +222 -0
- app/telegram/commands/branch.py +366 -0
- app/telegram/commands/clear_stop.py +221 -0
- app/telegram/commands/fix.py +219 -0
- app/telegram/commands/model.py +93 -0
- app/telegram/commands/monitor.py +185 -0
- app/telegram/commands/registry.py +110 -0
- app/telegram/commands/status.py +243 -0
- app/telegram/commands/system.py +201 -0
- app/telegram/confirmations.py +36 -0
- app/telegram/conversation.py +789 -0
- app/telegram/i18n.py +742 -0
- app/telegram/model_preferences.py +53 -0
- app/telegram/notifier.py +387 -0
- app/telegram/parser.py +267 -0
- app/telegram/webhook.py +988 -0
- app/telegram/webhook_registration.py +172 -0
- app/tunnel.py +104 -0
- remote_coder-0.4.1.dist-info/METADATA +520 -0
- remote_coder-0.4.1.dist-info/RECORD +78 -0
- remote_coder-0.4.1.dist-info/WHEEL +5 -0
- remote_coder-0.4.1.dist-info/entry_points.txt +2 -0
- remote_coder-0.4.1.dist-info/licenses/LICENSE +201 -0
- remote_coder-0.4.1.dist-info/top_level.txt +1 -0
app/__init__.py
ADDED
app/admin/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from threading import Lock
|
|
6
|
+
from typing import Self
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, model_validator
|
|
9
|
+
|
|
10
|
+
from app.models import UiLanguage
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AdvancedSettings(BaseModel):
|
|
14
|
+
model_config = {"extra": "forbid"}
|
|
15
|
+
|
|
16
|
+
ui_language: UiLanguage = UiLanguage.ENGLISH
|
|
17
|
+
server_lifecycle_notify_enabled: bool = True
|
|
18
|
+
pull_projects_on_server_startup_enabled: bool = False
|
|
19
|
+
auto_merge_to_main_enabled: bool = False
|
|
20
|
+
delete_rebased_branch_enabled: bool = True
|
|
21
|
+
natural_job_confirmation_buttons_enabled: bool = False
|
|
22
|
+
conversation_memory_limit_enabled: bool = False
|
|
23
|
+
conversation_memory_max_rows: int | None = None
|
|
24
|
+
conversation_memory_max_bytes: int | None = None
|
|
25
|
+
status_recent_job_limit: int = 10
|
|
26
|
+
job_timeout_seconds: int | None = None
|
|
27
|
+
|
|
28
|
+
@model_validator(mode="after")
|
|
29
|
+
def _validate_memory_limits(self) -> Self:
|
|
30
|
+
if self.conversation_memory_limit_enabled:
|
|
31
|
+
has_rows = self.conversation_memory_max_rows is not None and self.conversation_memory_max_rows > 0
|
|
32
|
+
has_bytes = (
|
|
33
|
+
self.conversation_memory_max_bytes is not None and self.conversation_memory_max_bytes > 0
|
|
34
|
+
)
|
|
35
|
+
if not has_rows and not has_bytes:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"When conversation_memory_limit_enabled is set, at least one of "
|
|
38
|
+
"conversation_memory_max_rows or conversation_memory_max_bytes must be a positive value.",
|
|
39
|
+
)
|
|
40
|
+
if self.conversation_memory_max_rows is not None and self.conversation_memory_max_rows <= 0:
|
|
41
|
+
raise ValueError("conversation_memory_max_rows must be positive or blank.")
|
|
42
|
+
if self.conversation_memory_max_bytes is not None and self.conversation_memory_max_bytes <= 0:
|
|
43
|
+
raise ValueError("conversation_memory_max_bytes must be positive or blank.")
|
|
44
|
+
if self.status_recent_job_limit < 1:
|
|
45
|
+
raise ValueError("status_recent_job_limit must be at least 1.")
|
|
46
|
+
if self.job_timeout_seconds is not None and self.job_timeout_seconds <= 0:
|
|
47
|
+
raise ValueError("job_timeout_seconds must be positive or blank.")
|
|
48
|
+
return self
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def advanced_settings_path_for_project_root(project_root: Path) -> Path:
|
|
52
|
+
return (project_root.expanduser().resolve() / ".remote-coder" / "advanced_settings.json").resolve()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class FileAdvancedSettingsStore:
|
|
56
|
+
def __init__(self, path: Path) -> None:
|
|
57
|
+
self._path = path
|
|
58
|
+
self._lock = Lock()
|
|
59
|
+
self._cached = AdvancedSettings()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def path(self) -> Path:
|
|
63
|
+
return self._path
|
|
64
|
+
|
|
65
|
+
def load(self) -> AdvancedSettings:
|
|
66
|
+
with self._lock:
|
|
67
|
+
if not self._path.exists():
|
|
68
|
+
self._cached = AdvancedSettings()
|
|
69
|
+
return self._cached.model_copy(deep=True)
|
|
70
|
+
raw = self._path.read_text(encoding="utf-8")
|
|
71
|
+
data = json.loads(raw) if raw.strip() else {}
|
|
72
|
+
if isinstance(data, dict):
|
|
73
|
+
data.pop("auto_pull_on_project_switch", None)
|
|
74
|
+
self._cached = AdvancedSettings.model_validate(data)
|
|
75
|
+
return self._cached.model_copy(deep=True)
|
|
76
|
+
|
|
77
|
+
def get(self) -> AdvancedSettings:
|
|
78
|
+
with self._lock:
|
|
79
|
+
return self._cached.model_copy(deep=True)
|
|
80
|
+
|
|
81
|
+
def save(self, settings: AdvancedSettings) -> AdvancedSettings:
|
|
82
|
+
validated = AdvancedSettings.model_validate(settings.model_dump(mode="json"))
|
|
83
|
+
with self._lock:
|
|
84
|
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
text = json.dumps(validated.model_dump(mode="json"), indent=2, ensure_ascii=False)
|
|
86
|
+
self._path.write_text(text + "\n", encoding="utf-8")
|
|
87
|
+
self._cached = validated
|
|
88
|
+
return self._cached.model_copy(deep=True)
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import csv
|
|
4
|
+
import io
|
|
5
|
+
import sqlite3
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Final
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class _TableSpec:
|
|
14
|
+
name: str
|
|
15
|
+
label: str
|
|
16
|
+
columns: tuple[str, ...]
|
|
17
|
+
sortable: frozenset[str]
|
|
18
|
+
default_sort: str
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
_TABLES: Final[dict[str, _TableSpec]] = {
|
|
22
|
+
"conversation_entries": _TableSpec(
|
|
23
|
+
name="conversation_entries",
|
|
24
|
+
label="Conversation & job history",
|
|
25
|
+
columns=(
|
|
26
|
+
"id",
|
|
27
|
+
"project",
|
|
28
|
+
"chat_id",
|
|
29
|
+
"role",
|
|
30
|
+
"text",
|
|
31
|
+
"job_id",
|
|
32
|
+
"message_id",
|
|
33
|
+
"reply_to_message_id",
|
|
34
|
+
"created_at",
|
|
35
|
+
),
|
|
36
|
+
sortable=frozenset({"id", "project", "chat_id", "role", "created_at", "job_id", "message_id"}),
|
|
37
|
+
default_sort="id",
|
|
38
|
+
),
|
|
39
|
+
"message_branch_links": _TableSpec(
|
|
40
|
+
name="message_branch_links",
|
|
41
|
+
label="Message–branch links",
|
|
42
|
+
columns=(
|
|
43
|
+
"project",
|
|
44
|
+
"chat_id",
|
|
45
|
+
"message_id",
|
|
46
|
+
"branch",
|
|
47
|
+
"job_id",
|
|
48
|
+
"created_at",
|
|
49
|
+
"updated_at",
|
|
50
|
+
),
|
|
51
|
+
sortable=frozenset(
|
|
52
|
+
{"project", "chat_id", "message_id", "created_at", "updated_at", "branch", "job_id"}
|
|
53
|
+
),
|
|
54
|
+
default_sort="updated_at",
|
|
55
|
+
),
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
_DEFAULT_EXPORT_MAX_ROWS: Final[int] = 50_000
|
|
59
|
+
_EXPORT_CHUNK: Final[int] = 2_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _open_readonly(db_path: Path) -> sqlite3.Connection:
|
|
63
|
+
uri = f"file:{db_path.resolve()}?mode=ro"
|
|
64
|
+
return sqlite3.connect(uri, uri=True)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _build_where(
|
|
68
|
+
spec: _TableSpec,
|
|
69
|
+
*,
|
|
70
|
+
project: str | None,
|
|
71
|
+
chat_id: int | None,
|
|
72
|
+
role: str | None,
|
|
73
|
+
job_id: str | None,
|
|
74
|
+
q: str | None,
|
|
75
|
+
) -> tuple[str, list[Any]]:
|
|
76
|
+
where_parts: list[str] = []
|
|
77
|
+
params: list[Any] = []
|
|
78
|
+
|
|
79
|
+
if project and project.strip():
|
|
80
|
+
where_parts.append("project = ?")
|
|
81
|
+
params.append(project.strip())
|
|
82
|
+
if chat_id is not None:
|
|
83
|
+
where_parts.append("chat_id = ?")
|
|
84
|
+
params.append(chat_id)
|
|
85
|
+
if role and role.strip() and spec.name == "conversation_entries":
|
|
86
|
+
where_parts.append("role = ?")
|
|
87
|
+
params.append(role.strip())
|
|
88
|
+
if job_id and job_id.strip():
|
|
89
|
+
where_parts.append("job_id = ?")
|
|
90
|
+
params.append(job_id.strip())
|
|
91
|
+
|
|
92
|
+
if q and q.strip():
|
|
93
|
+
qq = q.strip()
|
|
94
|
+
if spec.name == "conversation_entries":
|
|
95
|
+
where_parts.append(
|
|
96
|
+
"(instr(lower(COALESCE(text,'')), lower(?)) > 0 "
|
|
97
|
+
"OR instr(lower(COALESCE(job_id,'')), lower(?)) > 0 "
|
|
98
|
+
"OR instr(lower(COALESCE(project,'')), lower(?)) > 0)"
|
|
99
|
+
)
|
|
100
|
+
params.extend([qq, qq, qq])
|
|
101
|
+
else:
|
|
102
|
+
where_parts.append(
|
|
103
|
+
"(instr(lower(COALESCE(branch,'')), lower(?)) > 0 "
|
|
104
|
+
"OR instr(lower(COALESCE(job_id,'')), lower(?)) > 0 "
|
|
105
|
+
"OR instr(lower(COALESCE(project,'')), lower(?)) > 0)"
|
|
106
|
+
)
|
|
107
|
+
params.extend([qq, qq, qq])
|
|
108
|
+
|
|
109
|
+
where_sql = " AND ".join(where_parts) if where_parts else "1=1"
|
|
110
|
+
return where_sql, params
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class ConversationDatabaseBrowser:
|
|
114
|
+
# SECURITY: Only tables in the `_TABLES` whitelist are exposed, and sqlite is opened read-only URI.
|
|
115
|
+
def __init__(self, db_path: Path) -> None:
|
|
116
|
+
self._db_path = db_path.resolve()
|
|
117
|
+
|
|
118
|
+
def tables_payload(self) -> dict[str, Any]:
|
|
119
|
+
exists = self._db_path.is_file()
|
|
120
|
+
tables = []
|
|
121
|
+
for spec in _TABLES.values():
|
|
122
|
+
tables.append(
|
|
123
|
+
{
|
|
124
|
+
"name": spec.name,
|
|
125
|
+
"label": spec.label,
|
|
126
|
+
"columns": list(spec.columns),
|
|
127
|
+
"default_sort": spec.default_sort,
|
|
128
|
+
"sortable": sorted(spec.sortable),
|
|
129
|
+
}
|
|
130
|
+
)
|
|
131
|
+
return {
|
|
132
|
+
"db_path": str(self._db_path),
|
|
133
|
+
"db_exists": exists,
|
|
134
|
+
"tables": tables,
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
def distinct_filter_options(self, table: str) -> dict[str, list[str]]:
|
|
138
|
+
spec = _TABLES.get(table)
|
|
139
|
+
if spec is None:
|
|
140
|
+
raise ValueError(f"unknown table: {table}")
|
|
141
|
+
if not self._db_path.is_file():
|
|
142
|
+
return {"projects": [], "roles": []}
|
|
143
|
+
|
|
144
|
+
conn = _open_readonly(self._db_path)
|
|
145
|
+
try:
|
|
146
|
+
cur = conn.execute(
|
|
147
|
+
f"SELECT DISTINCT project FROM {spec.name} ORDER BY project COLLATE NOCASE"
|
|
148
|
+
)
|
|
149
|
+
projects = [str(r[0]) for r in cur.fetchall() if r[0] is not None and str(r[0]).strip() != ""]
|
|
150
|
+
roles: list[str] = []
|
|
151
|
+
if spec.name == "conversation_entries":
|
|
152
|
+
cur2 = conn.execute(
|
|
153
|
+
"SELECT DISTINCT role FROM conversation_entries ORDER BY role COLLATE NOCASE"
|
|
154
|
+
)
|
|
155
|
+
roles = [str(r[0]) for r in cur2.fetchall() if r[0] is not None and str(r[0]).strip() != ""]
|
|
156
|
+
finally:
|
|
157
|
+
conn.close()
|
|
158
|
+
return {"projects": projects, "roles": roles}
|
|
159
|
+
|
|
160
|
+
def query_rows(
|
|
161
|
+
self,
|
|
162
|
+
table: str,
|
|
163
|
+
*,
|
|
164
|
+
project: str | None,
|
|
165
|
+
chat_id: int | None,
|
|
166
|
+
role: str | None,
|
|
167
|
+
job_id: str | None,
|
|
168
|
+
q: str | None,
|
|
169
|
+
sort: str | None,
|
|
170
|
+
order: str,
|
|
171
|
+
limit: int,
|
|
172
|
+
offset: int,
|
|
173
|
+
) -> dict[str, Any]:
|
|
174
|
+
spec = _TABLES.get(table)
|
|
175
|
+
if spec is None:
|
|
176
|
+
raise ValueError(f"unknown table: {table}")
|
|
177
|
+
if order not in ("asc", "desc"):
|
|
178
|
+
raise ValueError("order must be asc or desc")
|
|
179
|
+
|
|
180
|
+
sort_col = (sort or "").strip() or spec.default_sort
|
|
181
|
+
if sort_col not in spec.sortable:
|
|
182
|
+
raise ValueError(f"invalid sort column: {sort_col}")
|
|
183
|
+
|
|
184
|
+
if not self._db_path.is_file():
|
|
185
|
+
return {
|
|
186
|
+
"table": spec.name,
|
|
187
|
+
"label": spec.label,
|
|
188
|
+
"columns": list(spec.columns),
|
|
189
|
+
"rows": [],
|
|
190
|
+
"total": 0,
|
|
191
|
+
"limit": limit,
|
|
192
|
+
"offset": offset,
|
|
193
|
+
"sort": sort_col,
|
|
194
|
+
"order": order,
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
where_sql, params = _build_where(
|
|
198
|
+
spec, project=project, chat_id=chat_id, role=role, job_id=job_id, q=q
|
|
199
|
+
)
|
|
200
|
+
cols_sql = ", ".join(spec.columns)
|
|
201
|
+
order_sql = "ASC" if order == "asc" else "DESC"
|
|
202
|
+
|
|
203
|
+
count_sql = f"SELECT COUNT(*) FROM {spec.name} WHERE {where_sql}"
|
|
204
|
+
data_sql = (
|
|
205
|
+
f"SELECT {cols_sql} FROM {spec.name} WHERE {where_sql} "
|
|
206
|
+
f"ORDER BY {sort_col} {order_sql} LIMIT ? OFFSET ?"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
conn = _open_readonly(self._db_path)
|
|
210
|
+
try:
|
|
211
|
+
total = int(conn.execute(count_sql, params).fetchone()[0])
|
|
212
|
+
cur = conn.execute(data_sql, params + [limit, offset])
|
|
213
|
+
col_names = [d[0] for d in cur.description]
|
|
214
|
+
rows = [dict(zip(col_names, row)) for row in cur.fetchall()]
|
|
215
|
+
finally:
|
|
216
|
+
conn.close()
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
"table": spec.name,
|
|
220
|
+
"label": spec.label,
|
|
221
|
+
"columns": list(spec.columns),
|
|
222
|
+
"rows": rows,
|
|
223
|
+
"total": total,
|
|
224
|
+
"limit": limit,
|
|
225
|
+
"offset": offset,
|
|
226
|
+
"sort": sort_col,
|
|
227
|
+
"order": order,
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
def iter_csv_rows(
|
|
231
|
+
self,
|
|
232
|
+
table: str,
|
|
233
|
+
*,
|
|
234
|
+
project: str | None,
|
|
235
|
+
chat_id: int | None,
|
|
236
|
+
role: str | None,
|
|
237
|
+
job_id: str | None,
|
|
238
|
+
q: str | None,
|
|
239
|
+
sort: str | None,
|
|
240
|
+
order: str,
|
|
241
|
+
max_rows: int = _DEFAULT_EXPORT_MAX_ROWS,
|
|
242
|
+
chunk_size: int = _EXPORT_CHUNK,
|
|
243
|
+
) -> Iterator[bytes]:
|
|
244
|
+
# NOTE: Emit a BOM first so Excel and CJK locales detect the CSV as UTF-8.
|
|
245
|
+
spec = _TABLES.get(table)
|
|
246
|
+
if spec is None:
|
|
247
|
+
raise ValueError(f"unknown table: {table}")
|
|
248
|
+
if order not in ("asc", "desc"):
|
|
249
|
+
raise ValueError("order must be asc or desc")
|
|
250
|
+
sort_col = (sort or "").strip() or spec.default_sort
|
|
251
|
+
if sort_col not in spec.sortable:
|
|
252
|
+
raise ValueError(f"invalid sort column: {sort_col}")
|
|
253
|
+
if max_rows < 1:
|
|
254
|
+
raise ValueError("max_rows must be >= 1")
|
|
255
|
+
if chunk_size < 1:
|
|
256
|
+
raise ValueError("chunk_size must be >= 1")
|
|
257
|
+
|
|
258
|
+
enc = "utf-8"
|
|
259
|
+
yield "\ufeff".encode(enc)
|
|
260
|
+
|
|
261
|
+
if not self._db_path.is_file():
|
|
262
|
+
buf = io.StringIO()
|
|
263
|
+
csv.writer(buf, lineterminator="\n").writerow(list(spec.columns))
|
|
264
|
+
yield buf.getvalue().encode(enc)
|
|
265
|
+
return
|
|
266
|
+
|
|
267
|
+
where_sql, params = _build_where(
|
|
268
|
+
spec, project=project, chat_id=chat_id, role=role, job_id=job_id, q=q
|
|
269
|
+
)
|
|
270
|
+
cols_sql = ", ".join(spec.columns)
|
|
271
|
+
order_sql = "ASC" if order == "asc" else "DESC"
|
|
272
|
+
header_buf = io.StringIO()
|
|
273
|
+
csv.writer(header_buf, lineterminator="\n").writerow(list(spec.columns))
|
|
274
|
+
yield header_buf.getvalue().encode(enc)
|
|
275
|
+
|
|
276
|
+
conn = _open_readonly(self._db_path)
|
|
277
|
+
try:
|
|
278
|
+
emitted = 0
|
|
279
|
+
offset = 0
|
|
280
|
+
while emitted < max_rows:
|
|
281
|
+
take = min(chunk_size, max_rows - emitted)
|
|
282
|
+
data_sql = (
|
|
283
|
+
f"SELECT {cols_sql} FROM {spec.name} WHERE {where_sql} "
|
|
284
|
+
f"ORDER BY {sort_col} {order_sql} LIMIT ? OFFSET ?"
|
|
285
|
+
)
|
|
286
|
+
cur = conn.execute(data_sql, params + [take, offset])
|
|
287
|
+
batch = cur.fetchall()
|
|
288
|
+
if not batch:
|
|
289
|
+
break
|
|
290
|
+
for row in batch:
|
|
291
|
+
line_buf = io.StringIO()
|
|
292
|
+
csv.writer(line_buf, lineterminator="\n").writerow(
|
|
293
|
+
["" if v is None else v for v in row]
|
|
294
|
+
)
|
|
295
|
+
yield line_buf.getvalue().encode(enc)
|
|
296
|
+
emitted += len(batch)
|
|
297
|
+
offset += len(batch)
|
|
298
|
+
if len(batch) < take:
|
|
299
|
+
break
|
|
300
|
+
finally:
|
|
301
|
+
conn.close()
|