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.
Files changed (84) hide show
  1. pycodex/__init__.py +14 -14
  2. pycodex/agent.py +465 -499
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -510
  5. pycodex/compat.py +19 -5
  6. pycodex/context.py +222 -212
  7. pycodex/doctor.py +52 -48
  8. pycodex/events.py +857 -0
  9. pycodex/feishu_card.py +217 -163
  10. pycodex/feishu_link.py +43 -83
  11. pycodex/model.py +324 -253
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +76 -45
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +245 -983
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -176
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +1 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +58 -25
  23. pycodex/tools/close_agent_tool.py +2 -2
  24. pycodex/tools/code_mode_manager.py +77 -64
  25. pycodex/tools/exec_command_tool.py +26 -11
  26. pycodex/tools/exec_tool.py +4 -4
  27. pycodex/tools/grep_files_tool.py +12 -10
  28. pycodex/tools/ipython_tool.py +10 -13
  29. pycodex/tools/list_dir_tool.py +13 -9
  30. pycodex/tools/read_file_tool.py +29 -17
  31. pycodex/tools/request_permissions_tool.py +15 -5
  32. pycodex/tools/request_user_input_tool.py +13 -104
  33. pycodex/tools/resume_agent_tool.py +2 -2
  34. pycodex/tools/send_input_tool.py +11 -8
  35. pycodex/tools/shell_command_tool.py +7 -5
  36. pycodex/tools/shell_tool.py +7 -5
  37. pycodex/tools/spawn_agent_tool.py +7 -4
  38. pycodex/tools/unified_exec_manager.py +102 -69
  39. pycodex/tools/update_plan_tool.py +8 -5
  40. pycodex/tools/view_image_tool.py +7 -5
  41. pycodex/tools/wait_agent_tool.py +27 -4
  42. pycodex/tools/wait_tool.py +5 -4
  43. pycodex/tools/web_search_tool.py +4 -2
  44. pycodex/tools/write_stdin_tool.py +12 -11
  45. pycodex/utils/__init__.py +2 -17
  46. pycodex/utils/compactor.py +41 -72
  47. pycodex/utils/debug.py +2 -2
  48. pycodex/utils/dotenv.py +6 -7
  49. pycodex/utils/event_helpers.py +190 -0
  50. pycodex/utils/get_env.py +27 -70
  51. pycodex/{image_utils.py → utils/image_utils.py} +8 -11
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +217 -163
  54. pycodex/utils/truncation.py +21 -45
  55. python_codex-0.3.0.dist-info/METADATA +704 -0
  56. python_codex-0.3.0.dist-info/RECORD +90 -0
  57. responses_server/__init__.py +1 -5
  58. responses_server/__main__.py +0 -1
  59. responses_server/app.py +36 -31
  60. responses_server/config.py +23 -23
  61. responses_server/messages_api.py +51 -53
  62. responses_server/payload_processors.py +25 -20
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +101 -98
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +36 -14
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +461 -375
  71. workspace_server/workspace.html +852 -228
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +137 -79
  74. pycodex/collaboration.py +0 -20
  75. pycodex/interactive_session.py +0 -415
  76. pycodex/prompts/collaboration_default.md +0 -11
  77. pycodex/prompts/collaboration_plan.md +0 -128
  78. pycodex/utils/toolcall_visualize.py +0 -713
  79. pycodex/utils/visualize.py +0 -560
  80. python_codex-0.2.7.dist-info/METADATA +0 -455
  81. python_codex-0.2.7.dist-info/RECORD +0 -93
  82. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.7.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {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
- from pathlib import Path
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() -> 'typing.Dict[str, JSONDict]':
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: 'typing.Dict[str, JSONDict]' = {}
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: 'typing.Union[str, None]') -> 'typing.Union[JSONDict, None]':
28
+ def model_metadata(slug: "typing.Union[str, None]") -> "typing.Union[JSONDict, None]":
29
29
  if slug is None:
30
30
  return None
