python-codex 0.2.7__py3-none-any.whl → 0.3.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.
- pycodex/__init__.py +14 -14
- pycodex/agent.py +465 -499
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -510
- pycodex/compat.py +19 -5
- pycodex/context.py +222 -212
- pycodex/doctor.py +52 -48
- pycodex/events.py +857 -0
- pycodex/feishu_card.py +217 -163
- pycodex/feishu_link.py +43 -83
- pycodex/model.py +324 -253
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +76 -45
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +245 -983
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -176
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +1 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +58 -25
- pycodex/tools/close_agent_tool.py +2 -2
- pycodex/tools/code_mode_manager.py +77 -64
- pycodex/tools/exec_command_tool.py +26 -11
- pycodex/tools/exec_tool.py +4 -4
- pycodex/tools/grep_files_tool.py +12 -10
- pycodex/tools/ipython_tool.py +10 -13
- pycodex/tools/list_dir_tool.py +13 -9
- pycodex/tools/read_file_tool.py +29 -17
- pycodex/tools/request_permissions_tool.py +15 -5
- pycodex/tools/request_user_input_tool.py +13 -104
- pycodex/tools/resume_agent_tool.py +2 -2
- pycodex/tools/send_input_tool.py +11 -8
- pycodex/tools/shell_command_tool.py +7 -5
- pycodex/tools/shell_tool.py +7 -5
- pycodex/tools/spawn_agent_tool.py +7 -4
- pycodex/tools/unified_exec_manager.py +102 -69
- pycodex/tools/update_plan_tool.py +8 -5
- pycodex/tools/view_image_tool.py +7 -5
- pycodex/tools/wait_agent_tool.py +27 -4
- pycodex/tools/wait_tool.py +5 -4
- pycodex/tools/web_search_tool.py +4 -2
- pycodex/tools/write_stdin_tool.py +12 -11
- pycodex/utils/__init__.py +2 -17
- pycodex/utils/compactor.py +41 -72
- pycodex/utils/debug.py +2 -2
- pycodex/utils/dotenv.py +6 -7
- pycodex/utils/event_helpers.py +190 -0
- pycodex/utils/get_env.py +27 -70
- pycodex/{image_utils.py → utils/image_utils.py} +8 -11
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +217 -163
- pycodex/utils/truncation.py +21 -45
- python_codex-0.3.0.dist-info/METADATA +704 -0
- python_codex-0.3.0.dist-info/RECORD +90 -0
- responses_server/__init__.py +1 -5
- responses_server/__main__.py +0 -1
- responses_server/app.py +36 -31
- responses_server/config.py +23 -23
- responses_server/messages_api.py +51 -53
- responses_server/payload_processors.py +25 -20
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +101 -98
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +36 -14
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +461 -375
- workspace_server/workspace.html +852 -228
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +137 -79
- pycodex/collaboration.py +0 -20
- pycodex/interactive_session.py +0 -415
- pycodex/prompts/collaboration_default.md +0 -11
- pycodex/prompts/collaboration_plan.md +0 -128
- pycodex/utils/toolcall_visualize.py +0 -713
- pycodex/utils/visualize.py +0 -560
- python_codex-0.2.7.dist-info/METADATA +0 -455
- python_codex-0.2.7.dist-info/RECORD +0 -93
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/licenses/LICENSE +0 -0
pycodex/model_metadata.py
CHANGED
|
@@ -1,21 +1,21 @@
|
|
|
1
1
|
"""Shared loader for vendored Codex model metadata."""
|
|
2
2
|
|
|
3
|
-
from functools import lru_cache
|
|
4
3
|
import json
|
|
5
|
-
|
|
4
|
+
import re
|
|
6
5
|
import typing
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from pathlib import Path
|
|
7
8
|
|
|
8
9
|
from .protocol import JSONDict
|
|
9
10
|
|
|
10
|
-
|
|
11
11
|
DEFAULT_MODELS_PATH = Path(__file__).resolve().parent / "prompts" / "models.json"
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
@lru_cache(maxsize=1)
|
|
15
|
-
def load_models_by_slug() ->
|
|
15
|
+
def load_models_by_slug() -> "typing.Dict[str, JSONDict]":
|
|
16
16
|
payload = json.loads(DEFAULT_MODELS_PATH.read_text(encoding="utf-8"))
|
|
17
17
|
models = payload.get("models", [])
|
|
18
|
-
by_slug:
|
|
18
|
+
by_slug: "typing.Dict[str, JSONDict]" = {}
|
|
19
19
|
for model in models:
|
|
20
20
|
if not isinstance(model, dict):
|
|
21
21
|
continue
|
|
@@ -25,7 +25,19 @@ def load_models_by_slug() -> 'typing.Dict[str, JSONDict]':
|
|
|
25
25
|
return by_slug
|
|
26
26
|
|
|
27
27
|
|
|
28
|
-
def model_metadata(slug:
|
|
28
|
+
def model_metadata(slug: "typing.Union[str, None]") -> "typing.Union[JSONDict, None]":
|
|
29
29
|
if slug is None:
|
|
30
30
|
return None
|
|
31
|
-
|
|
31
|
+
models = load_models_by_slug()
|
|
32
|
+
candidates = [name for name in models if slug.startswith(name)]
|
|
33
|
+
if not candidates:
|
|
34
|
+
namespace, separator, suffix = slug.partition("/")
|
|
35
|
+
if (
|
|
36
|
+
separator
|
|
37
|
+
and "/" not in suffix
|
|
38
|
+
and re.fullmatch(r"[A-Za-z0-9_-]+", namespace)
|
|
39
|
+
):
|
|
40
|
+
candidates = [name for name in models if suffix.startswith(name)]
|
|
41
|
+
if not candidates:
|
|
42
|
+
return None
|
|
43
|
+
return models[max(candidates, key=len)]
|
pycodex/portable.py
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
|
|
2
1
|
import hashlib
|
|
3
2
|
import json
|
|
4
3
|
import os
|
|
5
4
|
import shutil
|
|
6
5
|
import tempfile
|
|
6
|
+
import typing
|
|
7
7
|
import zipfile
|
|
8
8
|
from io import BytesIO
|
|
9
9
|
from pathlib import Path, PurePosixPath
|
|
@@ -12,7 +12,6 @@ from urllib.parse import quote, urlparse
|
|
|
12
12
|
|
|
13
13
|
import requests
|
|
14
14
|
from Cryptodome.Cipher import AES
|
|
15
|
-
import typing
|
|
16
15
|
|
|
17
16
|
try:
|
|
18
17
|
import tomllib
|
|
@@ -46,9 +45,9 @@ ProgressHandler = Callable[[str], None]
|
|
|
46
45
|
|
|
47
46
|
|
|
48
47
|
def upload_codex_home(
|
|
49
|
-
put_text:
|
|
50
|
-
event_handler:
|
|
51
|
-
) ->
|
|
48
|
+
put_text: "typing.Union[str, None]" = None,
|
|
49
|
+
event_handler: "typing.Union[ProgressHandler, None]" = None,
|
|
50
|
+
) -> "str":
|
|
52
51
|
source_dir, server = _parse_put_spec(put_text)
|
|
53
52
|
resolved_source_dir = resolve_put_source_dir(source_dir)
|
|
54
53
|
server_address, base_url = resolve_storage_server(server)
|
|
@@ -86,9 +85,9 @@ def upload_codex_home(
|
|
|
86
85
|
|
|
87
86
|
|
|
88
87
|
def bootstrap_called_home(
|
|
89
|
-
call_text:
|
|
90
|
-
storage_root:
|
|
91
|
-
) ->
|
|
88
|
+
call_text: "str",
|
|
89
|
+
storage_root: "typing.Union[typing.Union[str, Path], None]" = None,
|
|
90
|
+
) -> "Path":
|
|
92
91
|
secret, call_id, server_address, base_url = _parse_call_spec(call_text)
|
|
93
92
|
root = resolve_storage_root(storage_root)
|
|
94
93
|
cache_key = hashlib.sha256(call_text.strip().encode("utf-8")).hexdigest()[:16]
|
|
@@ -128,7 +127,9 @@ def bootstrap_called_home(
|
|
|
128
127
|
return home_dir / DEFAULT_ENTRY_CONFIG
|
|
129
128
|
|
|
130
129
|
|
|
131
|
-
def resolve_put_source_dir(
|
|
130
|
+
def resolve_put_source_dir(
|
|
131
|
+
source_dir: "typing.Union[typing.Union[str, Path], None]",
|
|
132
|
+
) -> "Path":
|
|
132
133
|
if source_dir is None or str(source_dir).strip() == "":
|
|
133
134
|
candidate = Path.home() / ".codex"
|
|
134
135
|
else:
|
|
@@ -138,13 +139,13 @@ def resolve_put_source_dir(source_dir: 'typing.Union[typing.Union[str, Path], No
|
|
|
138
139
|
if not resolved.is_dir():
|
|
139
140
|
raise RemoteStorageError(f"Codex home is not a directory: {resolved}")
|
|
140
141
|
if not config_path.is_file():
|
|
141
|
-
raise RemoteStorageError(
|
|
142
|
-
f"Codex home is missing required file: {config_path}"
|
|
143
|
-
)
|
|
142
|
+
raise RemoteStorageError(f"Codex home is missing required file: {config_path}")
|
|
144
143
|
return resolved
|
|
145
144
|
|
|
146
145
|
|
|
147
|
-
def resolve_storage_root(
|
|
146
|
+
def resolve_storage_root(
|
|
147
|
+
storage_root: "typing.Union[typing.Union[str, Path], None]" = None,
|
|
148
|
+
) -> "Path":
|
|
148
149
|
if storage_root is not None:
|
|
149
150
|
return Path(storage_root).expanduser().resolve()
|
|
150
151
|
env_value = os.environ.get(STORAGE_ROOT_ENV, "").strip()
|
|
@@ -153,7 +154,9 @@ def resolve_storage_root(storage_root: 'typing.Union[typing.Union[str, Path], No
|
|
|
153
154
|
return _discover_project_root() / STORAGE_CACHE_DIRNAME
|
|
154
155
|
|
|
155
156
|
|
|
156
|
-
def resolve_storage_server(
|
|
157
|
+
def resolve_storage_server(
|
|
158
|
+
server: "typing.Union[str, None]" = None,
|
|
159
|
+
) -> "typing.Tuple[str, str]":
|
|
157
160
|
raw_value = (server or os.environ.get(STORAGE_SERVER_ENV) or "").strip()
|
|
158
161
|
if not raw_value:
|
|
159
162
|
raw_value = DEFAULT_STORAGE_SERVER
|
|
@@ -167,7 +170,7 @@ def resolve_storage_server(server: 'typing.Union[str, None]' = None) -> 'typing.
|
|
|
167
170
|
return raw_value, f"http://{raw_value}{STORAGE_API_PREFIX}"
|
|
168
171
|
|
|
169
172
|
|
|
170
|
-
def _build_bundle_bytes(root:
|
|
173
|
+
def _build_bundle_bytes(root: "Path", emit: "ProgressHandler") -> "bytes":
|
|
171
174
|
files = _collect_upload_files(root)
|
|
172
175
|
emit("[put] mode: whitelist")
|
|
173
176
|
emit(f"[put] packing {len(files)} files")
|
|
@@ -179,8 +182,8 @@ def _build_bundle_bytes(root: 'Path', emit: 'ProgressHandler') -> 'bytes':
|
|
|
179
182
|
return buffer.getvalue()
|
|
180
183
|
|
|
181
184
|
|
|
182
|
-
def _collect_upload_files(root:
|
|
183
|
-
included:
|
|
185
|
+
def _collect_upload_files(root: "Path") -> "typing.List[str]":
|
|
186
|
+
included: "typing.Set[str]" = set()
|
|
184
187
|
for relative_name in ALLOWED_TOP_LEVEL_FILES:
|
|
185
188
|
candidate = root / relative_name
|
|
186
189
|
if candidate.is_file():
|
|
@@ -195,17 +198,19 @@ def _collect_upload_files(root: 'Path') -> 'typing.List[str]':
|
|
|
195
198
|
return sorted(included)
|
|
196
199
|
|
|
197
200
|
|
|
198
|
-
def _collect_config_referenced_files(root:
|
|
201
|
+
def _collect_config_referenced_files(root: "Path") -> "typing.Set[str]":
|
|
199
202
|
config_path = root / DEFAULT_ENTRY_CONFIG
|
|
200
203
|
if not config_path.is_file():
|
|
201
204
|
return set()
|
|
202
205
|
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
203
|
-
referenced:
|
|
206
|
+
referenced: "typing.Set[str]" = set()
|
|
204
207
|
candidates = [data]
|
|
205
208
|
profiles = data.get("profiles")
|
|
206
209
|
if isinstance(profiles, dict):
|
|
207
210
|
candidates.extend(
|
|
208
|
-
profile_data
|
|
211
|
+
profile_data
|
|
212
|
+
for profile_data in profiles.values()
|
|
213
|
+
if isinstance(profile_data, dict)
|
|
209
214
|
)
|
|
210
215
|
for candidate in candidates:
|
|
211
216
|
model_instructions_file = candidate.get("model_instructions_file")
|
|
@@ -217,7 +222,9 @@ def _collect_config_referenced_files(root: 'Path') -> 'typing.Set[str]':
|
|
|
217
222
|
return referenced
|
|
218
223
|
|
|
219
224
|
|
|
220
|
-
def _normalize_optional_relative_file(
|
|
225
|
+
def _normalize_optional_relative_file(
|
|
226
|
+
root: "Path", value: "str"
|
|
227
|
+
) -> "typing.Union[str, None]":
|
|
221
228
|
candidate = Path(value)
|
|
222
229
|
if candidate.is_absolute():
|
|
223
230
|
return None
|
|
@@ -231,17 +238,19 @@ def _normalize_optional_relative_file(root: 'Path', value: 'str') -> 'typing.Uni
|
|
|
231
238
|
return resolved.relative_to(root_resolved).as_posix()
|
|
232
239
|
|
|
233
240
|
|
|
234
|
-
def _encrypt_bundle(bundle_bytes:
|
|
241
|
+
def _encrypt_bundle(bundle_bytes: "bytes", secret: "str") -> "bytes":
|
|
235
242
|
nonce = os.urandom(NONCE_LENGTH)
|
|
236
243
|
cipher = AES.new(_encryption_key(secret), AES.MODE_GCM, nonce=nonce)
|
|
237
244
|
ciphertext, tag = cipher.encrypt_and_digest(bundle_bytes)
|
|
238
245
|
return ENCRYPTED_BUNDLE_MAGIC + nonce + ciphertext + tag
|
|
239
246
|
|
|
240
247
|
|
|
241
|
-
def _decrypt_bundle(payload:
|
|
248
|
+
def _decrypt_bundle(payload: "bytes", secret: "str") -> "bytes":
|
|
242
249
|
if not payload.startswith(ENCRYPTED_BUNDLE_MAGIC):
|
|
243
250
|
raise RemoteStorageError("stored bundle is not a recognized encrypted payload")
|
|
244
|
-
nonce = payload[
|
|
251
|
+
nonce = payload[
|
|
252
|
+
len(ENCRYPTED_BUNDLE_MAGIC) : len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH
|
|
253
|
+
]
|
|
245
254
|
encrypted = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
|
|
246
255
|
if len(encrypted) < 16:
|
|
247
256
|
raise RemoteStorageError("call secret is invalid or bundle is corrupted")
|
|
@@ -253,22 +262,24 @@ def _decrypt_bundle(payload: 'bytes', secret: 'str') -> 'bytes':
|
|
|
253
262
|
cipher.verify(tag)
|
|
254
263
|
return plaintext
|
|
255
264
|
except ValueError as exc:
|
|
256
|
-
raise RemoteStorageError(
|
|
265
|
+
raise RemoteStorageError(
|
|
266
|
+
"call secret is invalid or bundle is corrupted"
|
|
267
|
+
) from exc
|
|
257
268
|
|
|
258
269
|
|
|
259
|
-
def _encryption_key(secret:
|
|
270
|
+
def _encryption_key(secret: "str") -> "bytes":
|
|
260
271
|
return hashlib.sha256(secret.encode("utf-8")).digest()
|
|
261
272
|
|
|
262
273
|
|
|
263
|
-
def _call_id_from_payload(payload:
|
|
274
|
+
def _call_id_from_payload(payload: "bytes") -> "str":
|
|
264
275
|
return _base58_encode(hashlib.sha256(payload).digest()[:8])
|
|
265
276
|
|
|
266
277
|
|
|
267
|
-
def _base58_encode(payload:
|
|
278
|
+
def _base58_encode(payload: "bytes") -> "str":
|
|
268
279
|
number = int.from_bytes(payload, "big")
|
|
269
280
|
if number == 0:
|
|
270
281
|
return TOKEN_BASE58_ALPHABET[0]
|
|
271
|
-
encoded:
|
|
282
|
+
encoded: "typing.List[str]" = []
|
|
272
283
|
while number:
|
|
273
284
|
number, remainder = divmod(number, 58)
|
|
274
285
|
encoded.append(TOKEN_BASE58_ALPHABET[remainder])
|
|
@@ -277,7 +288,9 @@ def _base58_encode(payload: 'bytes') -> 'str':
|
|
|
277
288
|
return prefix + "".join(encoded)
|
|
278
289
|
|
|
279
290
|
|
|
280
|
-
def _parse_put_spec(
|
|
291
|
+
def _parse_put_spec(
|
|
292
|
+
put_text: "typing.Union[str, None]",
|
|
293
|
+
) -> "typing.Tuple[typing.Union[str, None], typing.Union[str, None]]":
|
|
281
294
|
raw_value = (put_text or "").strip()
|
|
282
295
|
if not raw_value:
|
|
283
296
|
return None, None
|
|
@@ -296,10 +309,12 @@ def _parse_put_spec(put_text: 'typing.Union[str, None]') -> 'typing.Tuple[typing
|
|
|
296
309
|
return raw_value, None
|
|
297
310
|
|
|
298
311
|
|
|
299
|
-
def _parse_call_spec(call_text:
|
|
312
|
+
def _parse_call_spec(call_text: "str") -> "typing.Tuple[str, str, str, str]":
|
|
300
313
|
raw_value = call_text.strip()
|
|
301
314
|
if not raw_value or "@" not in raw_value:
|
|
302
|
-
raise RemoteStorageError(
|
|
315
|
+
raise RemoteStorageError(
|
|
316
|
+
"call spec must look like <secret>-<call_id>@<host:port>"
|
|
317
|
+
)
|
|
303
318
|
secret_and_call_id, server_text = raw_value.rsplit("@", 1)
|
|
304
319
|
if "-" not in secret_and_call_id:
|
|
305
320
|
raise RemoteStorageError("call spec must include secret and call_id")
|
|
@@ -310,7 +325,7 @@ def _parse_call_spec(call_text: 'str') -> 'typing.Tuple[str, str, str, str]':
|
|
|
310
325
|
return secret, call_id, server_address, base_url
|
|
311
326
|
|
|
312
327
|
|
|
313
|
-
def _download_encrypted_bundle(base_url:
|
|
328
|
+
def _download_encrypted_bundle(base_url: "str", call_id: "str") -> "bytes":
|
|
314
329
|
url = f"{base_url}/call/{quote(call_id, safe='')}"
|
|
315
330
|
try:
|
|
316
331
|
response = requests.get(url, timeout=(5.0, 120.0))
|
|
@@ -319,15 +334,22 @@ def _download_encrypted_bundle(base_url: 'str', call_id: 'str') -> 'bytes':
|
|
|
319
334
|
if response.status_code == 404:
|
|
320
335
|
raise RemoteStorageError(f"call id not found: {call_id}")
|
|
321
336
|
if response.status_code >= 400:
|
|
322
|
-
raise RemoteStorageError(
|
|
337
|
+
raise RemoteStorageError(
|
|
338
|
+
f"call download failed with status {response.status_code}"
|
|
339
|
+
)
|
|
323
340
|
payload = response.content
|
|
324
|
-
expected_sha256 =
|
|
325
|
-
|
|
341
|
+
expected_sha256 = (
|
|
342
|
+
response.headers.get("X-Pycodex-Sha256", "").strip().lower() or None
|
|
343
|
+
)
|
|
344
|
+
if (
|
|
345
|
+
expected_sha256 is not None
|
|
346
|
+
and hashlib.sha256(payload).hexdigest() != expected_sha256
|
|
347
|
+
):
|
|
326
348
|
raise RemoteStorageError("downloaded bundle checksum mismatch")
|
|
327
349
|
return payload
|
|
328
350
|
|
|
329
351
|
|
|
330
|
-
def _extract_bundle_bytes(bundle_bytes:
|
|
352
|
+
def _extract_bundle_bytes(bundle_bytes: "bytes", destination: "Path") -> "None":
|
|
331
353
|
destination.mkdir(parents=True, exist_ok=True)
|
|
332
354
|
destination_resolved = destination.resolve()
|
|
333
355
|
try:
|
|
@@ -341,22 +363,29 @@ def _extract_bundle_bytes(bundle_bytes: 'bytes', destination: 'Path') -> 'None':
|
|
|
341
363
|
continue
|
|
342
364
|
_normalize_member_path(member_name, field_name="bundle member")
|
|
343
365
|
target_path = (destination_resolved / member_name).resolve()
|
|
344
|
-
if
|
|
366
|
+
if (
|
|
367
|
+
target_path != destination_resolved
|
|
368
|
+
and destination_resolved not in target_path.parents
|
|
369
|
+
):
|
|
345
370
|
raise RemoteStorageError("bundle contains unsafe paths")
|
|
346
371
|
archive.extractall(destination)
|
|
347
372
|
|
|
348
373
|
|
|
349
|
-
def _resolve_extracted_home(extract_root:
|
|
374
|
+
def _resolve_extracted_home(extract_root: "Path") -> "Path":
|
|
350
375
|
direct_config = extract_root / DEFAULT_ENTRY_CONFIG
|
|
351
376
|
if direct_config.is_file():
|
|
352
377
|
return extract_root
|
|
353
378
|
children = [child for child in extract_root.iterdir() if child.name != "__MACOSX"]
|
|
354
|
-
if
|
|
379
|
+
if (
|
|
380
|
+
len(children) == 1
|
|
381
|
+
and children[0].is_dir()
|
|
382
|
+
and (children[0] / DEFAULT_ENTRY_CONFIG).is_file()
|
|
383
|
+
):
|
|
355
384
|
return children[0]
|
|
356
385
|
raise RemoteStorageError("bundle is missing required config file after extraction")
|
|
357
386
|
|
|
358
387
|
|
|
359
|
-
def _load_cached_metadata(metadata_path:
|
|
388
|
+
def _load_cached_metadata(metadata_path: "Path") -> "typing.Dict[str, object]":
|
|
360
389
|
if not metadata_path.is_file():
|
|
361
390
|
return {}
|
|
362
391
|
try:
|
|
@@ -366,7 +395,7 @@ def _load_cached_metadata(metadata_path: 'Path') -> 'typing.Dict[str, object]':
|
|
|
366
395
|
return payload if isinstance(payload, dict) else {}
|
|
367
396
|
|
|
368
397
|
|
|
369
|
-
def _check_storage_server(server_address:
|
|
398
|
+
def _check_storage_server(server_address: "str", base_url: "str") -> "None":
|
|
370
399
|
parsed = urlparse(base_url)
|
|
371
400
|
health_url = f"{parsed.scheme}://{parsed.netloc}{HEALTHCHECK_PATH}"
|
|
372
401
|
try:
|
|
@@ -381,15 +410,17 @@ def _check_storage_server(server_address: 'str', base_url: 'str') -> 'None':
|
|
|
381
410
|
)
|
|
382
411
|
|
|
383
412
|
|
|
384
|
-
def _discover_project_root(start:
|
|
413
|
+
def _discover_project_root(start: "typing.Union[Path, None]" = None) -> "Path":
|
|
385
414
|
current = (start or Path.cwd()).resolve()
|
|
386
415
|
for candidate in (current, *current.parents):
|
|
387
|
-
if (candidate / "pyproject.toml").is_file() and (
|
|
416
|
+
if (candidate / "pyproject.toml").is_file() and (
|
|
417
|
+
candidate / "pycodex"
|
|
418
|
+
).is_dir():
|
|
388
419
|
return candidate
|
|
389
420
|
return current
|
|
390
421
|
|
|
391
422
|
|
|
392
|
-
def _normalize_member_path(value:
|
|
423
|
+
def _normalize_member_path(value: "str", field_name: "str") -> "str":
|
|
393
424
|
path = PurePosixPath(value)
|
|
394
425
|
if not value or path.is_absolute():
|
|
395
426
|
raise RemoteStorageError(f"{field_name} must be relative")
|
pycodex/portable_server.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
|
|
2
1
|
import argparse
|
|
3
2
|
import hashlib
|
|
4
3
|
import json
|
|
5
4
|
import threading
|
|
5
|
+
import typing
|
|
6
6
|
from http.server import BaseHTTPRequestHandler
|
|
7
7
|
from pathlib import Path
|
|
8
8
|
from urllib.parse import unquote, urlparse
|
|
@@ -14,44 +14,43 @@ from .portable import (
|
|
|
14
14
|
STORAGE_API_PREFIX,
|
|
15
15
|
_call_id_from_payload,
|
|
16
16
|
)
|
|
17
|
-
import typing
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class CodexStorageServer:
|
|
21
20
|
def __init__(
|
|
22
21
|
self,
|
|
23
|
-
root:
|
|
24
|
-
host:
|
|
25
|
-
port:
|
|
26
|
-
) ->
|
|
22
|
+
root: "typing.Union[str, Path]",
|
|
23
|
+
host: "str" = "127.0.0.1",
|
|
24
|
+
port: "int" = 5577,
|
|
25
|
+
) -> "None":
|
|
27
26
|
self._root = Path(root).resolve()
|
|
28
27
|
self._root.mkdir(parents=True, exist_ok=True)
|
|
29
28
|
self._objects_dir = self._root / "objects"
|
|
30
29
|
self._objects_dir.mkdir(parents=True, exist_ok=True)
|
|
31
30
|
self._server = ThreadingHTTPServer((host, port), self._build_handler())
|
|
32
|
-
self._thread:
|
|
31
|
+
self._thread: "typing.Union[threading.Thread, None]" = None
|
|
33
32
|
|
|
34
33
|
@property
|
|
35
|
-
def host(self) ->
|
|
34
|
+
def host(self) -> "str":
|
|
36
35
|
return str(self._server.server_address[0])
|
|
37
36
|
|
|
38
37
|
@property
|
|
39
|
-
def port(self) ->
|
|
38
|
+
def port(self) -> "int":
|
|
40
39
|
return int(self._server.server_address[1])
|
|
41
40
|
|
|
42
41
|
@property
|
|
43
|
-
def server_address(self) ->
|
|
42
|
+
def server_address(self) -> "str":
|
|
44
43
|
return f"{self.host}:{self.port}"
|
|
45
44
|
|
|
46
45
|
@property
|
|
47
|
-
def base_url(self) ->
|
|
46
|
+
def base_url(self) -> "str":
|
|
48
47
|
return f"http://{self.server_address}{STORAGE_API_PREFIX}"
|
|
49
48
|
|
|
50
49
|
@property
|
|
51
|
-
def root(self) ->
|
|
50
|
+
def root(self) -> "Path":
|
|
52
51
|
return self._root
|
|
53
52
|
|
|
54
|
-
def start(self) ->
|
|
53
|
+
def start(self) -> "None":
|
|
55
54
|
if self._thread is not None:
|
|
56
55
|
return
|
|
57
56
|
self._thread = threading.Thread(
|
|
@@ -61,7 +60,7 @@ class CodexStorageServer:
|
|
|
61
60
|
)
|
|
62
61
|
self._thread.start()
|
|
63
62
|
|
|
64
|
-
def stop(self) ->
|
|
63
|
+
def stop(self) -> "None":
|
|
65
64
|
self._server.shutdown()
|
|
66
65
|
self._server.server_close()
|
|
67
66
|
if self._thread is not None:
|
|
@@ -72,7 +71,7 @@ class CodexStorageServer:
|
|
|
72
71
|
server = self
|
|
73
72
|
|
|
74
73
|
class Handler(BaseHTTPRequestHandler):
|
|
75
|
-
def do_GET(self) ->
|
|
74
|
+
def do_GET(self) -> "None": # noqa: N802
|
|
76
75
|
path = urlparse(self.path).path
|
|
77
76
|
if path == HEALTHCHECK_PATH:
|
|
78
77
|
self._send_json(200, {"ok": True})
|
|
@@ -97,12 +96,14 @@ class CodexStorageServer:
|
|
|
97
96
|
self.send_response(200)
|
|
98
97
|
self.send_header("Content-Type", "application/octet-stream")
|
|
99
98
|
self.send_header("Content-Length", str(len(payload)))
|
|
100
|
-
self.send_header(
|
|
99
|
+
self.send_header(
|
|
100
|
+
"X-Pycodex-Sha256", hashlib.sha256(payload).hexdigest()
|
|
101
|
+
)
|
|
101
102
|
self.send_header("X-Pycodex-Call-Id", call_id)
|
|
102
103
|
self.end_headers()
|
|
103
104
|
self.wfile.write(payload)
|
|
104
105
|
|
|
105
|
-
def do_POST(self) ->
|
|
106
|
+
def do_POST(self) -> "None": # noqa: N802
|
|
106
107
|
path = urlparse(self.path).path
|
|
107
108
|
if path != f"{STORAGE_API_PREFIX}/put":
|
|
108
109
|
self._send_json(404, {"error": "not found"})
|
|
@@ -113,7 +114,9 @@ class CodexStorageServer:
|
|
|
113
114
|
return
|
|
114
115
|
payload = self.rfile.read(content_length)
|
|
115
116
|
sha256 = hashlib.sha256(payload).hexdigest()
|
|
116
|
-
expected_sha256 =
|
|
117
|
+
expected_sha256 = (
|
|
118
|
+
self.headers.get("X-Pycodex-Sha256", "").strip().lower()
|
|
119
|
+
)
|
|
117
120
|
if expected_sha256 and expected_sha256 != sha256:
|
|
118
121
|
self._send_json(400, {"error": "checksum mismatch"})
|
|
119
122
|
return
|
|
@@ -130,7 +133,10 @@ class CodexStorageServer:
|
|
|
130
133
|
f"call_id={call_id} status={status} path={object_path}",
|
|
131
134
|
flush=True,
|
|
132
135
|
)
|
|
133
|
-
host_header =
|
|
136
|
+
host_header = (
|
|
137
|
+
self.headers.get("Host", server.server_address).strip()
|
|
138
|
+
or server.server_address
|
|
139
|
+
)
|
|
134
140
|
self._send_json(
|
|
135
141
|
200,
|
|
136
142
|
{
|
|
@@ -139,10 +145,12 @@ class CodexStorageServer:
|
|
|
139
145
|
},
|
|
140
146
|
)
|
|
141
147
|
|
|
142
|
-
def log_message(self, _format:
|
|
148
|
+
def log_message(self, _format: "str", *_args) -> "None":
|
|
143
149
|
return
|
|
144
150
|
|
|
145
|
-
def _send_json(
|
|
151
|
+
def _send_json(
|
|
152
|
+
self, status: "int", payload: "typing.Dict[str, object]"
|
|
153
|
+
) -> "None":
|
|
146
154
|
body = json.dumps(payload).encode("utf-8")
|
|
147
155
|
self.send_response(status)
|
|
148
156
|
self.send_header("Content-Type", "application/json")
|
|
@@ -152,11 +160,11 @@ class CodexStorageServer:
|
|
|
152
160
|
|
|
153
161
|
return Handler
|
|
154
162
|
|
|
155
|
-
def _object_path(self, call_id:
|
|
163
|
+
def _object_path(self, call_id: "str") -> "Path":
|
|
156
164
|
return self._objects_dir / f"{call_id}.bin"
|
|
157
165
|
|
|
158
166
|
|
|
159
|
-
def build_parser() ->
|
|
167
|
+
def build_parser() -> "argparse.ArgumentParser":
|
|
160
168
|
parser = argparse.ArgumentParser(
|
|
161
169
|
prog="python -m pycodex.portable_server",
|
|
162
170
|
description="Run a pycodex remote storage service for --put/--call testing.",
|
|
@@ -180,7 +188,7 @@ def build_parser() -> 'argparse.ArgumentParser':
|
|
|
180
188
|
return parser
|
|
181
189
|
|
|
182
190
|
|
|
183
|
-
def main(argv:
|
|
191
|
+
def main(argv: "typing.Union[typing.List[str], None]" = None) -> "int":
|
|
184
192
|
parser = build_parser()
|
|
185
193
|
args = parser.parse_args(argv)
|
|
186
194
|
server = CodexStorageServer(args.root, host=args.host, port=args.port)
|