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.
Files changed (84) hide show
  1. pycodex/__init__.py +18 -14
  2. pycodex/agent.py +468 -462
  3. pycodex/bootstrap.py +417 -0
  4. pycodex/cli.py +236 -436
  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 +329 -252
  12. pycodex/model_metadata.py +19 -7
  13. pycodex/portable.py +90 -52
  14. pycodex/portable_server.py +32 -24
  15. pycodex/prompts/models.json +235 -803
  16. pycodex/protocol.py +177 -137
  17. pycodex/runtime.py +579 -174
  18. pycodex/runtime_services.py +204 -157
  19. pycodex/tools/__init__.py +4 -1
  20. pycodex/tools/apply_patch_tool.py +69 -48
  21. pycodex/tools/base_tool.py +89 -42
  22. pycodex/tools/clock_tool.py +201 -0
  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 +13 -13
  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 +50 -66
  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/utils/image_utils.py +76 -0
  52. pycodex/utils/random_ids.py +1 -2
  53. pycodex/utils/session_persist.py +263 -161
  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 +25 -22
  61. responses_server/messages_api.py +96 -49
  62. responses_server/payload_processors.py +25 -19
  63. responses_server/server.py +11 -11
  64. responses_server/session_store.py +14 -11
  65. responses_server/stream_router.py +196 -107
  66. responses_server/tools/custom_adapter.py +17 -16
  67. responses_server/tools/web_search.py +39 -36
  68. responses_server/trajectory_dump.py +51 -13
  69. workspace_server/__main__.py +0 -1
  70. workspace_server/app.py +470 -384
  71. workspace_server/workspace.html +859 -232
  72. workspace_server/workspaces.html +94 -95
  73. workspace_server/workspaces.py +168 -100
  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 -553
  80. python_codex-0.2.6.dist-info/METADATA +0 -441
  81. python_codex-0.2.6.dist-info/RECORD +0 -91
  82. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/WHEEL +0 -0
  83. {python_codex-0.2.6.dist-info → python_codex-0.3.0.dist-info}/entry_points.txt +0 -0
  84. {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
- 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
@@ -11,9 +11,7 @@ from typing import Callable
11
11
  from urllib.parse import quote, urlparse
12
12
 
13
13
  import requests
14
- from cryptography.exceptions import InvalidTag
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: 'typing.Union[str, None]' = None,
51
- event_handler: 'typing.Union[ProgressHandler, None]' = None,
52
- ) -> 'str':
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: 'str',
91
- storage_root: 'typing.Union[typing.Union[str, Path], None]' = None,
92
- ) -> 'Path':
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(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":
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(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":
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(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]":
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: 'Path', emit: 'ProgressHandler') -> 'bytes':
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: 'Path') -> 'typing.List[str]':
184
- included: 'typing.Set[str]' = set()
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: 'Path') -> 'typing.Set[str]':
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: 'typing.Set[str]' = set()
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 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)
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(root: 'Path', value: 'str') -> 'typing.Union[str, None]':
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: 'bytes', secret: 'str') -> 'bytes':
241
+ def _encrypt_bundle(bundle_bytes: "bytes", secret: "str") -> "bytes":
236
242
  nonce = os.urandom(NONCE_LENGTH)
237
- ciphertext = AESGCM(_encryption_key(secret)).encrypt(nonce, bundle_bytes, None)
238
- return ENCRYPTED_BUNDLE_MAGIC + nonce + ciphertext
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: '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]
245
- ciphertext = payload[len(ENCRYPTED_BUNDLE_MAGIC) + NONCE_LENGTH :]
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
- return AESGCM(_encryption_key(secret)).decrypt(nonce, ciphertext, None)
248
- except InvalidTag as exc:
249
- raise RemoteStorageError("call secret is invalid or bundle is corrupted") from exc
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: 'str') -> 'bytes':
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: 'bytes') -> 'str':
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: 'bytes') -> 'str':
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: 'typing.List[str]' = []
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(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]]":
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: 'str') -> 'typing.Tuple[str, str, str, str]':
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("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
+ )
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: 'str', call_id: 'str') -> 'bytes':
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(f"call download failed with status {response.status_code}")
337
+ raise RemoteStorageError(
338
+ f"call download failed with status {response.status_code}"
339
+ )
316
340
  payload = response.content
317
- expected_sha256 = response.headers.get("X-Pycodex-Sha256", "").strip().lower() or None
318
- 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
+ ):
319
348
  raise RemoteStorageError("downloaded bundle checksum mismatch")
320
349
  return payload
321
350
 
322
351
 
323
- def _extract_bundle_bytes(bundle_bytes: 'bytes', destination: 'Path') -> 'None':
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 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
+ ):
338
370
  raise RemoteStorageError("bundle contains unsafe paths")
339
371
  archive.extractall(destination)
340
372
 
341
373
 
342
- def _resolve_extracted_home(extract_root: 'Path') -> 'Path':
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 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
+ ):
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: 'Path') -> 'typing.Dict[str, object]':
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: 'str', base_url: 'str') -> 'None':
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: 'typing.Union[Path, None]' = None) -> 'Path':
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 (candidate / "pycodex").is_dir():
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: 'str', field_name: 'str') -> 'str':
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")
@@ -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)