synth-ai 0.2.14__py3-none-any.whl → 0.2.16__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.
Potentially problematic release.
This version of synth-ai might be problematic. Click here for more details.
- examples/README.md +1 -0
- examples/multi_step/SFT_README.md +147 -0
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +9 -9
- examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
- examples/multi_step/convert_traces_to_sft.py +84 -0
- examples/multi_step/run_sft_qwen30b.sh +45 -0
- examples/qwen_coder/configs/coder_lora_30b.toml +2 -1
- examples/qwen_coder/configs/coder_lora_4b.toml +2 -1
- examples/qwen_coder/configs/coder_lora_small.toml +2 -1
- examples/qwen_vl/BUGS_AND_FIXES.md +232 -0
- examples/qwen_vl/IMAGE_VALIDATION_COMPLETE.md +271 -0
- examples/qwen_vl/IMAGE_VALIDATION_SUMMARY.md +260 -0
- examples/qwen_vl/INFERENCE_SFT_TESTS.md +412 -0
- examples/qwen_vl/NEXT_STEPS_2B.md +325 -0
- examples/qwen_vl/QUICKSTART.md +327 -0
- examples/qwen_vl/QUICKSTART_RL_VISION.md +110 -0
- examples/qwen_vl/README.md +154 -0
- examples/qwen_vl/RL_VISION_COMPLETE.md +475 -0
- examples/qwen_vl/RL_VISION_TESTING.md +333 -0
- examples/qwen_vl/SDK_VISION_INTEGRATION.md +328 -0
- examples/qwen_vl/SETUP_COMPLETE.md +275 -0
- examples/qwen_vl/VISION_TESTS_COMPLETE.md +490 -0
- examples/qwen_vl/VLM_PIPELINE_COMPLETE.md +242 -0
- examples/qwen_vl/__init__.py +2 -0
- examples/qwen_vl/collect_data_via_cli.md +423 -0
- examples/qwen_vl/collect_vision_traces.py +368 -0
- examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +127 -0
- examples/qwen_vl/configs/crafter_vlm_sft_example.toml +60 -0
- examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +43 -0
- examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
- examples/qwen_vl/configs/eval_gpt5nano_vision.toml +45 -0
- examples/qwen_vl/configs/eval_qwen2vl_vision.toml +44 -0
- examples/qwen_vl/configs/filter_qwen2vl_sft.toml +50 -0
- examples/qwen_vl/configs/filter_vision_sft.toml +53 -0
- examples/qwen_vl/configs/filter_vision_test.toml +8 -0
- examples/qwen_vl/configs/sft_qwen3_vl_2b_test.toml +54 -0
- examples/qwen_vl/crafter_gpt5nano_agent.py +308 -0
- examples/qwen_vl/crafter_qwen_vl_agent.py +300 -0
- examples/qwen_vl/run_vision_comparison.sh +62 -0
- examples/qwen_vl/run_vision_sft_pipeline.sh +175 -0
- examples/qwen_vl/test_image_validation.py +201 -0
- examples/qwen_vl/test_sft_vision_data.py +110 -0
- examples/rl/README.md +1 -1
- examples/rl/configs/eval_base_qwen.toml +17 -0
- examples/rl/configs/eval_rl_qwen.toml +13 -0
- examples/rl/configs/rl_from_base_qwen.toml +37 -0
- examples/rl/configs/rl_from_base_qwen17.toml +76 -0
- examples/rl/configs/rl_from_ft_qwen.toml +37 -0
- examples/rl/run_eval.py +436 -0
- examples/rl/run_rl_and_save.py +111 -0
- examples/rl/task_app/README.md +22 -0
- examples/rl/task_app/math_single_step.py +990 -0
- examples/rl/task_app/math_task_app.py +111 -0
- examples/sft/README.md +5 -5
- examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -2
- examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -3
- examples/sft/evaluate.py +2 -4
- examples/sft/export_dataset.py +7 -4
- examples/swe/task_app/README.md +1 -1
- examples/swe/task_app/grpo_swe_mini.py +0 -1
- examples/swe/task_app/grpo_swe_mini_task_app.py +0 -12
- examples/swe/task_app/hosted/envs/mini_swe/environment.py +13 -13
- examples/swe/task_app/hosted/policy_routes.py +0 -2
- examples/swe/task_app/hosted/rollout.py +0 -8
- examples/task_apps/crafter/task_app/grpo_crafter.py +4 -7
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +59 -1
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +30 -0
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +62 -31
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +16 -14
- examples/task_apps/enron/__init__.py +1 -0
- examples/vlm/README.md +3 -3
- examples/vlm/configs/crafter_vlm_gpt4o.toml +2 -0
- examples/vlm/crafter_openai_vlm_agent.py +3 -5
- examples/vlm/filter_image_rows.py +1 -1
- examples/vlm/run_crafter_vlm_benchmark.py +2 -2
- examples/warming_up_to_rl/_utils.py +92 -0
- examples/warming_up_to_rl/analyze_trace_db.py +1 -1
- examples/warming_up_to_rl/configs/crafter_fft.toml +2 -0
- examples/warming_up_to_rl/configs/crafter_fft_4b.toml +2 -0
- examples/warming_up_to_rl/configs/eval_fft_qwen4b.toml +2 -0
- examples/warming_up_to_rl/configs/eval_groq_qwen32b.toml +2 -0
- examples/warming_up_to_rl/configs/eval_modal_qwen4b.toml +2 -1
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +2 -1
- examples/warming_up_to_rl/configs/rl_from_ft.toml +2 -0
- examples/warming_up_to_rl/export_trace_sft.py +174 -60
- examples/warming_up_to_rl/readme.md +63 -132
- examples/warming_up_to_rl/run_fft_and_save.py +1 -1
- examples/warming_up_to_rl/run_rl_and_save.py +1 -1
- examples/warming_up_to_rl/task_app/README.md +42 -0
- examples/warming_up_to_rl/task_app/grpo_crafter.py +696 -0
- examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +135 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/README.md +173 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/branching.py +143 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/environment_routes.py +1226 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/__init__.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/__init__.py +6 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/app.py +1 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/environment.py +522 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +478 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +108 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/shared.py +305 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/tools.py +47 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +204 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +618 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/main.py +100 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +1081 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/registry.py +195 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +1861 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/__init__.py +5 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/storage/volume.py +211 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_agents.py +161 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/test_service.py +137 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +62 -0
- synth_ai/__init__.py +44 -30
- synth_ai/_utils/__init__.py +47 -0
- synth_ai/_utils/base_url.py +10 -0
- synth_ai/_utils/http.py +10 -0
- synth_ai/_utils/prompts.py +10 -0
- synth_ai/_utils/task_app_state.py +12 -0
- synth_ai/_utils/user_config.py +10 -0
- synth_ai/api/models/supported.py +144 -7
- synth_ai/api/train/__init__.py +13 -1
- synth_ai/api/train/cli.py +30 -7
- synth_ai/api/train/config_finder.py +18 -11
- synth_ai/api/train/env_resolver.py +13 -10
- synth_ai/cli/__init__.py +62 -78
- synth_ai/cli/_modal_wrapper.py +7 -5
- synth_ai/cli/_typer_patch.py +0 -2
- synth_ai/cli/_validate_task_app.py +22 -4
- synth_ai/cli/legacy_root_backup.py +3 -1
- synth_ai/cli/lib/__init__.py +10 -0
- synth_ai/cli/lib/task_app_discovery.py +7 -0
- synth_ai/cli/lib/task_app_env.py +518 -0
- synth_ai/cli/recent.py +2 -1
- synth_ai/cli/setup.py +266 -0
- synth_ai/cli/status.py +1 -1
- synth_ai/cli/task_app_deploy.py +16 -0
- synth_ai/cli/task_app_list.py +25 -0
- synth_ai/cli/task_app_modal_serve.py +16 -0
- synth_ai/cli/task_app_serve.py +18 -0
- synth_ai/cli/task_apps.py +71 -31
- synth_ai/cli/traces.py +1 -1
- synth_ai/cli/train.py +18 -0
- synth_ai/cli/tui.py +7 -2
- synth_ai/cli/turso.py +1 -1
- synth_ai/cli/watch.py +1 -1
- synth_ai/demos/__init__.py +10 -0
- synth_ai/demos/core/__init__.py +28 -1
- synth_ai/demos/crafter/__init__.py +1 -0
- synth_ai/demos/crafter/crafter_fft_4b.toml +55 -0
- synth_ai/demos/crafter/grpo_crafter_task_app.py +185 -0
- synth_ai/demos/crafter/rl_from_base_qwen4b.toml +74 -0
- synth_ai/demos/demo_registry.py +176 -0
- synth_ai/demos/math/__init__.py +1 -0
- synth_ai/demos/math/_common.py +16 -0
- synth_ai/demos/math/app.py +38 -0
- synth_ai/demos/math/config.toml +76 -0
- synth_ai/demos/math/deploy_modal.py +54 -0
- synth_ai/demos/math/modal_task_app.py +702 -0
- synth_ai/demos/math/task_app_entry.py +51 -0
- synth_ai/environments/environment/core.py +7 -1
- synth_ai/environments/examples/bandit/engine.py +0 -1
- synth_ai/environments/examples/bandit/environment.py +0 -1
- synth_ai/environments/examples/wordle/environment.py +0 -1
- synth_ai/evals/base.py +16 -5
- synth_ai/evals/client.py +1 -1
- synth_ai/inference/client.py +1 -1
- synth_ai/judge_schemas.py +8 -8
- synth_ai/learning/client.py +1 -1
- synth_ai/learning/health.py +1 -1
- synth_ai/learning/jobs.py +1 -1
- synth_ai/learning/rl/client.py +1 -1
- synth_ai/learning/rl/env_keys.py +1 -1
- synth_ai/learning/rl/secrets.py +1 -1
- synth_ai/learning/sft/client.py +1 -1
- synth_ai/learning/sft/data.py +407 -4
- synth_ai/learning/validators.py +4 -1
- synth_ai/task/apps/__init__.py +4 -2
- synth_ai/task/config.py +6 -4
- synth_ai/task/rubrics/__init__.py +1 -2
- synth_ai/task/rubrics/loaders.py +14 -10
- synth_ai/task/rubrics.py +219 -0
- synth_ai/task/trace_correlation_helpers.py +24 -11
- synth_ai/task/tracing_utils.py +14 -3
- synth_ai/task/validators.py +2 -3
- synth_ai/tracing_v3/abstractions.py +3 -3
- synth_ai/tracing_v3/config.py +15 -13
- synth_ai/tracing_v3/constants.py +21 -0
- synth_ai/tracing_v3/db_config.py +3 -1
- synth_ai/tracing_v3/decorators.py +10 -7
- synth_ai/tracing_v3/llm_call_record_helpers.py +5 -5
- synth_ai/tracing_v3/session_tracer.py +7 -7
- synth_ai/tracing_v3/storage/base.py +29 -29
- synth_ai/tracing_v3/storage/config.py +3 -3
- synth_ai/tracing_v3/turso/daemon.py +8 -9
- synth_ai/tracing_v3/turso/native_manager.py +80 -72
- synth_ai/tracing_v3/utils.py +2 -2
- synth_ai/tui/cli/query_experiments.py +4 -4
- synth_ai/tui/cli/query_experiments_v3.py +4 -4
- synth_ai/tui/dashboard.py +14 -9
- synth_ai/utils/__init__.py +101 -0
- synth_ai/utils/base_url.py +94 -0
- synth_ai/utils/cli.py +131 -0
- synth_ai/utils/env.py +287 -0
- synth_ai/utils/http.py +169 -0
- synth_ai/utils/modal.py +308 -0
- synth_ai/utils/process.py +212 -0
- synth_ai/utils/prompts.py +39 -0
- synth_ai/utils/sqld.py +122 -0
- synth_ai/utils/task_app_discovery.py +882 -0
- synth_ai/utils/task_app_env.py +186 -0
- synth_ai/utils/task_app_state.py +318 -0
- synth_ai/utils/user_config.py +137 -0
- synth_ai/v0/config/__init__.py +1 -5
- synth_ai/v0/config/base_url.py +1 -7
- synth_ai/v0/tracing/config.py +1 -1
- synth_ai/v0/tracing/decorators.py +1 -1
- synth_ai/v0/tracing/upload.py +1 -1
- synth_ai/v0/tracing_v1/config.py +1 -1
- synth_ai/v0/tracing_v1/decorators.py +1 -1
- synth_ai/v0/tracing_v1/upload.py +1 -1
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/METADATA +85 -31
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/RECORD +229 -117
- synth_ai/cli/man.py +0 -106
- synth_ai/compound/cais.py +0 -0
- synth_ai/core/experiment.py +0 -13
- synth_ai/core/system.py +0 -15
- synth_ai/demo_registry.py +0 -295
- synth_ai/handshake.py +0 -109
- synth_ai/http.py +0 -26
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.16.dist-info}/top_level.txt +0 -0
synth_ai/utils/http.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import aiohttp
|
|
9
|
+
|
|
10
|
+
__all__ = ["HTTPError", "AsyncHttpClient", "http_request", "sleep"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HTTPError(Exception):
|
|
15
|
+
status: int
|
|
16
|
+
url: str
|
|
17
|
+
message: str
|
|
18
|
+
body_snippet: str | None = None
|
|
19
|
+
detail: Any | None = None
|
|
20
|
+
|
|
21
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
22
|
+
base = f"HTTP {self.status} for {self.url}: {self.message}"
|
|
23
|
+
if self.body_snippet:
|
|
24
|
+
base += f" | body[0:200]={self.body_snippet[:200]}"
|
|
25
|
+
return base
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class AsyncHttpClient:
|
|
29
|
+
def __init__(self, base_url: str, api_key: str, timeout: float = 30.0) -> None:
|
|
30
|
+
self._base_url = base_url.rstrip("/")
|
|
31
|
+
self._api_key = api_key
|
|
32
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
33
|
+
self._session: aiohttp.ClientSession | None = None
|
|
34
|
+
|
|
35
|
+
async def __aenter__(self) -> AsyncHttpClient:
|
|
36
|
+
if self._session is None:
|
|
37
|
+
headers = {"authorization": f"Bearer {self._api_key}"}
|
|
38
|
+
user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
|
|
39
|
+
if user_id:
|
|
40
|
+
headers["X-User-ID"] = user_id
|
|
41
|
+
org_id = os.getenv("SYNTH_ORG_ID") or os.getenv("X_ORG_ID") or os.getenv("ORG_ID")
|
|
42
|
+
if org_id:
|
|
43
|
+
headers["X-Org-ID"] = org_id
|
|
44
|
+
self._session = aiohttp.ClientSession(headers=headers, timeout=self._timeout)
|
|
45
|
+
return self
|
|
46
|
+
|
|
47
|
+
async def __aexit__(self, exc_type, exc, tb) -> None: # noqa: ANN001
|
|
48
|
+
if self._session is not None:
|
|
49
|
+
await self._session.close()
|
|
50
|
+
self._session = None
|
|
51
|
+
|
|
52
|
+
def _abs(self, path: str) -> str:
|
|
53
|
+
if path.startswith("http://") or path.startswith("https://"):
|
|
54
|
+
return path
|
|
55
|
+
if self._base_url.endswith("/api") and path.startswith("/api"):
|
|
56
|
+
path = path[4:]
|
|
57
|
+
return f"{self._base_url}/{path.lstrip('/')}"
|
|
58
|
+
|
|
59
|
+
async def get(
|
|
60
|
+
self,
|
|
61
|
+
path: str,
|
|
62
|
+
*,
|
|
63
|
+
params: dict[str, Any] | None = None,
|
|
64
|
+
headers: dict[str, str] | None = None,
|
|
65
|
+
) -> Any:
|
|
66
|
+
url = self._abs(path)
|
|
67
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
68
|
+
async with self._session.get(url, params=params, headers=headers) as resp:
|
|
69
|
+
return await self._handle_response(resp, url)
|
|
70
|
+
|
|
71
|
+
async def post_json(
|
|
72
|
+
self, path: str, *, json: dict[str, Any], headers: dict[str, str] | None = None
|
|
73
|
+
) -> Any:
|
|
74
|
+
url = self._abs(path)
|
|
75
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
76
|
+
async with self._session.post(url, json=json, headers=headers) as resp:
|
|
77
|
+
return await self._handle_response(resp, url)
|
|
78
|
+
|
|
79
|
+
async def post_multipart(
|
|
80
|
+
self,
|
|
81
|
+
path: str,
|
|
82
|
+
*,
|
|
83
|
+
data: dict[str, Any],
|
|
84
|
+
files: dict[str, tuple[str, bytes, str | None]],
|
|
85
|
+
headers: dict[str, str] | None = None,
|
|
86
|
+
) -> Any:
|
|
87
|
+
url = self._abs(path)
|
|
88
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
89
|
+
form = aiohttp.FormData()
|
|
90
|
+
for k, v in data.items():
|
|
91
|
+
form.add_field(k, str(v))
|
|
92
|
+
for field, (filename, content, content_type) in files.items():
|
|
93
|
+
form.add_field(
|
|
94
|
+
field,
|
|
95
|
+
content,
|
|
96
|
+
filename=filename,
|
|
97
|
+
content_type=content_type or "application/octet-stream",
|
|
98
|
+
)
|
|
99
|
+
async with self._session.post(url, data=form, headers=headers) as resp:
|
|
100
|
+
return await self._handle_response(resp, url)
|
|
101
|
+
|
|
102
|
+
async def delete(self, path: str, *, headers: dict[str, str] | None = None) -> Any:
|
|
103
|
+
url = self._abs(path)
|
|
104
|
+
assert self._session is not None, "AsyncHttpClient must be used as an async context manager"
|
|
105
|
+
async with self._session.delete(url, headers=headers) as resp:
|
|
106
|
+
return await self._handle_response(resp, url)
|
|
107
|
+
|
|
108
|
+
async def _handle_response(self, resp: aiohttp.ClientResponse, url: str) -> Any:
|
|
109
|
+
text = await resp.text()
|
|
110
|
+
body_snippet = text[:200] if text else None
|
|
111
|
+
if 200 <= resp.status < 300:
|
|
112
|
+
ctype = resp.headers.get("content-type", "")
|
|
113
|
+
if "application/json" in ctype:
|
|
114
|
+
try:
|
|
115
|
+
return await resp.json()
|
|
116
|
+
except Exception:
|
|
117
|
+
return text
|
|
118
|
+
return text
|
|
119
|
+
detail: Any | None = None
|
|
120
|
+
try:
|
|
121
|
+
detail = await resp.json()
|
|
122
|
+
except Exception:
|
|
123
|
+
detail = None
|
|
124
|
+
raise HTTPError(
|
|
125
|
+
status=resp.status,
|
|
126
|
+
url=url,
|
|
127
|
+
message="request_failed",
|
|
128
|
+
body_snippet=body_snippet,
|
|
129
|
+
detail=detail,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def http_request(
|
|
134
|
+
method: str, url: str, headers: dict[str, str] | None = None, body: dict[str, Any] | None = None
|
|
135
|
+
) -> tuple[int, dict[str, Any] | str]:
|
|
136
|
+
import json as _json
|
|
137
|
+
import ssl
|
|
138
|
+
import urllib.error
|
|
139
|
+
import urllib.request
|
|
140
|
+
|
|
141
|
+
data = None
|
|
142
|
+
if body is not None:
|
|
143
|
+
data = _json.dumps(body).encode("utf-8")
|
|
144
|
+
req = urllib.request.Request(url, method=method, headers=headers or {}, data=data)
|
|
145
|
+
try:
|
|
146
|
+
ctx = ssl._create_unverified_context()
|
|
147
|
+
if os.getenv("SYNTH_SSL_VERIFY", "0") == "1":
|
|
148
|
+
ctx = None
|
|
149
|
+
with urllib.request.urlopen(req, timeout=60, context=ctx) as resp:
|
|
150
|
+
code = getattr(resp, "status", 200)
|
|
151
|
+
txt = resp.read().decode("utf-8", errors="ignore")
|
|
152
|
+
try:
|
|
153
|
+
return int(code), _json.loads(txt)
|
|
154
|
+
except Exception:
|
|
155
|
+
return int(code), txt
|
|
156
|
+
except urllib.error.HTTPError as exc: # Capture 4xx/5xx bodies
|
|
157
|
+
txt = exc.read().decode("utf-8", errors="ignore")
|
|
158
|
+
try:
|
|
159
|
+
return int(exc.code or 0), _json.loads(txt)
|
|
160
|
+
except Exception:
|
|
161
|
+
return int(exc.code or 0), txt
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
return 0, str(exc)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def sleep(seconds: float) -> None:
|
|
167
|
+
"""Small async sleep helper preserved for backwards compatibility."""
|
|
168
|
+
|
|
169
|
+
await asyncio.sleep(max(float(seconds or 0.0), 0.0))
|
synth_ai/utils/modal.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
from urllib.parse import urlparse, urlunparse
|
|
9
|
+
|
|
10
|
+
from synth_ai.demos import core as demo_core
|
|
11
|
+
from synth_ai.demos.core import DEFAULT_TASK_APP_SECRET_NAME, DemoEnv
|
|
12
|
+
|
|
13
|
+
from .env import mask_str
|
|
14
|
+
from .http import http_request
|
|
15
|
+
from .process import popen_capture
|
|
16
|
+
from .user_config import load_user_config
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ensure_modal_installed",
|
|
20
|
+
"ensure_task_app_ready",
|
|
21
|
+
"find_asgi_apps",
|
|
22
|
+
"is_local_demo_url",
|
|
23
|
+
"is_modal_public_url",
|
|
24
|
+
"normalize_endpoint_url",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def is_modal_public_url(url: str | None) -> bool:
|
|
29
|
+
try:
|
|
30
|
+
candidate = (url or "").strip().lower()
|
|
31
|
+
if not candidate or not (candidate.startswith("http://") or candidate.startswith("https://")):
|
|
32
|
+
return False
|
|
33
|
+
return (".modal.run" in candidate) and ("modal.local" not in candidate) and ("pypi-mirror" not in candidate)
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def is_local_demo_url(url: str | None) -> bool:
|
|
39
|
+
try:
|
|
40
|
+
candidate = (url or "").strip().lower()
|
|
41
|
+
if not candidate:
|
|
42
|
+
return False
|
|
43
|
+
return candidate.startswith("http://127.0.0.1") or candidate.startswith("http://localhost")
|
|
44
|
+
except Exception:
|
|
45
|
+
return False
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def normalize_endpoint_url(url: str) -> str:
|
|
49
|
+
"""Convert loopback URLs to forms accepted by the backend."""
|
|
50
|
+
if not url:
|
|
51
|
+
return url
|
|
52
|
+
try:
|
|
53
|
+
parsed = urlparse(url)
|
|
54
|
+
host = parsed.hostname or ""
|
|
55
|
+
if host in {"127.0.0.1", "::1"}:
|
|
56
|
+
new_host = "localhost"
|
|
57
|
+
netloc = new_host
|
|
58
|
+
if parsed.port:
|
|
59
|
+
netloc = f"{new_host}:{parsed.port}"
|
|
60
|
+
if parsed.username:
|
|
61
|
+
creds = parsed.username
|
|
62
|
+
if parsed.password:
|
|
63
|
+
creds += f":{parsed.password}"
|
|
64
|
+
netloc = f"{creds}@{netloc}"
|
|
65
|
+
parsed = parsed._replace(netloc=netloc)
|
|
66
|
+
return cast(str, urlunparse(parsed))
|
|
67
|
+
except Exception:
|
|
68
|
+
pass
|
|
69
|
+
return url
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def find_asgi_apps(root: Path) -> list[Path]:
|
|
73
|
+
"""Recursively search for Python files that declare a Modal ASGI app."""
|
|
74
|
+
results: list[Path] = []
|
|
75
|
+
skip_dirs = {
|
|
76
|
+
".git",
|
|
77
|
+
".hg",
|
|
78
|
+
".svn",
|
|
79
|
+
"node_modules",
|
|
80
|
+
"dist",
|
|
81
|
+
"build",
|
|
82
|
+
"__pycache__",
|
|
83
|
+
".ruff_cache",
|
|
84
|
+
".mypy_cache",
|
|
85
|
+
"venv",
|
|
86
|
+
".venv",
|
|
87
|
+
}
|
|
88
|
+
for dirpath, dirnames, filenames in os.walk(root):
|
|
89
|
+
dirnames[:] = [d for d in dirnames if d not in skip_dirs]
|
|
90
|
+
for name in filenames:
|
|
91
|
+
if not name.endswith(".py"):
|
|
92
|
+
continue
|
|
93
|
+
path = Path(dirpath) / name
|
|
94
|
+
try:
|
|
95
|
+
with path.open("r", encoding="utf-8", errors="ignore") as fh:
|
|
96
|
+
txt = fh.read()
|
|
97
|
+
if ("@asgi_app()" in txt) or ("@modal.asgi_app()" in txt):
|
|
98
|
+
results.append(path)
|
|
99
|
+
except Exception:
|
|
100
|
+
continue
|
|
101
|
+
|
|
102
|
+
def _priority(path: Path) -> tuple[int, str]:
|
|
103
|
+
rel = str(path.resolve())
|
|
104
|
+
in_demo = "/synth_demo/" in rel or rel.endswith("/synth_demo/task_app.py")
|
|
105
|
+
return (0 if in_demo else 1, rel)
|
|
106
|
+
|
|
107
|
+
results.sort(key=_priority)
|
|
108
|
+
return results
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def ensure_task_app_ready(env: DemoEnv, synth_key: str, *, label: str) -> DemoEnv:
|
|
112
|
+
persist_path = demo_core.load_demo_dir() or os.getcwd()
|
|
113
|
+
user_config_map = load_user_config()
|
|
114
|
+
|
|
115
|
+
env_key = (env.env_api_key or "").strip()
|
|
116
|
+
if not env_key:
|
|
117
|
+
raise RuntimeError(
|
|
118
|
+
f"[{label}] ENVIRONMENT_API_KEY missing. Run `uvx synth-ai demo deploy` first."
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
template_id = demo_core.load_template_id()
|
|
122
|
+
allow_local = template_id == "crafter-local"
|
|
123
|
+
|
|
124
|
+
task_url = env.task_app_base_url
|
|
125
|
+
url_ok = is_modal_public_url(task_url) or (allow_local and is_local_demo_url(task_url or ""))
|
|
126
|
+
if not task_url or not url_ok:
|
|
127
|
+
resolved = task_url or ""
|
|
128
|
+
dynamic_lookup_allowed = env.task_app_name and not (
|
|
129
|
+
allow_local and is_local_demo_url(task_url or "")
|
|
130
|
+
)
|
|
131
|
+
if dynamic_lookup_allowed and not is_modal_public_url(resolved):
|
|
132
|
+
code, out = popen_capture(
|
|
133
|
+
[
|
|
134
|
+
"uv",
|
|
135
|
+
"run",
|
|
136
|
+
"python",
|
|
137
|
+
"-m",
|
|
138
|
+
"modal",
|
|
139
|
+
"app",
|
|
140
|
+
"url",
|
|
141
|
+
env.task_app_name,
|
|
142
|
+
]
|
|
143
|
+
)
|
|
144
|
+
if code == 0 and out:
|
|
145
|
+
for token in out.split():
|
|
146
|
+
if is_modal_public_url(token):
|
|
147
|
+
resolved = token.strip().rstrip("/")
|
|
148
|
+
break
|
|
149
|
+
if dynamic_lookup_allowed and not is_modal_public_url(resolved):
|
|
150
|
+
try:
|
|
151
|
+
choice = (
|
|
152
|
+
input(
|
|
153
|
+
f"Resolve URL from Modal for app '{env.task_app_name}'? [Y/n]: "
|
|
154
|
+
).strip().lower()
|
|
155
|
+
or "y"
|
|
156
|
+
)
|
|
157
|
+
except Exception:
|
|
158
|
+
choice = "y"
|
|
159
|
+
if choice.startswith("y"):
|
|
160
|
+
code, out = popen_capture(
|
|
161
|
+
[
|
|
162
|
+
"uv",
|
|
163
|
+
"run",
|
|
164
|
+
"python",
|
|
165
|
+
"-m",
|
|
166
|
+
"modal",
|
|
167
|
+
"app",
|
|
168
|
+
"url",
|
|
169
|
+
env.task_app_name,
|
|
170
|
+
]
|
|
171
|
+
)
|
|
172
|
+
if code == 0 and out:
|
|
173
|
+
for token in out.split():
|
|
174
|
+
if is_modal_public_url(token):
|
|
175
|
+
resolved = token.strip().rstrip("/")
|
|
176
|
+
break
|
|
177
|
+
if not is_modal_public_url(resolved):
|
|
178
|
+
hint = "Examples: https://<app-name>-fastapi-app.modal.run"
|
|
179
|
+
if allow_local:
|
|
180
|
+
hint += " or http://127.0.0.1:8001"
|
|
181
|
+
print(f"[{label}] Task app URL not configured or not a valid target.")
|
|
182
|
+
print(hint)
|
|
183
|
+
entered = input(
|
|
184
|
+
"Enter Task App base URL (must contain '.modal.run'), or press Enter to abort: "
|
|
185
|
+
).strip()
|
|
186
|
+
if not entered:
|
|
187
|
+
raise RuntimeError(f"[{label}] Task App URL is required.")
|
|
188
|
+
entered_clean = entered.rstrip("/")
|
|
189
|
+
if not (
|
|
190
|
+
is_modal_public_url(entered_clean)
|
|
191
|
+
or (allow_local and is_local_demo_url(entered_clean))
|
|
192
|
+
):
|
|
193
|
+
raise RuntimeError(f"[{label}] Valid Task App URL is required.")
|
|
194
|
+
task_url = entered_clean
|
|
195
|
+
else:
|
|
196
|
+
task_url = resolved
|
|
197
|
+
demo_core.persist_task_url(task_url, name=(env.task_app_name or None), path=persist_path)
|
|
198
|
+
|
|
199
|
+
app_name = (env.task_app_name or "").strip()
|
|
200
|
+
requires_modal_name = is_modal_public_url(task_url)
|
|
201
|
+
if requires_modal_name and not app_name:
|
|
202
|
+
fallback = input("Enter Modal app name for the task app (required): ").strip()
|
|
203
|
+
if not fallback:
|
|
204
|
+
raise RuntimeError(f"[{label}] Task app name is required.")
|
|
205
|
+
app_name = fallback
|
|
206
|
+
demo_core.persist_task_url(task_url, name=app_name, path=persist_path)
|
|
207
|
+
|
|
208
|
+
demo_core.persist_task_url(task_url, name=app_name if requires_modal_name else None, path=persist_path)
|
|
209
|
+
if synth_key:
|
|
210
|
+
os.environ["SYNTH_API_KEY"] = synth_key
|
|
211
|
+
|
|
212
|
+
openai_key = (
|
|
213
|
+
os.environ.get("OPENAI_API_KEY")
|
|
214
|
+
or str(user_config_map.get("OPENAI_API_KEY") or "")
|
|
215
|
+
).strip()
|
|
216
|
+
if openai_key:
|
|
217
|
+
os.environ["OPENAI_API_KEY"] = openai_key
|
|
218
|
+
|
|
219
|
+
print(f"[{label}] Verifying rollout health:")
|
|
220
|
+
try:
|
|
221
|
+
preview = mask_str(env_key)
|
|
222
|
+
print(f"[{label}] {preview}")
|
|
223
|
+
except Exception:
|
|
224
|
+
pass
|
|
225
|
+
health_base = task_url.rstrip("/")
|
|
226
|
+
health_urls = [f"{health_base}/health/rollout", f"{health_base}/health"]
|
|
227
|
+
rc = 0
|
|
228
|
+
body: Any = ""
|
|
229
|
+
for h in health_urls:
|
|
230
|
+
print(f"[{label}] GET", h)
|
|
231
|
+
rc, body = http_request("GET", h, headers={"X-API-Key": env_key})
|
|
232
|
+
if rc == 200:
|
|
233
|
+
break
|
|
234
|
+
print(f"[{label}] status: {rc}")
|
|
235
|
+
try:
|
|
236
|
+
preview_body = json.dumps(body)[:800] if isinstance(body, dict) else str(body)[:800]
|
|
237
|
+
except Exception:
|
|
238
|
+
preview_body = str(body)[:800]
|
|
239
|
+
print(f"[{label}] body:", preview_body)
|
|
240
|
+
if rc != 200:
|
|
241
|
+
print(f"[{label}] Warning: rollout health check failed ({rc}). Response: {body}")
|
|
242
|
+
with contextlib.suppress(Exception):
|
|
243
|
+
print(f"[{label}] Sent header X-API-Key → {mask_str(env_key)}")
|
|
244
|
+
else:
|
|
245
|
+
print(f"[{label}] Task app rollout health check OK.")
|
|
246
|
+
|
|
247
|
+
os.environ["TASK_APP_BASE_URL"] = task_url
|
|
248
|
+
os.environ["ENVIRONMENT_API_KEY"] = env_key
|
|
249
|
+
os.environ["TASK_APP_SECRET_NAME"] = DEFAULT_TASK_APP_SECRET_NAME
|
|
250
|
+
updated_env = demo_core.load_env()
|
|
251
|
+
updated_env.env_api_key = env_key
|
|
252
|
+
updated_env.task_app_base_url = task_url
|
|
253
|
+
updated_env.task_app_name = app_name if requires_modal_name else ""
|
|
254
|
+
updated_env.task_app_secret_name = DEFAULT_TASK_APP_SECRET_NAME
|
|
255
|
+
return updated_env
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def ensure_modal_installed() -> None:
|
|
259
|
+
"""Install the modal package if it is not already available and check authentication."""
|
|
260
|
+
modal_installed = False
|
|
261
|
+
try:
|
|
262
|
+
import importlib.util as import_util
|
|
263
|
+
|
|
264
|
+
if import_util.find_spec("modal") is not None:
|
|
265
|
+
modal_installed = True
|
|
266
|
+
except Exception:
|
|
267
|
+
pass
|
|
268
|
+
|
|
269
|
+
if not modal_installed:
|
|
270
|
+
print("modal not found; installing…")
|
|
271
|
+
try:
|
|
272
|
+
if shutil.which("uv"):
|
|
273
|
+
code, out = popen_capture(["uv", "pip", "install", "modal>=1.1.4"])
|
|
274
|
+
else:
|
|
275
|
+
code, out = popen_capture([sys.executable, "-m", "pip", "install", "modal>=1.1.4"])
|
|
276
|
+
if code != 0:
|
|
277
|
+
print(out)
|
|
278
|
+
print("Failed to install modal; continuing may fail.")
|
|
279
|
+
return
|
|
280
|
+
print("✓ modal installed successfully")
|
|
281
|
+
modal_installed = True
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
print(f"modal install error: {exc}")
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
if modal_installed:
|
|
287
|
+
try:
|
|
288
|
+
import importlib.util as import_util
|
|
289
|
+
|
|
290
|
+
if import_util.find_spec("modal") is None:
|
|
291
|
+
print("Warning: modal is still not importable after install attempt.")
|
|
292
|
+
return
|
|
293
|
+
except Exception:
|
|
294
|
+
print("Warning: unable to verify modal installation.")
|
|
295
|
+
return
|
|
296
|
+
|
|
297
|
+
auth_ok, auth_msg = demo_core.modal_auth_status()
|
|
298
|
+
if auth_ok:
|
|
299
|
+
print(f"✓ Modal authenticated: {auth_msg}")
|
|
300
|
+
else:
|
|
301
|
+
print("\n⚠️ Modal authentication required")
|
|
302
|
+
print(f" Status: {auth_msg}")
|
|
303
|
+
print("\n To authenticate Modal, run:")
|
|
304
|
+
print(" modal setup")
|
|
305
|
+
print("\n Or set environment variables:")
|
|
306
|
+
print(" export MODAL_TOKEN_ID=your-token-id")
|
|
307
|
+
print(" export MODAL_TOKEN_SECRET=your-token-secret")
|
|
308
|
+
print("\n You can deploy later after authenticating.\n")
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import signal
|
|
3
|
+
import socket
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ensure_local_port_available",
|
|
10
|
+
"popen_capture",
|
|
11
|
+
"popen_stream",
|
|
12
|
+
"popen_stream_capture",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def popen_capture(
|
|
17
|
+
cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
|
|
18
|
+
) -> tuple[int, str]:
|
|
19
|
+
"""Execute a subprocess and capture combined stdout/stderr."""
|
|
20
|
+
import subprocess
|
|
21
|
+
|
|
22
|
+
try:
|
|
23
|
+
proc = subprocess.Popen(
|
|
24
|
+
cmd,
|
|
25
|
+
cwd=cwd,
|
|
26
|
+
env=env,
|
|
27
|
+
stdout=subprocess.PIPE,
|
|
28
|
+
stderr=subprocess.STDOUT,
|
|
29
|
+
text=True,
|
|
30
|
+
)
|
|
31
|
+
out, _ = proc.communicate()
|
|
32
|
+
return int(proc.returncode or 0), out or ""
|
|
33
|
+
except Exception as exc:
|
|
34
|
+
return 1, str(exc)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def popen_stream(
|
|
38
|
+
cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
|
|
39
|
+
) -> int:
|
|
40
|
+
"""Stream subprocess output line-by-line to stdout for real-time feedback."""
|
|
41
|
+
import subprocess
|
|
42
|
+
import threading
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
proc = subprocess.Popen(
|
|
46
|
+
cmd,
|
|
47
|
+
cwd=cwd,
|
|
48
|
+
env=env,
|
|
49
|
+
stdout=subprocess.PIPE,
|
|
50
|
+
stderr=subprocess.STDOUT,
|
|
51
|
+
text=True,
|
|
52
|
+
bufsize=1,
|
|
53
|
+
)
|
|
54
|
+
except Exception as exc:
|
|
55
|
+
print(f"Failed to launch {' '.join(cmd)}: {exc}")
|
|
56
|
+
return 1
|
|
57
|
+
|
|
58
|
+
def _pump(stdout) -> None:
|
|
59
|
+
try:
|
|
60
|
+
for line in stdout:
|
|
61
|
+
print(line.rstrip())
|
|
62
|
+
except Exception:
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
if proc.stdout is not None:
|
|
66
|
+
t = threading.Thread(target=_pump, args=(proc.stdout,), daemon=True)
|
|
67
|
+
t.start()
|
|
68
|
+
proc.wait()
|
|
69
|
+
t.join(timeout=1.0)
|
|
70
|
+
else:
|
|
71
|
+
proc.wait()
|
|
72
|
+
return int(proc.returncode or 0)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def popen_stream_capture(
|
|
76
|
+
cmd: list[str], cwd: str | None = None, env: dict[str, Any] | None = None
|
|
77
|
+
) -> tuple[int, str]:
|
|
78
|
+
"""Stream subprocess output to stdout and also capture it into a buffer."""
|
|
79
|
+
import subprocess
|
|
80
|
+
import threading
|
|
81
|
+
|
|
82
|
+
buf_lines: list[str] = []
|
|
83
|
+
try:
|
|
84
|
+
proc = subprocess.Popen(
|
|
85
|
+
cmd,
|
|
86
|
+
cwd=cwd,
|
|
87
|
+
env=env,
|
|
88
|
+
stdout=subprocess.PIPE,
|
|
89
|
+
stderr=subprocess.STDOUT,
|
|
90
|
+
text=True,
|
|
91
|
+
bufsize=1,
|
|
92
|
+
)
|
|
93
|
+
except Exception as exc:
|
|
94
|
+
print(f"Failed to launch {' '.join(cmd)}: {exc}")
|
|
95
|
+
return 1, ""
|
|
96
|
+
|
|
97
|
+
def _pump(stdout) -> None:
|
|
98
|
+
try:
|
|
99
|
+
for line in stdout:
|
|
100
|
+
line = line.rstrip()
|
|
101
|
+
print(line)
|
|
102
|
+
buf_lines.append(line)
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
|
|
106
|
+
if proc.stdout is not None:
|
|
107
|
+
t = threading.Thread(target=_pump, args=(proc.stdout,), daemon=True)
|
|
108
|
+
t.start()
|
|
109
|
+
proc.wait()
|
|
110
|
+
t.join(timeout=1.0)
|
|
111
|
+
else:
|
|
112
|
+
proc.wait()
|
|
113
|
+
return int(proc.returncode or 0), "\n".join(buf_lines)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _list_process_ids(port: int) -> list[int]:
|
|
117
|
+
try:
|
|
118
|
+
import subprocess
|
|
119
|
+
|
|
120
|
+
out = subprocess.run(
|
|
121
|
+
["lsof", "-ti", f"TCP:{port}"],
|
|
122
|
+
capture_output=True,
|
|
123
|
+
text=True,
|
|
124
|
+
check=False,
|
|
125
|
+
)
|
|
126
|
+
if not out.stdout:
|
|
127
|
+
return []
|
|
128
|
+
result: list[int] = []
|
|
129
|
+
for token in out.stdout.strip().splitlines():
|
|
130
|
+
token = token.strip()
|
|
131
|
+
if token.isdigit():
|
|
132
|
+
result.append(int(token))
|
|
133
|
+
return result
|
|
134
|
+
except Exception:
|
|
135
|
+
return []
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _terminate_pids(pids: Iterable[int], *, aggressive: bool) -> bool:
|
|
139
|
+
terminated_any = False
|
|
140
|
+
for pid in pids:
|
|
141
|
+
try:
|
|
142
|
+
os.kill(pid, signal.SIGTERM)
|
|
143
|
+
terminated_any = True
|
|
144
|
+
except Exception as exc:
|
|
145
|
+
print(f"Failed to terminate PID {pid}: {exc}")
|
|
146
|
+
if terminated_any:
|
|
147
|
+
time.sleep(1.0)
|
|
148
|
+
|
|
149
|
+
if aggressive and pids:
|
|
150
|
+
still_running = []
|
|
151
|
+
for pid in pids:
|
|
152
|
+
try:
|
|
153
|
+
os.kill(pid, 0)
|
|
154
|
+
except OSError:
|
|
155
|
+
continue
|
|
156
|
+
still_running.append(pid)
|
|
157
|
+
if still_running:
|
|
158
|
+
for pid in still_running:
|
|
159
|
+
try:
|
|
160
|
+
os.kill(pid, signal.SIGKILL)
|
|
161
|
+
except Exception as exc:
|
|
162
|
+
print(f"Failed to force terminate PID {pid}: {exc}")
|
|
163
|
+
time.sleep(0.5)
|
|
164
|
+
return terminated_any
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def ensure_local_port_available(host: str, port: int, *, force: bool = False) -> bool:
|
|
168
|
+
"""Ensure ``host:port`` is free before starting a local server."""
|
|
169
|
+
|
|
170
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
171
|
+
sock.settimeout(0.5)
|
|
172
|
+
in_use = sock.connect_ex((host, port)) == 0
|
|
173
|
+
if not in_use:
|
|
174
|
+
return True
|
|
175
|
+
|
|
176
|
+
print(f"Port {port} on {host} is already in use.")
|
|
177
|
+
pids = _list_process_ids(port)
|
|
178
|
+
|
|
179
|
+
if pids:
|
|
180
|
+
print("Found processes using this port:")
|
|
181
|
+
for pid in pids:
|
|
182
|
+
print(f" PID {pid}")
|
|
183
|
+
else:
|
|
184
|
+
print("Could not automatically identify the owning process.")
|
|
185
|
+
|
|
186
|
+
if not force:
|
|
187
|
+
try:
|
|
188
|
+
choice = input(f"Stop the existing process on port {port}? [y/N]: ").strip().lower() or "n"
|
|
189
|
+
except Exception:
|
|
190
|
+
choice = "n"
|
|
191
|
+
if not choice.startswith("y"):
|
|
192
|
+
print("Aborting; stop the running server and try again.")
|
|
193
|
+
return False
|
|
194
|
+
else:
|
|
195
|
+
print("Attempting to terminate the existing process...")
|
|
196
|
+
|
|
197
|
+
if pids:
|
|
198
|
+
_terminate_pids(pids, aggressive=force)
|
|
199
|
+
else:
|
|
200
|
+
print("Unable to determine owning process. Please stop it manually and retry.")
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
for _ in range(10):
|
|
204
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
205
|
+
sock.settimeout(0.5)
|
|
206
|
+
if sock.connect_ex((host, port)) != 0:
|
|
207
|
+
print("Port is now available.")
|
|
208
|
+
return True
|
|
209
|
+
time.sleep(0.5)
|
|
210
|
+
|
|
211
|
+
print("Port still in use after terminating processes.")
|
|
212
|
+
return False
|