python-codex 0.2.6__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 +18 -14
- pycodex/agent.py +468 -462
- pycodex/bootstrap.py +417 -0
- pycodex/cli.py +236 -436
- 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 +329 -252
- pycodex/model_metadata.py +19 -7
- pycodex/portable.py +90 -52
- pycodex/portable_server.py +32 -24
- pycodex/prompts/models.json +235 -803
- pycodex/protocol.py +177 -137
- pycodex/runtime.py +579 -174
- pycodex/runtime_services.py +204 -157
- pycodex/tools/__init__.py +4 -1
- pycodex/tools/apply_patch_tool.py +69 -48
- pycodex/tools/base_tool.py +89 -42
- pycodex/tools/clock_tool.py +201 -0
- 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 +13 -13
- 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 +50 -66
- 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/utils/image_utils.py +76 -0
- pycodex/utils/random_ids.py +1 -2
- pycodex/utils/session_persist.py +263 -161
- 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 +25 -22
- responses_server/messages_api.py +96 -49
- responses_server/payload_processors.py +25 -19
- responses_server/server.py +11 -11
- responses_server/session_store.py +14 -11
- responses_server/stream_router.py +196 -107
- responses_server/tools/custom_adapter.py +17 -16
- responses_server/tools/web_search.py +39 -36
- responses_server/trajectory_dump.py +51 -13
- workspace_server/__main__.py +0 -1
- workspace_server/app.py +470 -384
- workspace_server/workspace.html +859 -232
- workspace_server/workspaces.html +94 -95
- workspace_server/workspaces.py +168 -100
- 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 -553
- python_codex-0.2.6.dist-info/METADATA +0 -441
- python_codex-0.2.6.dist-info/RECORD +0 -91
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
- {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_codex-0.2.6.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
|
|
@@ -11,9 +11,7 @@ from typing import Callable
|
|
|
11
11
|
from urllib.parse import quote, urlparse
|
|
12
12
|
|
|
13
13
|
import requests
|
|
14
|
-
from
|
|
15
|
-
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
|
16
|
-
import typing
|
|
14
|
+
from Cryptodome.Cipher import AES
|
|
17
15
|
|
|
18
16
|
try:
|
|
19
17
|
import tomllib
|
|
@@ -47,9 +45,9 @@ ProgressHandler = Callable[[str], None]
|
|
|
47
45
|
|
|
48
46
|
|
|
49
47
|
def upload_codex_home(
|
|
50
|
-
put_text:
|
|
51
|
-
event_handler:
|
|
52
|
-
) ->
|
|
48
|
+
put_text: "typing.Union[str, None]" = None,
|
|
49
|
+
event_handler: "typing.Union[ProgressHandler, None]" = None,
|
|
50
|
+
) -> "str":
|
|
53
51
|
source_dir, server = _parse_put_spec(put_text)
|
|
54
52
|
resolved_source_dir = resolve_put_source_dir(source_dir)
|
|
55
53
|
server_address, base_url = resolve_storage_server(server)
|
|
@@ -87,9 +85,9 @@ def upload_codex_home(
|
|
|
87
85
|
|
|
88
86
|
|
|
89
87
|
def bootstrap_called_home(
|
|
90
|
-
call_text:
|
|
91
|
-
storage_root:
|
|
92
|
-
) ->
|
|
88
|
+
call_text: "str",
|
|
89
|
+
storage_root: "typing.Union[typing.Union[str, Path], None]" = None,
|
|
90
|
+
) -> "Path":
|
|
93
91
|
secret, call_id, server_address, base_url = _parse_call_spec(call_text)
|
|
94
92
|
root = resolve_storage_root(storage_root)
|
|
95
93
|
cache_key = hashlib.sha256(call_text.strip().encode("utf-8")).hexdigest()[:16]
|
|
@@ -129,7 +127,9 @@ def bootstrap_called_home(
|
|
|
129
127
|
return home_dir / DEFAULT_ENTRY_CONFIG
|
|
130
128
|
|
|
131
129
|
|
|
132
|
-
def resolve_put_source_dir(
|
|
130
|
+
def resolve_put_source_dir(
|
|
131
|
+
source_dir: "typing.Union[typing.Union[str, Path], None]",
|
|
132
|
+
) -> "Path":
|
|
133
133
|
if source_dir is None or str(source_dir).strip() == "":
|
|
134
134
|
candidate = Path.home() / ".codex"
|
|
135
135
|
else:
|
|
@@ -139,13 +139,13 @@ def resolve_put_source_dir(source_dir: 'typing.Union[typing.Union[str, Path], No
|
|
|
139
139
|
if not resolved.is_dir():
|
|
140
140
|
raise RemoteStorageError(f"Codex home is not a directory: {resolved}")
|
|
141
141
|
if not config_path.is_file():
|
|
142
|
-
raise RemoteStorageError(
|
|
143
|
-
f"Codex home is missing required file: {config_path}"
|
|
144
|
-
)
|
|
142
|
+
raise RemoteStorageError(f"Codex home is missing required file: {config_path}")
|
|
145
143
|
return resolved
|
|
146
144
|
|
|
147
145
|
|
|
148
|
-
def resolve_storage_root(
|
|
146
|
+
def resolve_storage_root(
|
|
147
|
+
storage_root: "typing.Union[typing.Union[str, Path], None]" = None,
|
|
148
|
+
) -> "Path":
|
|
149
149
|
if storage_root is not None:
|
|
150
150
|
return Path(storage_root).expanduser().resolve()
|
|
151
151
|
env_value = os.environ.get(STORAGE_ROOT_ENV, "").strip()
|
|
@@ -154,7 +154,9 @@ def resolve_storage_root(storage_root: 'typing.Union[typing.Union[str, Path], No
|
|
|
154
154
|
return _discover_project_root() / STORAGE_CACHE_DIRNAME
|
|
155
155
|
|
|
156
156
|
|
|
157
|
-
def resolve_storage_server(
|
|
157
|
+
def resolve_storage_server(
|
|
158
|
+
server: "typing.Union[str, None]" = None,
|
|
159
|
+
) -> "typing.Tuple[str, str]":
|
|
158
160
|
raw_value = (server or os.environ.get(STORAGE_SERVER_ENV) or "").strip()
|
|
159
161
|
if not raw_value:
|
|
160
162
|
raw_value = DEFAULT_STORAGE_SERVER
|
|
@@ -168,7 +170,7 @@ def resolve_storage_server(server: 'typing.Union[str, None]' = None) -> 'typing.
|
|
|
168
170
|
return raw_value, f"http://{raw_value}{STORAGE_API_PREFIX}"
|
|
169
171
|
|
|
170
172
|
|
|
171
|
-
def _build_bundle_bytes(root:
|
|
173
|
+
def _build_bundle_bytes(root: "Path", emit: "ProgressHandler") -> "bytes":
|
|
172
174
|
files = _collect_upload_files(root)
|
|
173
175
|
emit("[put] mode: whitelist")
|
|
174
176
|
emit(f"[put] packing {len(files)} files")
|
|
@@ -180,8 +182,8 @@ def _build_bundle_bytes(root: 'Path', emit: 'ProgressHandler') -> 'bytes':
|
|
|
180
182
|
return buffer.getvalue()
|
|
181
183
|
|
|
182
184
|
|
|
183
|
-
def _collect_upload_files(root:
|
|
184
|
-
included:
|
|
185
|
+
def _collect_upload_files(root: "Path") -> "typing.List[str]":
|
|
186
|
+
included: "typing.Set[str]" = set()
|
|
185
187
|
for relative_name in ALLOWED_TOP_LEVEL_FILES:
|
|
186
188
|
candidate = root / relative_name
|
|
187
189
|
if candidate.is_file():
|
|
@@ -196,17 +198,19 @@ def _collect_upload_files(root: 'Path') -> 'typing.List[str]':
|
|
|
196
198
|
return sorted(included)
|
|
197
199
|
|
|
198
200
|
|
|
199
|
-
def _collect_config_referenced_files(root:
|
|
201
|
+
def _collect_config_referenced_files(root: "Path") -> "typing.Set[str]":
|
|
200
202
|
config_path = root / DEFAULT_ENTRY_CONFIG
|
|
201
203
|
if not config_path.is_file():
|
|
202
204
|
return set()
|
|
203
205
|
data = tomllib.loads(config_path.read_text(encoding="utf-8"))
|
|
204
|
-
referenced:
|
|
206
|
+
referenced: "typing.Set[str]" = set()
|
|
205
207
|
candidates = [data]
|
|
206
208
|
profiles = data.get("profiles")
|
|
207
209
|
if isinstance(profiles, dict):
|
|
208
210
|
candidates.extend(
|
|
209
|
-
profile_data
|
|
211
|
+
profile_data
|
|
212
|
+
for profile_data in profiles.values()
|
|
213
|
+
if isinstance(profile_data, dict)
|
|
210
214
|
)
|
|
211
215
|
for candidate in candidates:
|
|
212
216
|
model_instructions_file = candidate.get("model_instructions_file")
|
|
@@ -218,7 +222,9 @@ def _collect_config_referenced_files(root: 'Path') -> 'typing.Set[str]':
|
|
|
218
222
|
return referenced
|
|
219
223
|
|
|
220
224
|
|
|
221
|
-
def _normalize_optional_relative_file(
|
|
225
|
+
def _normalize_optional_relative_file(
|
|
226
|
+
root: "Path", value: "str"
|
|
227
|
+
) -> "typing.Union[str, None]":
|
|
222
228
|
candidate = Path(value)
|
|
223
229
|
if candidate.is_absolute():
|
|
224
230
|
return None
|
|
@@ -232,36 +238,48 @@ def _normalize_optional_relative_file(root: 'Path', value: 'str') -> 'typing.Uni
|
|
|
232
238
|
return resolved.relative_to(root_resolved).as_posix()
|
|
233
239
|
|
|
234
240
|
|
|
235
|
-
def _encrypt_bundle(bundle_bytes:
|
|
241
|
+
def _encrypt_bundle(bundle_bytes: "bytes", secret: "str") -> "bytes":
|
|
236
242
|
nonce = os.urandom(NONCE_LENGTH)
|
|
237
|
-
|
|
238
|
-
|
|
243
|
+
cipher = AES.new(_encryption_key(secret), AES.MODE_GCM, nonce=nonce)
|
|
244
|
+
ciphertext, tag = cipher.encrypt_and_digest(bundle_bytes)
|
|
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[
|
|
245
|
-
|
|
251
|
+
nonce = payload[
|
|
252
|
+
len(ENCRYPTED_BUNDLE_MAGIC) : len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH
|
|
253
|
+
]
|
|
254
|
+
encrypted = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
|
|
255
|
+
if len(encrypted) < 16:
|
|
256
|
+
raise RemoteStorageError("call secret is invalid or bundle is corrupted")
|
|
257
|
+
ciphertext = encrypted[:-16]
|
|
258
|
+
tag = encrypted[-16:]
|
|
246
259
|
try:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
260
|
+
cipher = AES.new(_encryption_key(secret), AES.MODE_GCM, nonce=nonce)
|
|
261
|
+
plaintext = cipher.decrypt(ciphertext)
|
|
262
|
+
cipher.verify(tag)
|
|
263
|
+
return plaintext
|
|
264
|
+
except ValueError as exc:
|
|
265
|
+
raise RemoteStorageError(
|
|
266
|
+
"call secret is invalid or bundle is corrupted"
|
|
267
|
+
) from exc
|
|
250
268
|
|
|
251
269
|
|
|
252
|
-
def _encryption_key(secret:
|
|
270
|
+
def _encryption_key(secret: "str") -> "bytes":
|
|
253
271
|
return hashlib.sha256(secret.encode("utf-8")).digest()
|
|
254
272
|
|
|
255
273
|
|
|
256
|
-
def _call_id_from_payload(payload:
|
|
274
|
+
def _call_id_from_payload(payload: "bytes") -> "str":
|
|
257
275
|
return _base58_encode(hashlib.sha256(payload).digest()[:8])
|
|
258
276
|
|
|
259
277
|
|
|
260
|
-
def _base58_encode(payload:
|
|
278
|
+
def _base58_encode(payload: "bytes") -> "str":
|
|
261
279
|
number = int.from_bytes(payload, "big")
|
|
262
280
|
if number == 0:
|
|
263
281
|
return TOKEN_BASE58_ALPHABET[0]
|
|
264
|
-
encoded:
|
|
282
|
+
encoded: "typing.List[str]" = []
|
|
265
283
|
while number:
|
|
266
284
|
number, remainder = divmod(number, 58)
|
|
267
285
|
encoded.append(TOKEN_BASE58_ALPHABET[remainder])
|
|
@@ -270,7 +288,9 @@ def _base58_encode(payload: 'bytes') -> 'str':
|
|
|
270
288
|
return prefix + "".join(encoded)
|
|
271
289
|
|
|
272
290
|
|
|
273
|
-
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]]":
|
|
274
294
|
raw_value = (put_text or "").strip()
|
|
275
295
|
if not raw_value:
|
|
276
296
|
return None, None
|
|
@@ -289,10 +309,12 @@ def _parse_put_spec(put_text: 'typing.Union[str, None]') -> 'typing.Tuple[typing
|
|
|
289
309
|
return raw_value, None
|
|
290
310
|
|
|
291
311
|
|
|
292
|
-
def _parse_call_spec(call_text:
|
|
312
|
+
def _parse_call_spec(call_text: "str") -> "typing.Tuple[str, str, str, str]":
|
|
293
313
|
raw_value = call_text.strip()
|
|
294
314
|
if not raw_value or "@" not in raw_value:
|
|
295
|
-
raise RemoteStorageError(
|
|
315
|
+
raise RemoteStorageError(
|
|
316
|
+
"call spec must look like <secret>-<call_id>@<host:port>"
|
|
317
|
+
)
|
|
296
318
|
secret_and_call_id, server_text = raw_value.rsplit("@", 1)
|
|
297
319
|
if "-" not in secret_and_call_id:
|
|
298
320
|
raise RemoteStorageError("call spec must include secret and call_id")
|
|
@@ -303,7 +325,7 @@ def _parse_call_spec(call_text: 'str') -> 'typing.Tuple[str, str, str, str]':
|
|
|
303
325
|
return secret, call_id, server_address, base_url
|
|
304
326
|
|
|
305
327
|
|
|
306
|
-
def _download_encrypted_bundle(base_url:
|
|
328
|
+
def _download_encrypted_bundle(base_url: "str", call_id: "str") -> "bytes":
|
|
307
329
|
url = f"{base_url}/call/{quote(call_id, safe='')}"
|
|
308
330
|
try:
|
|
309
331
|
response = requests.get(url, timeout=(5.0, 120.0))
|
|
@@ -312,15 +334,22 @@ def _download_encrypted_bundle(base_url: 'str', call_id: 'str') -> 'bytes':
|
|
|
312
334
|
if response.status_code == 404:
|
|
313
335
|
raise RemoteStorageError(f"call id not found: {call_id}")
|
|
314
336
|
if response.status_code >= 400:
|
|
315
|
-
raise RemoteStorageError(
|
|
337
|
+
raise RemoteStorageError(
|
|
338
|
+
f"call download failed with status {response.status_code}"
|
|
339
|
+
)
|
|
316
340
|
payload = response.content
|
|
317
|
-
expected_sha256 =
|
|
318
|
-
|
|
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
|
+
):
|
|
319
348
|
raise RemoteStorageError("downloaded bundle checksum mismatch")
|
|
320
349
|
return payload
|
|
321
350
|
|
|
322
351
|
|
|
323
|
-
def _extract_bundle_bytes(bundle_bytes:
|
|
352
|
+
def _extract_bundle_bytes(bundle_bytes: "bytes", destination: "Path") -> "None":
|
|
324
353
|
destination.mkdir(parents=True, exist_ok=True)
|
|
325
354
|
destination_resolved = destination.resolve()
|
|
326
355
|
try:
|
|
@@ -334,22 +363,29 @@ def _extract_bundle_bytes(bundle_bytes: 'bytes', destination: 'Path') -> 'None':
|
|
|
334
363
|
continue
|
|
335
364
|
_normalize_member_path(member_name, field_name="bundle member")
|
|
336
365
|
target_path = (destination_resolved / member_name).resolve()
|
|
337
|
-
if
|
|
366
|
+
if (
|
|
367
|
+
target_path != destination_resolved
|
|
368
|
+
and destination_resolved not in target_path.parents
|
|
369
|
+
):
|
|
338
370
|
raise RemoteStorageError("bundle contains unsafe paths")
|
|
339
371
|
archive.extractall(destination)
|
|
340
372
|
|
|
341
373
|
|
|
342
|
-
def _resolve_extracted_home(extract_root:
|
|
374
|
+
def _resolve_extracted_home(extract_root: "Path") -> "Path":
|
|
343
375
|
direct_config = extract_root / DEFAULT_ENTRY_CONFIG
|
|
344
376
|
if direct_config.is_file():
|
|
345
377
|
return extract_root
|
|
346
378
|
children = [child for child in extract_root.iterdir() if child.name != "__MACOSX"]
|
|
347
|
-
if
|
|
379
|
+
if (
|
|
380
|
+
len(children) == 1
|
|
381
|
+
and children[0].is_dir()
|
|
382
|
+
and (children[0] / DEFAULT_ENTRY_CONFIG).is_file()
|
|
383
|
+
):
|
|
348
384
|
return children[0]
|
|
349
385
|
raise RemoteStorageError("bundle is missing required config file after extraction")
|
|
350
386
|
|
|
351
387
|
|
|
352
|
-
def _load_cached_metadata(metadata_path:
|
|
388
|
+
def _load_cached_metadata(metadata_path: "Path") -> "typing.Dict[str, object]":
|
|
353
389
|
if not metadata_path.is_file():
|
|
354
390
|
return {}
|
|
355
391
|
try:
|
|
@@ -359,7 +395,7 @@ def _load_cached_metadata(metadata_path: 'Path') -> 'typing.Dict[str, object]':
|
|
|
359
395
|
return payload if isinstance(payload, dict) else {}
|
|
360
396
|
|
|
361
397
|
|
|
362
|
-
def _check_storage_server(server_address:
|
|
398
|
+
def _check_storage_server(server_address: "str", base_url: "str") -> "None":
|
|
363
399
|
parsed = urlparse(base_url)
|
|
364
400
|
health_url = f"{parsed.scheme}://{parsed.netloc}{HEALTHCHECK_PATH}"
|
|
365
401
|
try:
|
|
@@ -374,15 +410,17 @@ def _check_storage_server(server_address: 'str', base_url: 'str') -> 'None':
|
|
|
374
410
|
)
|
|
375
411
|
|
|
376
412
|
|
|
377
|
-
def _discover_project_root(start:
|
|
413
|
+
def _discover_project_root(start: "typing.Union[Path, None]" = None) -> "Path":
|
|
378
414
|
current = (start or Path.cwd()).resolve()
|
|
379
415
|
for candidate in (current, *current.parents):
|
|
380
|
-
if (candidate / "pyproject.toml").is_file() and (
|
|
416
|
+
if (candidate / "pyproject.toml").is_file() and (
|
|
417
|
+
candidate / "pycodex"
|
|
418
|
+
).is_dir():
|
|
381
419
|
return candidate
|
|
382
420
|
return current
|
|
383
421
|
|
|
384
422
|
|
|
385
|
-
def _normalize_member_path(value:
|
|
423
|
+
def _normalize_member_path(value: "str", field_name: "str") -> "str":
|
|
386
424
|
path = PurePosixPath(value)
|
|
387
425
|
if not value or path.is_absolute():
|
|
388
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)
|