31
- return load_models_by_slug().get(slug)
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: 'typing.Union[str, None]' = None,
50
- event_handler: 'typing.Union[ProgressHandler, None]' = None,
51
- ) -> 'str':
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: 'str',
90
- storage_root: 'typing.Union[typing.Union[str, Path], None]' = None,
91
- ) -> 'Path':
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(source_dir: 'typing.Union[typing.Union[str, Path], None]') -> 'Path':
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(storage_root: 'typing.Union[typing.Union[str, Path], None]' = None) -> 'Path':
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(server: 'typing.Union[str, None]' = None) -> 'typing.Tuple[str, str]':
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: 'Path', emit: 'ProgressHandler') -> 'bytes':
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: 'Path') -> 'typing.List[str]':
183
- included: 'typing.Set[str]' = set()
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: 'Path') -> 'typing.Set[str]':
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: 'typing.Set[str]' = set()
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 for profile_data in profiles.values() if isinstance(profile_data, dict)
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(root: 'Path', value: 'str') -> 'typing.Union[str, None]':
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: 'bytes', secret: 'str') -> '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: 'bytes', secret: 'str') -> 'bytes':
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[len(ENCRYPTED_BUNDLE_MAGIC) : len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH]
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("call secret is invalid or bundle is corrupted") from exc
265
+ raise RemoteStorageError(
266
+ "call secret is invalid or bundle is corrupted"
267
+ ) from exc
257
268
 
258
269
 
259
- def _encryption_key(secret: 'str') -> 'bytes':
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: 'bytes') -> 'str':
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: 'bytes') -> 'str':
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: 'typing.List[str]' = []
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(put_text: 'typing.Union[str, None]') -> 'typing.Tuple[typing.Union[str, None], typing.Union[str, None]]':
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: 'str') -> 'typing.Tuple[str, str, str, str]':
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("call spec must look like <secret>-<call_id>@<host:port>")
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: 'str', call_id: 'str') -> 'bytes':
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(f"call download failed with status {response.status_code}")
337
+ raise RemoteStorageError(
338
+ f"call download failed with status {response.status_code}"
339
+ )
323
340
  payload = response.content
324
- expected_sha256 = response.headers.get("X-Pycodex-Sha256", "").strip().lower() or None
325
- if expected_sha256 is not None and hashlib.sha256(payload).hexdigest() != expected_sha256:
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: 'bytes', destination: 'Path') -> 'None':
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 target_path != destination_resolved and destination_resolved not in target_path.parents:
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: 'Path') -> 'Path':
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 len(children) == 1 and children[0].is_dir() and (children[0] / DEFAULT_ENTRY_CONFIG).is_file():
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: 'Path') -> 'typing.Dict[str, object]':
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: 'str', base_url: 'str') -> 'None':
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: 'typing.Union[Path, None]' = None) -> 'Path':
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 (candidate / "pycodex").is_dir():
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: 'str', field_name: 'str') -> 'str':
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")
@@ -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: 'typing.Union[str, Path]',
24
- host: 'str' = "127.0.0.1",
25
- port: 'int' = 5577,
26
- ) -> 'None':
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: 'typing.Union[threading.Thread, None]' = None
31
+ self._thread: "typing.Union[threading.Thread, None]" = None
33
32
 
34
33
  @property
35
- def host(self) -> 'str':
34
+ def host(self) -> "str":
36
35
  return str(self._server.server_address[0])
37
36
 
38
37
  @property
39
- def port(self) -> 'int':
38
+ def port(self) -> "int":
40
39
  return int(self._server.server_address[1])
41
40
 
42
41
  @property
43
- def server_address(self) -> 'str':
42
+ def server_address(self) -> "str":
44
43
  return f"{self.host}:{self.port}"
45
44
 
46
45
  @property
47
- def base_url(self) -> 'str':
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) -> 'Path':
50
+ def root(self) -> "Path":
52
51
  return self._root
53
52
 
54
- def start(self) -> 'None':
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) -> 'None':
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) -> 'None': # noqa: N802
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("X-Pycodex-Sha256", hashlib.sha256(payload).hexdigest())
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) -> 'None': # noqa: N802
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 = self.headers.get("X-Pycodex-Sha256", "").strip().lower()
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 = self.headers.get("Host", server.server_address).strip() or server.server_address
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: 'str', *_args) -> 'None':
148
+ def log_message(self, _format: "str", *_args) -> "None":
143
149
  return
144
150
 
145
- def _send_json(self, status: 'int', payload: 'typing.Dict[str, object]') -> 'None':
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: 'str') -> 'Path':
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() -> 'argparse.ArgumentParser':
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: 'typing.Union[typing.List[str], None]' = None) -> 'int':
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)