synth-ai 0.2.14__py3-none-any.whl → 0.2.17__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/analyze_semantic_words.sh +2 -2
- examples/blog_posts/pokemon_vl/README.md +98 -0
- examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +25 -0
- examples/blog_posts/pokemon_vl/configs/eval_rl_final.toml +24 -0
- examples/blog_posts/pokemon_vl/configs/filter_high_reward.toml +10 -0
- examples/blog_posts/pokemon_vl/configs/train_rl_from_sft.toml +42 -0
- examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
- examples/blog_posts/warming_up_to_rl/README.md +158 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_groq_qwen32b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_openai_gpt_oss_120b.toml +29 -0
- examples/blog_posts/warming_up_to_rl/configs/filter_high_reward_dataset.toml +10 -0
- examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +41 -0
- examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
- examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
- examples/multi_step/SFT_README.md +147 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +73 -115
- examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple.toml +1 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
- examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
- examples/multi_step/configs/verilog_rl_lora.toml +80 -123
- 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 +1 -2
- examples/qwen_coder/configs/coder_lora_4b.toml +5 -1
- examples/qwen_coder/configs/coder_lora_small.toml +1 -2
- 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 +152 -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 +274 -0
- examples/qwen_vl/VISION_TESTS_COMPLETE.md +489 -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 +415 -0
- examples/qwen_vl/collect_vision_traces.py +368 -0
- examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +110 -0
- examples/qwen_vl/configs/crafter_vlm_sft_example.toml +59 -0
- examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +26 -0
- examples/qwen_vl/configs/eval_gpt4o_vision_proper.toml +29 -0
- examples/qwen_vl/configs/eval_gpt5nano_vision.toml +26 -0
- examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
- examples/qwen_vl/configs/filter_qwen3vl_sft.toml +49 -0
- examples/qwen_vl/configs/filter_vision_sft.toml +52 -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 +61 -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 +6 -6
- 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 +62 -0
- examples/rl/configs/rl_from_base_qwen17.toml +79 -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 +21 -0
- examples/rl/task_app/math_single_step.py +990 -0
- examples/rl/task_app/math_task_app.py +111 -0
- examples/run_crafter_demo.sh +2 -2
- examples/sft/README.md +6 -6
- examples/sft/configs/crafter_fft_qwen0p6b.toml +7 -2
- examples/sft/configs/crafter_lora_qwen0p6b.toml +7 -3
- examples/sft/evaluate.py +2 -4
- examples/sft/export_dataset.py +7 -4
- examples/swe/task_app/README.md +33 -3
- examples/swe/task_app/grpo_swe_mini.py +4 -1
- examples/swe/task_app/grpo_swe_mini_task_app.py +0 -12
- examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
- examples/swe/task_app/hosted/envs/mini_swe/environment.py +50 -23
- examples/swe/task_app/hosted/inference/openai_client.py +4 -4
- examples/swe/task_app/hosted/policy_routes.py +0 -2
- examples/swe/task_app/hosted/rollout.py +0 -8
- examples/swe/task_app/morph_backend.py +178 -0
- examples/task_apps/crafter/task_app/README.md +1 -1
- examples/task_apps/crafter/task_app/grpo_crafter.py +70 -10
- examples/task_apps/crafter/task_app/grpo_crafter_task_app.py +1 -1
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +63 -27
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -2
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +48 -50
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +75 -36
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +31 -15
- examples/task_apps/enron/__init__.py +1 -0
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
- examples/task_apps/math/README.md +1 -2
- examples/task_apps/pokemon_red/README.md +3 -4
- examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +6 -5
- examples/task_apps/pokemon_red/eval_pokemon_red_policy.py +1 -2
- examples/task_apps/pokemon_red/task_app.py +36 -5
- examples/task_apps/sokoban/README.md +2 -3
- examples/task_apps/verilog/eval_groq_qwen32b.toml +12 -14
- examples/task_apps/verilog/task_app/grpo_verilog_task_app.py +1 -1
- examples/vlm/README.md +3 -3
- examples/vlm/configs/crafter_vlm_gpt4o.toml +5 -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 +5 -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_local_rollout_traced.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 +827 -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 +454 -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 +1084 -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
- examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +5 -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/builders.py +9 -3
- synth_ai/api/train/cli.py +155 -17
- synth_ai/api/train/config_finder.py +18 -11
- synth_ai/api/train/configs/__init__.py +8 -1
- synth_ai/api/train/configs/rl.py +32 -7
- synth_ai/api/train/configs/sft.py +6 -2
- synth_ai/api/train/configs/shared.py +59 -2
- synth_ai/api/train/env_resolver.py +13 -10
- synth_ai/auth/credentials.py +119 -0
- synth_ai/cli/__init__.py +61 -69
- 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/commands/__init__.py +17 -0
- synth_ai/cli/commands/demo/__init__.py +6 -0
- synth_ai/cli/commands/demo/core.py +163 -0
- synth_ai/cli/commands/deploy/__init__.py +23 -0
- synth_ai/cli/commands/deploy/core.py +614 -0
- synth_ai/cli/commands/deploy/errors.py +72 -0
- synth_ai/cli/commands/deploy/validation.py +11 -0
- synth_ai/cli/commands/eval/__init__.py +19 -0
- synth_ai/cli/commands/eval/core.py +1109 -0
- synth_ai/cli/commands/eval/errors.py +81 -0
- synth_ai/cli/commands/eval/validation.py +133 -0
- synth_ai/cli/commands/filter/__init__.py +12 -0
- synth_ai/cli/commands/filter/core.py +388 -0
- synth_ai/cli/commands/filter/errors.py +55 -0
- synth_ai/cli/commands/filter/validation.py +77 -0
- synth_ai/cli/commands/help/__init__.py +177 -0
- synth_ai/cli/commands/help/core.py +73 -0
- synth_ai/cli/commands/status/__init__.py +64 -0
- synth_ai/cli/commands/status/client.py +192 -0
- synth_ai/cli/commands/status/config.py +92 -0
- synth_ai/cli/commands/status/errors.py +20 -0
- synth_ai/cli/commands/status/formatters.py +164 -0
- synth_ai/cli/commands/status/subcommands/__init__.py +9 -0
- synth_ai/cli/commands/status/subcommands/files.py +79 -0
- synth_ai/cli/commands/status/subcommands/jobs.py +334 -0
- synth_ai/cli/commands/status/subcommands/models.py +79 -0
- synth_ai/cli/commands/status/subcommands/runs.py +81 -0
- synth_ai/cli/commands/status/subcommands/summary.py +47 -0
- synth_ai/cli/commands/status/utils.py +114 -0
- synth_ai/cli/commands/train/__init__.py +53 -0
- synth_ai/cli/commands/train/core.py +21 -0
- synth_ai/cli/commands/train/errors.py +117 -0
- synth_ai/cli/commands/train/judge_schemas.py +199 -0
- synth_ai/cli/commands/train/judge_validation.py +304 -0
- synth_ai/cli/commands/train/validation.py +443 -0
- synth_ai/cli/demo.py +2 -162
- synth_ai/cli/deploy/__init__.py +28 -0
- synth_ai/cli/deploy/core.py +5 -0
- synth_ai/cli/deploy/errors.py +23 -0
- synth_ai/cli/deploy/validation.py +5 -0
- synth_ai/cli/eval/__init__.py +36 -0
- synth_ai/cli/eval/core.py +5 -0
- synth_ai/cli/eval/errors.py +31 -0
- synth_ai/cli/eval/validation.py +5 -0
- synth_ai/cli/filter/__init__.py +28 -0
- synth_ai/cli/filter/core.py +5 -0
- synth_ai/cli/filter/errors.py +23 -0
- synth_ai/cli/filter/validation.py +5 -0
- 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/modal_serve/__init__.py +12 -0
- synth_ai/cli/modal_serve/core.py +14 -0
- synth_ai/cli/modal_serve/errors.py +8 -0
- synth_ai/cli/modal_serve/validation.py +11 -0
- synth_ai/cli/recent.py +2 -1
- synth_ai/cli/serve/__init__.py +12 -0
- synth_ai/cli/serve/core.py +14 -0
- synth_ai/cli/serve/errors.py +8 -0
- synth_ai/cli/serve/validation.py +11 -0
- synth_ai/cli/setup.py +21 -0
- synth_ai/cli/status.py +7 -126
- synth_ai/cli/task_app_deploy.py +7 -0
- synth_ai/cli/task_app_list.py +25 -0
- synth_ai/cli/task_app_modal_serve.py +11 -0
- synth_ai/cli/task_app_serve.py +11 -0
- synth_ai/cli/task_apps.py +110 -1499
- synth_ai/cli/traces.py +1 -1
- synth_ai/cli/train/__init__.py +12 -0
- synth_ai/cli/train/core.py +21 -0
- synth_ai/cli/train/errors.py +8 -0
- synth_ai/cli/train/validation.py +24 -0
- synth_ai/cli/train.py +5 -0
- 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/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
- 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/red/engine.py +33 -12
- synth_ai/environments/examples/red/engine_helpers/reward_components.py +151 -179
- synth_ai/environments/examples/red/environment.py +26 -0
- synth_ai/environments/examples/red/trace_hooks_v3.py +168 -0
- 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/http.py +8 -22
- synth_ai/inference/client.py +1 -1
- synth_ai/judge_schemas.py +4 -5
- 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 +4 -2
- 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/streaming/__init__.py +29 -0
- synth_ai/streaming/config.py +94 -0
- synth_ai/streaming/handlers.py +469 -0
- synth_ai/streaming/streamer.py +301 -0
- synth_ai/streaming/types.py +95 -0
- 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 +0 -1
- 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/migration_helper.py +1 -2
- 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/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 +294 -0
- synth_ai/utils/http.py +172 -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.17.dist-info}/METADATA +91 -32
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/RECORD +341 -154
- synth_ai/cli/man.py +0 -106
- synth_ai/cli/tui.py +0 -57
- 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/tui/__init__.py +0 -5
- synth_ai/tui/__main__.py +0 -13
- synth_ai/tui/cli/__init__.py +0 -1
- synth_ai/tui/cli/query_experiments.py +0 -164
- synth_ai/tui/cli/query_experiments_v3.py +0 -164
- synth_ai/tui/dashboard.py +0 -906
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.14.dist-info → synth_ai-0.2.17.dist-info}/top_level.txt +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
|
|
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 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
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from argparse import Namespace
|
|
4
|
+
from collections.abc import Callable, Mapping, MutableMapping
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
__all__ = ["ensure_required_args"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def ensure_required_args(
|
|
11
|
+
args: Namespace,
|
|
12
|
+
prompts: Mapping[str, str],
|
|
13
|
+
*,
|
|
14
|
+
coerce: Mapping[str, Callable[[Any], Any]] | None = None,
|
|
15
|
+
defaults: Mapping[str, Any] | None = None,
|
|
16
|
+
) -> Namespace:
|
|
17
|
+
"""Ensure required CLI arguments are populated.
|
|
18
|
+
|
|
19
|
+
Legacy helper that historically prompted users. Our tests rely on it to
|
|
20
|
+
populate defaults and perform simple coercions, so we implement a minimal,
|
|
21
|
+
non-interactive version that mirrors that behaviour.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
coerce_map = dict(coerce or {})
|
|
25
|
+
default_map: MutableMapping[str, Any] = dict(defaults or {})
|
|
26
|
+
|
|
27
|
+
for key, label in prompts.items():
|
|
28
|
+
value = getattr(args, key, None)
|
|
29
|
+
if value in (None, "") and key in default_map:
|
|
30
|
+
value = default_map[key]
|
|
31
|
+
if value in (None, ""):
|
|
32
|
+
raise ValueError(f"{label} is required")
|
|
33
|
+
if key in coerce_map:
|
|
34
|
+
try:
|
|
35
|
+
value = coerce_map[key](value)
|
|
36
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
37
|
+
raise ValueError(f"Failed to normalize {label}: {exc}") from exc
|
|
38
|
+
setattr(args, key, value)
|
|
39
|
+
return args
|
synth_ai/utils/sqld.py
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import contextlib
|
|
2
|
+
import logging
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import socket
|
|
6
|
+
import subprocess
|
|
7
|
+
import tempfile
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
SQLD_VERSION = "v0.26.2"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def find_sqld_binary() -> str | None:
|
|
15
|
+
"""Locate an existing sqld binary on PATH or in common install locations."""
|
|
16
|
+
|
|
17
|
+
sqld_path = shutil.which("sqld")
|
|
18
|
+
if sqld_path:
|
|
19
|
+
return sqld_path
|
|
20
|
+
common_paths = [
|
|
21
|
+
"/usr/local/bin/sqld",
|
|
22
|
+
"/usr/bin/sqld",
|
|
23
|
+
os.path.expanduser("~/.local/bin/sqld"),
|
|
24
|
+
os.path.expanduser("~/bin/sqld"),
|
|
25
|
+
os.path.expanduser("~/.turso/bin/sqld"),
|
|
26
|
+
]
|
|
27
|
+
for path in common_paths:
|
|
28
|
+
if os.path.exists(path) and os.access(path, os.X_OK):
|
|
29
|
+
return path
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def install_sqld() -> str:
|
|
34
|
+
"""Install sqld via the Turso CLI, installing the CLI via Homebrew if needed."""
|
|
35
|
+
|
|
36
|
+
click.echo("🔧 sqld not found. Attempting automatic install...")
|
|
37
|
+
|
|
38
|
+
turso_cli_path = shutil.which("turso")
|
|
39
|
+
brew_path = shutil.which("brew")
|
|
40
|
+
|
|
41
|
+
if not turso_cli_path:
|
|
42
|
+
if not brew_path:
|
|
43
|
+
raise click.ClickException(
|
|
44
|
+
"Automatic install requires either Homebrew or an existing Turso CLI.\n"
|
|
45
|
+
"Install manually using one of:\n"
|
|
46
|
+
" • brew install tursodatabase/tap/turso\n"
|
|
47
|
+
" • curl -sSfL https://get.tur.so/install.sh | bash\n"
|
|
48
|
+
"Then run 'turso dev' once and re-run this command."
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
click.echo("🧰 Installing Turso CLI via Homebrew (tursodatabase/tap/turso)…")
|
|
52
|
+
try:
|
|
53
|
+
subprocess.run(
|
|
54
|
+
[brew_path, "install", "tursodatabase/tap/turso"],
|
|
55
|
+
check=True,
|
|
56
|
+
)
|
|
57
|
+
except subprocess.CalledProcessError as exc:
|
|
58
|
+
raise click.ClickException(
|
|
59
|
+
"Homebrew install failed. Please resolve brew errors and retry."
|
|
60
|
+
) from exc
|
|
61
|
+
|
|
62
|
+
turso_cli_path = shutil.which("turso")
|
|
63
|
+
if not turso_cli_path:
|
|
64
|
+
raise click.ClickException(
|
|
65
|
+
"Homebrew reported success but the 'turso' binary is not on PATH."
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
click.echo("📥 Downloading sqld via 'turso dev' (this may take a few seconds)…")
|
|
69
|
+
|
|
70
|
+
with tempfile.NamedTemporaryFile(prefix="synth_sqld_", suffix=".db", delete=False) as temp_db:
|
|
71
|
+
temp_db_path = temp_db.name
|
|
72
|
+
|
|
73
|
+
env = os.environ.copy()
|
|
74
|
+
env.setdefault("TURSO_NONINTERACTIVE", "1")
|
|
75
|
+
|
|
76
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
|
77
|
+
sock.bind(("127.0.0.1", 0))
|
|
78
|
+
port = sock.getsockname()[1]
|
|
79
|
+
|
|
80
|
+
cmd = [
|
|
81
|
+
turso_cli_path,
|
|
82
|
+
"dev",
|
|
83
|
+
f"--db-file={temp_db_path}",
|
|
84
|
+
f"--port={port}",
|
|
85
|
+
]
|
|
86
|
+
proc: subprocess.Popen[str] | None = None
|
|
87
|
+
stdout_data = ""
|
|
88
|
+
stderr_data = ""
|
|
89
|
+
try:
|
|
90
|
+
proc = subprocess.Popen(
|
|
91
|
+
cmd,
|
|
92
|
+
stdout=subprocess.PIPE,
|
|
93
|
+
stderr=subprocess.PIPE,
|
|
94
|
+
text=True,
|
|
95
|
+
env=env,
|
|
96
|
+
)
|
|
97
|
+
try:
|
|
98
|
+
stdout_data, stderr_data = proc.communicate(timeout=10)
|
|
99
|
+
except subprocess.TimeoutExpired:
|
|
100
|
+
proc.terminate()
|
|
101
|
+
try:
|
|
102
|
+
stdout_data, stderr_data = proc.communicate(timeout=5)
|
|
103
|
+
except subprocess.TimeoutExpired:
|
|
104
|
+
proc.kill()
|
|
105
|
+
stdout_data, stderr_data = proc.communicate()
|
|
106
|
+
finally:
|
|
107
|
+
if proc and proc.returncode not in (0, None) and (stdout_data or stderr_data):
|
|
108
|
+
logging.getLogger(__name__).debug(
|
|
109
|
+
"turso dev stdout: %s\nstderr: %s", stdout_data, stderr_data
|
|
110
|
+
)
|
|
111
|
+
with contextlib.suppress(OSError):
|
|
112
|
+
os.unlink(temp_db_path)
|
|
113
|
+
|
|
114
|
+
sqld_path = find_sqld_binary()
|
|
115
|
+
if sqld_path:
|
|
116
|
+
click.echo(f"✅ sqld available at {sqld_path}")
|
|
117
|
+
return sqld_path
|
|
118
|
+
|
|
119
|
+
raise click.ClickException(
|
|
120
|
+
"sqld download did not succeed. Run 'turso dev' manually once, "
|
|
121
|
+
"ensure it downloads sqld, and try again."
|
|
122
|
+
)
|