synth-ai 0.2.16__py3-none-any.whl → 0.2.19__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/analyze_semantic_words.sh +2 -2
- examples/baseline/banking77_baseline.py +204 -0
- examples/baseline/crafter_baseline.py +407 -0
- examples/baseline/pokemon_red_baseline.py +326 -0
- examples/baseline/simple_baseline.py +56 -0
- examples/baseline/warming_up_to_rl_baseline.py +239 -0
- examples/blog_posts/gepa/README.md +355 -0
- examples/blog_posts/gepa/configs/banking77_gepa_local.toml +95 -0
- examples/blog_posts/gepa/configs/banking77_gepa_test.toml +82 -0
- examples/blog_posts/gepa/configs/banking77_mipro_local.toml +52 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hotpotqa_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hotpotqa_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/hover_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/hover_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/hover_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_local.toml +59 -0
- examples/blog_posts/gepa/configs/ifbench_gepa_qwen.toml +36 -0
- examples/blog_posts/gepa/configs/ifbench_mipro_local.toml +53 -0
- examples/blog_posts/gepa/configs/pupa_gepa_local.toml +60 -0
- examples/blog_posts/gepa/configs/pupa_mipro_local.toml +54 -0
- examples/blog_posts/gepa/deploy_banking77_task_app.sh +41 -0
- examples/blog_posts/gepa/gepa_baseline.py +204 -0
- examples/blog_posts/gepa/query_prompts_example.py +97 -0
- examples/blog_posts/gepa/run_gepa_banking77.sh +87 -0
- examples/blog_posts/gepa/task_apps.py +105 -0
- examples/blog_posts/gepa/test_gepa_local.sh +67 -0
- examples/blog_posts/gepa/verify_banking77_setup.sh +123 -0
- examples/blog_posts/pokemon_vl/README.md +98 -0
- examples/blog_posts/pokemon_vl/configs/eval_gpt5nano.toml +26 -0
- examples/blog_posts/pokemon_vl/configs/eval_qwen3_vl.toml +27 -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 +43 -0
- examples/blog_posts/pokemon_vl/configs/train_sft_qwen4b_vl.toml +40 -0
- examples/blog_posts/pokemon_vl/extract_images.py +239 -0
- examples/blog_posts/pokemon_vl/pokemon_vl_baseline.py +326 -0
- examples/blog_posts/pokemon_vl/run_eval_extract_images.py +209 -0
- examples/blog_posts/pokemon_vl/run_qwen_eval_extract_images.py +212 -0
- examples/blog_posts/pokemon_vl/text_box_analysis.md +106 -0
- examples/blog_posts/warming_up_to_rl/ARCHITECTURE.md +195 -0
- examples/blog_posts/warming_up_to_rl/FINAL_TEST_RESULTS.md +127 -0
- examples/blog_posts/warming_up_to_rl/INFERENCE_SUCCESS.md +132 -0
- examples/blog_posts/warming_up_to_rl/README.md +158 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TESTING.md +164 -0
- examples/blog_posts/warming_up_to_rl/SMOKE_TEST_COMPLETE.md +253 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_baseline_qwen32b_10x20.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b.toml +25 -0
- examples/blog_posts/warming_up_to_rl/configs/eval_ft_qwen4b_10x20.toml +26 -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/smoke_test.toml +75 -0
- examples/blog_posts/warming_up_to_rl/configs/train_rl_from_sft.toml +91 -0
- examples/blog_posts/warming_up_to_rl/configs/train_sft_qwen4b.toml +40 -0
- examples/blog_posts/warming_up_to_rl/warming_up_to_rl_baseline.py +187 -0
- examples/dev/qwen3_32b_qlora_4xh100.toml +5 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +4 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +4 -0
- examples/multi_step/configs/crafter_rl_outcome.toml +2 -1
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +65 -107
- examples/multi_step/configs/crafter_rl_stepwise_shaped.toml +2 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple.toml +2 -1
- examples/multi_step/configs/crafter_rl_stepwise_simple_NEW_FORMAT.toml +105 -0
- examples/multi_step/configs/verilog_rl_lora.toml +80 -123
- examples/qwen_coder/configs/coder_lora_30b.toml +1 -3
- examples/qwen_coder/configs/coder_lora_4b.toml +4 -1
- examples/qwen_coder/configs/coder_lora_small.toml +1 -3
- examples/qwen_vl/README.md +10 -12
- examples/qwen_vl/SETUP_COMPLETE.md +7 -8
- examples/qwen_vl/VISION_TESTS_COMPLETE.md +2 -3
- examples/qwen_vl/collect_data_via_cli.md +76 -84
- examples/qwen_vl/collect_vision_traces.py +4 -4
- examples/qwen_vl/configs/crafter_rl_vision_qwen3vl4b.toml +40 -57
- examples/qwen_vl/configs/crafter_vlm_sft_example.toml +1 -2
- examples/qwen_vl/configs/eval_gpt4o_mini_vision.toml +20 -37
- examples/qwen_vl/configs/eval_gpt5nano_vision.toml +21 -40
- examples/qwen_vl/configs/eval_qwen3vl_vision.toml +26 -0
- examples/qwen_vl/configs/{filter_qwen2vl_sft.toml → filter_qwen3vl_sft.toml} +4 -5
- examples/qwen_vl/configs/filter_vision_sft.toml +2 -3
- examples/qwen_vl/crafter_qwen_vl_agent.py +5 -5
- examples/qwen_vl/run_vision_comparison.sh +6 -7
- examples/rl/README.md +5 -5
- examples/rl/configs/rl_from_base_qwen.toml +26 -1
- examples/rl/configs/rl_from_base_qwen17.toml +6 -2
- examples/rl/task_app/README.md +1 -2
- examples/rl/task_app/math_single_step.py +2 -2
- examples/run_crafter_demo.sh +2 -2
- examples/sft/README.md +1 -1
- examples/sft/configs/crafter_fft_qwen0p6b.toml +4 -1
- examples/sft/configs/crafter_lora_qwen0p6b.toml +4 -1
- examples/swe/task_app/README.md +32 -2
- examples/swe/task_app/grpo_swe_mini.py +4 -0
- examples/swe/task_app/hosted/envs/crafter/react_agent.py +1 -1
- examples/swe/task_app/hosted/envs/mini_swe/environment.py +37 -10
- examples/swe/task_app/hosted/inference/openai_client.py +4 -38
- examples/swe/task_app/hosted/policy_routes.py +17 -0
- examples/swe/task_app/hosted/rollout.py +4 -2
- examples/swe/task_app/morph_backend.py +178 -0
- examples/task_apps/banking77/__init__.py +6 -0
- examples/task_apps/banking77/banking77_task_app.py +841 -0
- examples/task_apps/banking77/deploy_wrapper.py +46 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +4 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +4 -0
- examples/task_apps/crafter/task_app/README.md +1 -1
- examples/task_apps/crafter/task_app/grpo_crafter.py +90 -5
- 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 +4 -26
- 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/hosted_app.py +49 -0
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +372 -107
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +81 -12
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +82 -11
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +194 -1
- examples/task_apps/enron/task_app/grpo_enron_task_app.py +1 -1
- examples/task_apps/gepa_benchmarks/__init__.py +7 -0
- examples/task_apps/gepa_benchmarks/common.py +260 -0
- examples/task_apps/gepa_benchmarks/hotpotqa_task_app.py +507 -0
- examples/task_apps/gepa_benchmarks/hover_task_app.py +436 -0
- examples/task_apps/gepa_benchmarks/ifbench_task_app.py +563 -0
- examples/task_apps/gepa_benchmarks/pupa_task_app.py +460 -0
- examples/task_apps/math/README.md +1 -2
- examples/task_apps/pokemon_red/README.md +3 -4
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +4 -0
- 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 +288 -39
- 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/configs/crafter_vlm_gpt4o.toml +4 -1
- examples/warming_up_to_rl/configs/crafter_fft.toml +4 -1
- examples/warming_up_to_rl/configs/crafter_fft_4b.toml +0 -2
- examples/warming_up_to_rl/configs/rl_from_base_qwen4b.toml +3 -2
- examples/warming_up_to_rl/run_local_rollout_traced.py +1 -1
- examples/warming_up_to_rl/task_app/README.md +1 -1
- examples/warming_up_to_rl/task_app/grpo_crafter.py +185 -5
- examples/warming_up_to_rl/task_app/grpo_crafter_task_app.py +1 -1
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/policy.py +3 -27
- examples/warming_up_to_rl/task_app/synth_envs_hosted/envs/crafter/react_agent.py +1 -1
- examples/warming_up_to_rl/task_app/synth_envs_hosted/hosted_app.py +49 -0
- examples/warming_up_to_rl/task_app/synth_envs_hosted/inference/openai_client.py +156 -45
- examples/warming_up_to_rl/task_app/synth_envs_hosted/policy_routes.py +37 -4
- examples/warming_up_to_rl/task_app/synth_envs_hosted/rollout.py +33 -3
- examples/warming_up_to_rl/task_app/synth_envs_hosted/utils.py +67 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen.toml +27 -0
- examples/workflows/math_rl/configs/rl_from_base_qwen17.toml +6 -0
- synth_ai/api/train/builders.py +99 -4
- synth_ai/api/train/cli.py +516 -26
- synth_ai/api/train/config_finder.py +13 -2
- synth_ai/api/train/configs/__init__.py +23 -2
- synth_ai/api/train/configs/prompt_learning.py +442 -0
- synth_ai/api/train/configs/rl.py +61 -7
- synth_ai/api/train/configs/sft.py +6 -2
- synth_ai/api/train/configs/shared.py +59 -2
- synth_ai/api/train/task_app.py +1 -1
- synth_ai/api/train/validators.py +277 -0
- synth_ai/auth/credentials.py +119 -0
- synth_ai/baseline/__init__.py +25 -0
- synth_ai/baseline/config.py +209 -0
- synth_ai/baseline/discovery.py +214 -0
- synth_ai/baseline/execution.py +146 -0
- synth_ai/cli/__init__.py +94 -18
- synth_ai/cli/__main__.py +0 -0
- synth_ai/cli/claude.py +70 -0
- synth_ai/cli/codex.py +84 -0
- synth_ai/cli/commands/__init__.py +18 -0
- synth_ai/cli/commands/baseline/__init__.py +12 -0
- synth_ai/cli/commands/baseline/core.py +637 -0
- synth_ai/cli/commands/baseline/list.py +93 -0
- synth_ai/cli/commands/demo/__init__.py +6 -0
- synth_ai/cli/commands/demo/core.py +163 -0
- synth_ai/cli/commands/eval/__init__.py +19 -0
- synth_ai/cli/commands/eval/core.py +1112 -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 +424 -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 +72 -0
- synth_ai/cli/commands/smoke/__init__.py +7 -0
- synth_ai/cli/commands/smoke/core.py +1436 -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/pricing.py +22 -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/subcommands/usage.py +203 -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 +200 -0
- synth_ai/cli/commands/train/judge_validation.py +305 -0
- synth_ai/cli/commands/train/validation.py +386 -0
- synth_ai/cli/demo.py +30 -158
- synth_ai/cli/deploy/__init__.py +43 -0
- synth_ai/cli/deploy.py +162 -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 +14 -8
- 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/opencode.py +107 -0
- synth_ai/cli/root.py +9 -5
- 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 +20 -265
- synth_ai/cli/status.py +7 -126
- synth_ai/cli/task_app_deploy.py +1 -10
- synth_ai/cli/task_app_modal_serve.py +4 -9
- synth_ai/cli/task_app_serve.py +4 -11
- synth_ai/cli/task_apps.py +51 -1480
- 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 +1 -14
- synth_ai/demos/crafter/grpo_crafter_task_app.py +1 -1
- synth_ai/demos/demo_task_apps/crafter/grpo_crafter_task_app.py +1 -1
- synth_ai/environments/examples/crafter_classic/engine_deterministic_patch.py +7 -4
- synth_ai/environments/examples/crafter_classic/engine_serialization_patch_v3.py +9 -5
- synth_ai/environments/examples/crafter_classic/world_config_patch_simple.py +4 -3
- 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/http.py +12 -0
- synth_ai/judge_schemas.py +10 -10
- synth_ai/learning/__init__.py +10 -0
- synth_ai/learning/prompt_learning_client.py +276 -0
- synth_ai/learning/prompt_learning_types.py +184 -0
- synth_ai/learning/rl/client.py +3 -1
- synth_ai/pricing/__init__.py +2 -0
- synth_ai/pricing/model_pricing.py +57 -0
- synth_ai/streaming/__init__.py +29 -0
- synth_ai/streaming/config.py +94 -0
- synth_ai/streaming/handlers.py +518 -0
- synth_ai/streaming/streamer.py +320 -0
- synth_ai/streaming/types.py +95 -0
- synth_ai/task/apps/__init__.py +1 -0
- synth_ai/task/config.py +2 -0
- synth_ai/task/tracing_utils.py +25 -25
- synth_ai/task/validators.py +45 -9
- synth_ai/task_app_cfgs.py +21 -0
- synth_ai/tracing_v3/config.py +162 -19
- synth_ai/tracing_v3/constants.py +1 -1
- synth_ai/tracing_v3/db_config.py +24 -38
- synth_ai/tracing_v3/migration_helper.py +1 -2
- synth_ai/tracing_v3/storage/config.py +47 -13
- synth_ai/tracing_v3/storage/factory.py +3 -3
- synth_ai/tracing_v3/turso/daemon.py +113 -11
- synth_ai/tracing_v3/turso/native_manager.py +92 -16
- synth_ai/types.py +8 -0
- synth_ai/urls.py +11 -0
- synth_ai/utils/__init__.py +30 -1
- synth_ai/utils/agents.py +74 -0
- synth_ai/utils/bin.py +39 -0
- synth_ai/utils/cli.py +149 -5
- synth_ai/utils/env.py +40 -33
- synth_ai/utils/http.py +4 -1
- synth_ai/utils/json.py +72 -0
- synth_ai/utils/modal.py +285 -3
- synth_ai/utils/paths.py +48 -0
- synth_ai/utils/uvicorn.py +113 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/METADATA +109 -6
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/RECORD +291 -142
- examples/qwen_vl/configs/eval_qwen2vl_vision.toml +0 -44
- synth_ai/cli/tui.py +0 -62
- 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 -911
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.16.dist-info → synth_ai-0.2.19.dist-info}/top_level.txt +0 -0
synth_ai/utils/env.py
CHANGED
|
@@ -5,6 +5,8 @@ from pathlib import Path
|
|
|
5
5
|
|
|
6
6
|
import click
|
|
7
7
|
|
|
8
|
+
from .paths import get_env_file_paths, get_home_config_file_paths
|
|
9
|
+
|
|
8
10
|
_ENV_SAFE_CHARS = set(string.ascii_letters + string.digits + "_-./:@+=")
|
|
9
11
|
|
|
10
12
|
|
|
@@ -84,18 +86,6 @@ def mask_str(input: str, position: int = 3) -> str:
|
|
|
84
86
|
return input[:position] + "..." + input[-position:] if len(input) > position * 2 else "***"
|
|
85
87
|
|
|
86
88
|
|
|
87
|
-
def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
|
|
88
|
-
base = Path(base_dir).resolve()
|
|
89
|
-
return [path for path in base.rglob(".env*") if path.is_file()]
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def get_synth_config_file_paths() -> list[Path]:
|
|
93
|
-
dir = Path.home() / ".synth-ai"
|
|
94
|
-
if not dir.exists():
|
|
95
|
-
return []
|
|
96
|
-
return [path for path in dir.glob("*.json") if path.is_file()]
|
|
97
|
-
|
|
98
|
-
|
|
99
89
|
def filter_env_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
|
|
100
90
|
matches: list[tuple[Path, str]] = []
|
|
101
91
|
for path in paths:
|
|
@@ -127,18 +117,31 @@ def filter_json_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, st
|
|
|
127
117
|
return matches
|
|
128
118
|
|
|
129
119
|
|
|
130
|
-
def
|
|
120
|
+
def ensure_env_var(key: str, expected_value: str) -> None:
|
|
121
|
+
actual_value = os.getenv(key)
|
|
122
|
+
if expected_value != actual_value:
|
|
123
|
+
raise ValueError(f"Expected: {key}={expected_value}\nActual: {key}={actual_value}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def resolve_env_var(
|
|
127
|
+
key: str,
|
|
128
|
+
override_process_env: bool = False
|
|
129
|
+
) -> str:
|
|
131
130
|
env_value = os.getenv(key)
|
|
132
|
-
if env_value is not None:
|
|
131
|
+
if env_value is not None and not override_process_env:
|
|
133
132
|
click.echo(f"Using {key}={mask_str(env_value)} from process environment")
|
|
134
|
-
return
|
|
133
|
+
return env_value
|
|
135
134
|
|
|
136
135
|
value: str = ""
|
|
137
136
|
|
|
138
137
|
env_file_paths = filter_env_files_by_key(key, get_env_file_paths())
|
|
139
|
-
synth_file_paths = filter_json_files_by_key(key,
|
|
138
|
+
synth_file_paths = filter_json_files_by_key(key, get_home_config_file_paths(".synth-ai"))
|
|
140
139
|
|
|
141
140
|
options: list[tuple[str, str]] = []
|
|
141
|
+
if env_value is not None:
|
|
142
|
+
if not override_process_env:
|
|
143
|
+
return env_value
|
|
144
|
+
options.append((f"(process environment) {mask_str(env_value)}", env_value))
|
|
142
145
|
for path, value in env_file_paths:
|
|
143
146
|
resolved_path = path.resolve()
|
|
144
147
|
try:
|
|
@@ -161,14 +164,13 @@ def resolve_env_var(key: str) -> None:
|
|
|
161
164
|
while True:
|
|
162
165
|
try:
|
|
163
166
|
choice = click.prompt(
|
|
164
|
-
"Select option",
|
|
167
|
+
"Select an option",
|
|
165
168
|
default=1,
|
|
166
169
|
type=str,
|
|
167
170
|
show_choices=False,
|
|
168
171
|
).strip()
|
|
169
172
|
except click.Abort:
|
|
170
|
-
|
|
171
|
-
|
|
173
|
+
raise
|
|
172
174
|
if choice.lower() == 'm':
|
|
173
175
|
value = _prompt_manual_env_value(key)
|
|
174
176
|
break
|
|
@@ -186,20 +188,24 @@ def resolve_env_var(key: str) -> None:
|
|
|
186
188
|
click.echo(f"Invalid selection. Enter a number between 1 and {len(options)} or 'm'.")
|
|
187
189
|
|
|
188
190
|
else:
|
|
189
|
-
|
|
191
|
+
print(f"No value found for {key}")
|
|
190
192
|
value = _prompt_manual_env_value(key)
|
|
191
193
|
|
|
192
194
|
os.environ[key] = value
|
|
193
|
-
|
|
194
|
-
|
|
195
|
+
ensure_env_var(key, value)
|
|
196
|
+
print(f"Loaded {key}={mask_str(value)} into process environment")
|
|
197
|
+
return value
|
|
195
198
|
|
|
196
199
|
|
|
197
200
|
def write_env_var_to_dotenv(
|
|
198
201
|
key: str,
|
|
199
202
|
value: str,
|
|
200
|
-
output_file_path: str | Path,
|
|
203
|
+
output_file_path: str | Path | None = None,
|
|
204
|
+
print_msg: bool = True,
|
|
205
|
+
mask_msg: bool = True
|
|
201
206
|
) -> None:
|
|
202
|
-
path = Path(output_file_path
|
|
207
|
+
path = Path(".env") if output_file_path is None else Path(output_file_path)
|
|
208
|
+
path = path.expanduser()
|
|
203
209
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
210
|
|
|
205
211
|
encoded_value = _format_env_value(value)
|
|
@@ -212,7 +218,7 @@ def write_env_var_to_dotenv(
|
|
|
212
218
|
with path.open('r', encoding="utf-8") as handle:
|
|
213
219
|
lines = handle.readlines()
|
|
214
220
|
except OSError as exc:
|
|
215
|
-
raise
|
|
221
|
+
raise RuntimeError(f"Failed to read {path}: {exc}") from exc
|
|
216
222
|
|
|
217
223
|
for index, line in enumerate(lines):
|
|
218
224
|
parsed = _parse_env_assignment(line)
|
|
@@ -238,9 +244,10 @@ def write_env_var_to_dotenv(
|
|
|
238
244
|
with path.open('w', encoding="utf-8") as handle:
|
|
239
245
|
handle.writelines(lines)
|
|
240
246
|
except OSError as exc:
|
|
241
|
-
raise
|
|
247
|
+
raise RuntimeError(f"Failed to write {path}: {exc}") from exc
|
|
242
248
|
|
|
243
|
-
|
|
249
|
+
if print_msg:
|
|
250
|
+
print(f"Wrote {key}={mask_str(value) if mask_msg else value} to {path.resolve()}")
|
|
244
251
|
|
|
245
252
|
|
|
246
253
|
def write_env_var_to_json(
|
|
@@ -250,7 +257,7 @@ def write_env_var_to_json(
|
|
|
250
257
|
) -> None:
|
|
251
258
|
path = Path(output_file_path).expanduser()
|
|
252
259
|
if path.exists() and not path.is_file():
|
|
253
|
-
raise
|
|
260
|
+
raise RuntimeError(f"{path} exists and is not a file")
|
|
254
261
|
|
|
255
262
|
data: dict[str, str] = {}
|
|
256
263
|
|
|
@@ -259,12 +266,12 @@ def write_env_var_to_json(
|
|
|
259
266
|
with path.open('r', encoding="utf-8") as handle:
|
|
260
267
|
existing = json.load(handle)
|
|
261
268
|
except json.JSONDecodeError as exc:
|
|
262
|
-
raise
|
|
269
|
+
raise RuntimeError(f"Invalid JSON in {path}: {exc}") from exc
|
|
263
270
|
except OSError as exc:
|
|
264
|
-
raise
|
|
271
|
+
raise RuntimeError(f"Failed to read {path}: {exc}") from exc
|
|
265
272
|
|
|
266
273
|
if not isinstance(existing, dict):
|
|
267
|
-
raise
|
|
274
|
+
raise RuntimeError(f"Expected JSON object in {path}")
|
|
268
275
|
|
|
269
276
|
for existing_key, existing_value in existing.items():
|
|
270
277
|
if existing_key == key:
|
|
@@ -282,6 +289,6 @@ def write_env_var_to_json(
|
|
|
282
289
|
json.dump(data, handle, indent=2, sort_keys=True)
|
|
283
290
|
handle.write('\n')
|
|
284
291
|
except OSError as exc:
|
|
285
|
-
raise
|
|
292
|
+
raise RuntimeError(f"Failed to write {path}: {exc}") from exc
|
|
286
293
|
|
|
287
|
-
|
|
294
|
+
print(f"Wrote {key}={mask_str(value)} to {path}")
|
synth_ai/utils/http.py
CHANGED
|
@@ -34,7 +34,10 @@ class AsyncHttpClient:
|
|
|
34
34
|
|
|
35
35
|
async def __aenter__(self) -> AsyncHttpClient:
|
|
36
36
|
if self._session is None:
|
|
37
|
-
headers = {
|
|
37
|
+
headers = {
|
|
38
|
+
"authorization": f"Bearer {self._api_key}",
|
|
39
|
+
"accept": "application/json",
|
|
40
|
+
}
|
|
38
41
|
user_id = os.getenv("SYNTH_USER_ID") or os.getenv("X_USER_ID") or os.getenv("USER_ID")
|
|
39
42
|
if user_id:
|
|
40
43
|
headers["X-User-ID"] = user_id
|
synth_ai/utils/json.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def strip_json_comments(raw: str) -> str:
|
|
6
|
+
"""Remove // and /* */ comments from JSONC text."""
|
|
7
|
+
result: list[str] = []
|
|
8
|
+
in_string = False
|
|
9
|
+
in_line_comment = False
|
|
10
|
+
in_block_comment = False
|
|
11
|
+
escape = False
|
|
12
|
+
i = 0
|
|
13
|
+
length = len(raw)
|
|
14
|
+
while i < length:
|
|
15
|
+
char = raw[i]
|
|
16
|
+
next_char = raw[i + 1] if i + 1 < length else ""
|
|
17
|
+
|
|
18
|
+
if in_line_comment:
|
|
19
|
+
if char == "\n":
|
|
20
|
+
in_line_comment = False
|
|
21
|
+
result.append(char)
|
|
22
|
+
i += 1
|
|
23
|
+
continue
|
|
24
|
+
|
|
25
|
+
if in_block_comment:
|
|
26
|
+
if char == "*" and next_char == "/":
|
|
27
|
+
in_block_comment = False
|
|
28
|
+
i += 2
|
|
29
|
+
else:
|
|
30
|
+
i += 1
|
|
31
|
+
continue
|
|
32
|
+
|
|
33
|
+
if in_string:
|
|
34
|
+
result.append(char)
|
|
35
|
+
if char == "\"" and not escape:
|
|
36
|
+
in_string = False
|
|
37
|
+
escape = (char == "\\") and not escape
|
|
38
|
+
i += 1
|
|
39
|
+
continue
|
|
40
|
+
|
|
41
|
+
if char == "/" and next_char == "/":
|
|
42
|
+
in_line_comment = True
|
|
43
|
+
i += 2
|
|
44
|
+
continue
|
|
45
|
+
|
|
46
|
+
if char == "/" and next_char == "*":
|
|
47
|
+
in_block_comment = True
|
|
48
|
+
i += 2
|
|
49
|
+
continue
|
|
50
|
+
|
|
51
|
+
if char == "\"":
|
|
52
|
+
in_string = True
|
|
53
|
+
escape = False
|
|
54
|
+
|
|
55
|
+
result.append(char)
|
|
56
|
+
i += 1
|
|
57
|
+
|
|
58
|
+
return "".join(result)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_and_write_json(path: Path, content: dict) -> None:
|
|
62
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
63
|
+
path.write_text(json.dumps(content, indent=2) + "\n")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_json_to_dict(path: Path) -> dict:
|
|
67
|
+
if not path.exists():
|
|
68
|
+
return {}
|
|
69
|
+
try:
|
|
70
|
+
return json.loads(strip_json_comments(path.read_text()))
|
|
71
|
+
except (json.JSONDecodeError, OSError):
|
|
72
|
+
return {}
|
synth_ai/utils/modal.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
|
1
|
+
import ast
|
|
1
2
|
import contextlib
|
|
2
3
|
import json
|
|
3
4
|
import os
|
|
5
|
+
import re
|
|
6
|
+
import shlex
|
|
4
7
|
import shutil
|
|
8
|
+
import subprocess
|
|
5
9
|
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import textwrap
|
|
6
12
|
from pathlib import Path
|
|
7
|
-
from typing import Any
|
|
13
|
+
from typing import Any
|
|
8
14
|
from urllib.parse import urlparse, urlunparse
|
|
9
15
|
|
|
16
|
+
import click
|
|
17
|
+
from modal.config import config
|
|
10
18
|
from synth_ai.demos import core as demo_core
|
|
11
19
|
from synth_ai.demos.core import DEFAULT_TASK_APP_SECRET_NAME, DemoEnv
|
|
20
|
+
from synth_ai.task_app_cfgs import ModalTaskAppConfig
|
|
12
21
|
|
|
13
|
-
from .env import mask_str
|
|
22
|
+
from .env import mask_str, resolve_env_var, write_env_var_to_dotenv
|
|
14
23
|
from .http import http_request
|
|
15
24
|
from .process import popen_capture
|
|
16
25
|
from .user_config import load_user_config
|
|
@@ -25,6 +34,279 @@ __all__ = [
|
|
|
25
34
|
]
|
|
26
35
|
|
|
27
36
|
|
|
37
|
+
REPO_ROOT = Path(__file__).resolve().parents[2]
|
|
38
|
+
|
|
39
|
+
START_DIV = f"{'-' * 31} Modal start {'-' * 31}"
|
|
40
|
+
END_DIV = f"{'-' * 32} Modal end {'-' * 32}"
|
|
41
|
+
MODAL_URL_REGEX = re.compile(r"https?://[^\s]+modal\.run[^\s]*")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def get_default_modal_bin_path() -> Path | None:
|
|
45
|
+
resolved = shutil.which("modal")
|
|
46
|
+
return Path(resolved) if resolved else None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def ensure_py_file_defines_modal_app(file_path: Path) -> None:
|
|
50
|
+
if file_path.suffix != ".py":
|
|
51
|
+
raise TypeError()
|
|
52
|
+
try:
|
|
53
|
+
tree = ast.parse(file_path.read_text(encoding="utf-8"), filename=str(file_path))
|
|
54
|
+
except OSError as exc:
|
|
55
|
+
raise OSError() from exc
|
|
56
|
+
|
|
57
|
+
app_aliases: set[str] = set()
|
|
58
|
+
modal_aliases: set[str] = set()
|
|
59
|
+
|
|
60
|
+
def literal_name(call: ast.Call) -> str | None:
|
|
61
|
+
for kw in call.keywords:
|
|
62
|
+
if (
|
|
63
|
+
kw.arg in {"name", "app_name"}
|
|
64
|
+
and isinstance(kw.value, ast.Constant)
|
|
65
|
+
and isinstance(kw.value.value, str)
|
|
66
|
+
):
|
|
67
|
+
return kw.value.value
|
|
68
|
+
if call.args:
|
|
69
|
+
first = call.args[0]
|
|
70
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
71
|
+
return first.value
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
for node in ast.walk(tree):
|
|
75
|
+
if isinstance(node, ast.ImportFrom) and node.module == "modal":
|
|
76
|
+
for alias in node.names:
|
|
77
|
+
if alias.name == "App":
|
|
78
|
+
app_aliases.add(alias.asname or alias.name)
|
|
79
|
+
elif isinstance(node, ast.Import):
|
|
80
|
+
for alias in node.names:
|
|
81
|
+
if alias.name == "modal":
|
|
82
|
+
modal_aliases.add(alias.asname or alias.name)
|
|
83
|
+
elif isinstance(node, ast.Call):
|
|
84
|
+
func = node.func
|
|
85
|
+
if isinstance(func, ast.Name) and func.id in app_aliases:
|
|
86
|
+
if literal_name(node):
|
|
87
|
+
return None
|
|
88
|
+
elif (
|
|
89
|
+
isinstance(func, ast.Attribute)
|
|
90
|
+
and func.attr == "App"
|
|
91
|
+
and isinstance(func.value, ast.Name)
|
|
92
|
+
and func.value.id in modal_aliases
|
|
93
|
+
and literal_name(node)
|
|
94
|
+
):
|
|
95
|
+
return None
|
|
96
|
+
raise ValueError()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def run_modal_setup(modal_bin_path: Path) -> None:
|
|
100
|
+
|
|
101
|
+
print("\n🌐 Connecting to your Modal account via https://modal.com")
|
|
102
|
+
print(START_DIV)
|
|
103
|
+
cmd = [str(modal_bin_path), "setup"]
|
|
104
|
+
try:
|
|
105
|
+
subprocess.run(cmd, check=True)
|
|
106
|
+
except subprocess.CalledProcessError as exc:
|
|
107
|
+
print(END_DIV)
|
|
108
|
+
raise RuntimeError(
|
|
109
|
+
f"`{' '.join(cmd)}` exited with status {exc.returncode}"
|
|
110
|
+
f"Run `{' '.join(cmd)} manually to inspect output"
|
|
111
|
+
) from exc
|
|
112
|
+
print(END_DIV)
|
|
113
|
+
print("✅ Connected to your Modal account")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def ensure_modal_config() -> None:
|
|
117
|
+
token_id = os.environ.get("MODAL_TOKEN_ID") \
|
|
118
|
+
or config.get("token_id") \
|
|
119
|
+
or ''
|
|
120
|
+
token_secret = os.environ.get("MODAL_TOKEN_SECRET") \
|
|
121
|
+
or config.get("token_secret") \
|
|
122
|
+
or ''
|
|
123
|
+
if token_id and token_secret:
|
|
124
|
+
print(f"Found Modal token_id={mask_str(token_id)}")
|
|
125
|
+
print(f"Found Modal token_secret={mask_str(token_secret)}")
|
|
126
|
+
return
|
|
127
|
+
|
|
128
|
+
modal_bin_path = get_default_modal_bin_path()
|
|
129
|
+
if not modal_bin_path:
|
|
130
|
+
raise RuntimeError("Modal CLI not found on PATH")
|
|
131
|
+
run_modal_setup(modal_bin_path)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def deploy_modal_app(cfg: ModalTaskAppConfig) -> None:
|
|
135
|
+
ensure_py_file_defines_modal_app(cfg.modal_app_path)
|
|
136
|
+
ensure_modal_config()
|
|
137
|
+
|
|
138
|
+
py_paths: list[str] = []
|
|
139
|
+
|
|
140
|
+
source_dir = cfg.modal_app_path.parent.resolve()
|
|
141
|
+
py_paths.append(str(source_dir))
|
|
142
|
+
if (source_dir / "__init__.py").exists(): # if the modal app lives in a package, ensure the parent package is importable
|
|
143
|
+
py_paths.append(str(source_dir.parent.resolve()))
|
|
144
|
+
|
|
145
|
+
py_paths.append(str(REPO_ROOT))
|
|
146
|
+
|
|
147
|
+
env_api_key = resolve_env_var("ENVIRONMENT_API_KEY")
|
|
148
|
+
if not os.environ["ENVIRONMENT_API_KEY"]:
|
|
149
|
+
raise RuntimeError()
|
|
150
|
+
|
|
151
|
+
env_copy = os.environ.copy()
|
|
152
|
+
existing_python_path = env_copy.get("PYTHONPATH")
|
|
153
|
+
if existing_python_path:
|
|
154
|
+
py_paths.append(existing_python_path)
|
|
155
|
+
unique_python_paths = list(dict.fromkeys(py_paths))
|
|
156
|
+
env_copy["PYTHONPATH"] = os.pathsep.join(unique_python_paths)
|
|
157
|
+
if "PYTHONPATH" in env_copy: # ensure wrapper has access to synth source for intra-repo imports
|
|
158
|
+
env_copy["PYTHONPATH"] = os.pathsep.join(
|
|
159
|
+
[str(REPO_ROOT)] + env_copy["PYTHONPATH"].split(os.pathsep)
|
|
160
|
+
)
|
|
161
|
+
else:
|
|
162
|
+
env_copy["PYTHONPATH"] = str(REPO_ROOT)
|
|
163
|
+
|
|
164
|
+
modal_app_dir = cfg.modal_app_path.parent.resolve()
|
|
165
|
+
tmp_root = Path(tempfile.mkdtemp(prefix="synth_modal_app"))
|
|
166
|
+
wrapper_src = textwrap.dedent(f"""
|
|
167
|
+
from importlib import util as _util
|
|
168
|
+
from pathlib import Path as _Path
|
|
169
|
+
import sys as _sys
|
|
170
|
+
|
|
171
|
+
_source_dir = _Path({str(modal_app_dir)!r}).resolve()
|
|
172
|
+
_module_path = _source_dir / {cfg.modal_app_path.name!r}
|
|
173
|
+
_package_name = _source_dir.name
|
|
174
|
+
_repo_root = _Path({str(REPO_ROOT)!r}).resolve()
|
|
175
|
+
_synth_dir = _repo_root / "synth_ai"
|
|
176
|
+
|
|
177
|
+
for _path in (str(_source_dir), str(_source_dir.parent), str(_repo_root)):
|
|
178
|
+
if _path not in _sys.path:
|
|
179
|
+
_sys.path.insert(0, _path)
|
|
180
|
+
|
|
181
|
+
_spec = _util.spec_from_file_location("_synth_modal_target", str(_module_path))
|
|
182
|
+
if _spec is None or _spec.loader is None:
|
|
183
|
+
raise SystemExit("Unable to load modal task app from {cfg.modal_app_path}")
|
|
184
|
+
_module = _util.module_from_spec(_spec)
|
|
185
|
+
_sys.modules.setdefault("_synth_modal_target", _module)
|
|
186
|
+
_spec.loader.exec_module(_module)
|
|
187
|
+
|
|
188
|
+
try:
|
|
189
|
+
from modal import App as _ModalApp
|
|
190
|
+
from modal import Image as _ModalImage
|
|
191
|
+
except Exception:
|
|
192
|
+
_ModalApp = None # type: ignore[assignment]
|
|
193
|
+
_ModalImage = None # type: ignore[assignment]
|
|
194
|
+
|
|
195
|
+
def _apply_local_mounts(image):
|
|
196
|
+
if _ModalImage is None or not isinstance(image, _ModalImage):
|
|
197
|
+
return image
|
|
198
|
+
mounts = [
|
|
199
|
+
(str(_source_dir), f"/root/{{_package_name}}"),
|
|
200
|
+
(str(_synth_dir), "/root/synth_ai"),
|
|
201
|
+
]
|
|
202
|
+
for local_path, remote_path in mounts:
|
|
203
|
+
try:
|
|
204
|
+
image = image.add_local_dir(local_path, remote_path=remote_path)
|
|
205
|
+
except Exception:
|
|
206
|
+
pass
|
|
207
|
+
return image
|
|
208
|
+
|
|
209
|
+
if hasattr(_module, "image"):
|
|
210
|
+
_module.image = _apply_local_mounts(getattr(_module, "image"))
|
|
211
|
+
|
|
212
|
+
_candidate = getattr(_module, "app", None)
|
|
213
|
+
if _ModalApp is None or not isinstance(_candidate, _ModalApp):
|
|
214
|
+
candidate_modal_app = getattr(_module, "modal_app", None)
|
|
215
|
+
if _ModalApp is not None and isinstance(candidate_modal_app, _ModalApp):
|
|
216
|
+
_candidate = candidate_modal_app
|
|
217
|
+
setattr(_module, "app", _candidate)
|
|
218
|
+
|
|
219
|
+
if _ModalApp is not None and not isinstance(_candidate, _ModalApp):
|
|
220
|
+
raise SystemExit(
|
|
221
|
+
"Modal task app must expose an 'app = modal.App(...)' (or modal_app) attribute."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
try:
|
|
225
|
+
from modal import Secret as _Secret
|
|
226
|
+
except Exception:
|
|
227
|
+
_Secret = None
|
|
228
|
+
|
|
229
|
+
for remote_path in ("/root/synth_ai", f"/root/{{_package_name}}"):
|
|
230
|
+
if remote_path not in _sys.path:
|
|
231
|
+
_sys.path.insert(0, remote_path)
|
|
232
|
+
|
|
233
|
+
globals().update({{k: v for k, v in vars(_module).items() if not k.startswith("__")}})
|
|
234
|
+
app = getattr(_module, "app")
|
|
235
|
+
_ENVIRONMENT_API_KEY = {env_api_key!r}
|
|
236
|
+
if _Secret is not None and _ENVIRONMENT_API_KEY:
|
|
237
|
+
try:
|
|
238
|
+
_inline_secret = _Secret.from_dict({{"ENVIRONMENT_API_KEY": _ENVIRONMENT_API_KEY}})
|
|
239
|
+
except Exception:
|
|
240
|
+
_inline_secret = None
|
|
241
|
+
if _inline_secret is not None:
|
|
242
|
+
try:
|
|
243
|
+
_decorators = list(getattr(app, "_function_decorators", []))
|
|
244
|
+
except Exception:
|
|
245
|
+
_decorators = []
|
|
246
|
+
for _decorator in _decorators:
|
|
247
|
+
_existing = getattr(_decorator, "secrets", None)
|
|
248
|
+
if not _existing:
|
|
249
|
+
continue
|
|
250
|
+
try:
|
|
251
|
+
if _inline_secret not in _existing:
|
|
252
|
+
_decorator.secrets = list(_existing) + [_inline_secret]
|
|
253
|
+
except Exception:
|
|
254
|
+
pass
|
|
255
|
+
""").strip()
|
|
256
|
+
wrapper_path = tmp_root / "__modal_wrapper__.py"
|
|
257
|
+
wrapper_path.write_text(wrapper_src + '\n', encoding="utf-8")
|
|
258
|
+
wrapper_info = (wrapper_path, tmp_root)
|
|
259
|
+
|
|
260
|
+
cmd = [str(cfg.modal_bin_path), cfg.cmd_arg, str(wrapper_path)]
|
|
261
|
+
if cfg.task_app_name and cfg.cmd_arg == "deploy":
|
|
262
|
+
cmd.extend(["--name", cfg.task_app_name])
|
|
263
|
+
|
|
264
|
+
msg = " ".join(shlex.quote(c) for c in cmd)
|
|
265
|
+
if cfg.dry_run:
|
|
266
|
+
print("Dry run:\n", msg)
|
|
267
|
+
return
|
|
268
|
+
print(f"Running:\n{msg}")
|
|
269
|
+
|
|
270
|
+
try:
|
|
271
|
+
process = subprocess.Popen(
|
|
272
|
+
cmd,
|
|
273
|
+
stdout=subprocess.PIPE,
|
|
274
|
+
stderr=subprocess.STDOUT,
|
|
275
|
+
text=True,
|
|
276
|
+
bufsize=1,
|
|
277
|
+
env=env_copy
|
|
278
|
+
)
|
|
279
|
+
task_app_url = None
|
|
280
|
+
assert process.stdout is not None
|
|
281
|
+
print(START_DIV)
|
|
282
|
+
for line in process.stdout:
|
|
283
|
+
click.echo(line, nl=False)
|
|
284
|
+
if task_app_url is None:
|
|
285
|
+
match = MODAL_URL_REGEX.search(line)
|
|
286
|
+
if match:
|
|
287
|
+
task_app_url = match.group(0).rstrip(".,")
|
|
288
|
+
if task_app_url:
|
|
289
|
+
write_env_var_to_dotenv(
|
|
290
|
+
"TASK_APP_URL",
|
|
291
|
+
task_app_url,
|
|
292
|
+
print_msg=True,
|
|
293
|
+
mask_msg=False,
|
|
294
|
+
)
|
|
295
|
+
print(END_DIV)
|
|
296
|
+
rc = process.wait()
|
|
297
|
+
if rc != 0:
|
|
298
|
+
raise subprocess.CalledProcessError(rc, cmd)
|
|
299
|
+
except subprocess.CalledProcessError as exc:
|
|
300
|
+
raise click.ClickException(
|
|
301
|
+
f"modal {cfg.cmd_arg} failed with exit code: {exc.returncode}"
|
|
302
|
+
) from exc
|
|
303
|
+
finally:
|
|
304
|
+
if wrapper_info is not None:
|
|
305
|
+
wrapper_path, tmp_root = wrapper_info
|
|
306
|
+
wrapper_path.unlink(missing_ok=True)
|
|
307
|
+
shutil.rmtree(tmp_root, ignore_errors=True)
|
|
308
|
+
|
|
309
|
+
|
|
28
310
|
def is_modal_public_url(url: str | None) -> bool:
|
|
29
311
|
try:
|
|
30
312
|
candidate = (url or "").strip().lower()
|
|
@@ -63,7 +345,7 @@ def normalize_endpoint_url(url: str) -> str:
|
|
|
63
345
|
creds += f":{parsed.password}"
|
|
64
346
|
netloc = f"{creds}@{netloc}"
|
|
65
347
|
parsed = parsed._replace(netloc=netloc)
|
|
66
|
-
return
|
|
348
|
+
return urlunparse(parsed)
|
|
67
349
|
except Exception:
|
|
68
350
|
pass
|
|
69
351
|
return url
|
synth_ai/utils/paths.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import shutil
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def find_bin_path(name: str) -> Path | None:
|
|
6
|
+
path = shutil.which(name)
|
|
7
|
+
if not path:
|
|
8
|
+
return None
|
|
9
|
+
return Path(path)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def get_env_file_paths(base_dir: str | Path = '.') -> list[Path]:
|
|
13
|
+
base = Path(base_dir).resolve()
|
|
14
|
+
return [path for path in base.rglob(".env*") if path.is_file()]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_home_config_file_paths(
|
|
18
|
+
dir_name: str,
|
|
19
|
+
file_extension: str = "json"
|
|
20
|
+
) -> list[Path]:
|
|
21
|
+
dir = Path.home() / dir_name
|
|
22
|
+
if not dir.exists():
|
|
23
|
+
return []
|
|
24
|
+
return [path for path in dir.glob(f"*.{file_extension}") if path.is_file()]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def find_config_path(
|
|
28
|
+
bin_path: Path,
|
|
29
|
+
home_subdir: str,
|
|
30
|
+
filename: str,
|
|
31
|
+
) -> Path | None:
|
|
32
|
+
"""
|
|
33
|
+
Return a config file located in the user's home directory or alongside the binary.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
bin_path: Resolved path to the executable.
|
|
37
|
+
home_subdir: Directory under the user's home to inspect (e.g., ".codex").
|
|
38
|
+
filename: Name of the config file to locate.
|
|
39
|
+
"""
|
|
40
|
+
home_candidate = Path.home() / home_subdir / filename
|
|
41
|
+
if home_candidate.exists():
|
|
42
|
+
return home_candidate
|
|
43
|
+
|
|
44
|
+
local_candidate = Path(bin_path).parent / home_subdir / filename
|
|
45
|
+
if local_candidate.exists():
|
|
46
|
+
return local_candidate
|
|
47
|
+
|
|
48
|
+
return None
|