synth-ai 0.2.13.dev2__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/README_verilog_rl.md +77 -0
- examples/multi_step/configs/VERILOG_REWARDS.md +90 -0
- examples/multi_step/configs/VERILOG_RL_CHECKLIST.md +183 -0
- examples/multi_step/configs/crafter_eval_synth_qwen4b.toml +35 -0
- examples/multi_step/configs/crafter_eval_text_only_groq_qwen32b.toml +36 -0
- examples/multi_step/configs/crafter_rl_stepwise_hosted_judge.toml +12 -11
- examples/multi_step/configs/crafter_sft_qwen30b_lora.toml +62 -0
- examples/multi_step/configs/crafter_synth_backend.md +40 -0
- examples/multi_step/configs/verilog_eval_groq_qwen32b.toml +31 -0
- examples/multi_step/configs/verilog_eval_synth_qwen8b.toml +33 -0
- examples/multi_step/configs/verilog_rl_lora.toml +190 -0
- examples/multi_step/convert_traces_to_sft.py +84 -0
- examples/multi_step/judges/crafter_backend_judge.py +220 -0
- examples/multi_step/judges/verilog_backend_judge.py +234 -0
- examples/multi_step/readme.md +48 -0
- examples/multi_step/run_sft_qwen30b.sh +45 -0
- examples/multi_step/verilog_rl_lora.md +218 -0
- examples/qwen_coder/configs/coder_lora_30b.toml +3 -2
- 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 +4 -4
- examples/sft/export_dataset.py +7 -4
- examples/sft/generate_traces.py +2 -0
- examples/swe/task_app/README.md +1 -1
- examples/swe/task_app/grpo_swe_mini.py +1 -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 +2 -8
- examples/task_apps/IMAGE_ONLY_EVAL_QUICKSTART.md +258 -0
- examples/task_apps/crafter/CREATE_SFT_DATASET.md +273 -0
- examples/task_apps/crafter/EVAL_IMAGE_ONLY_RESULTS.md +152 -0
- examples/task_apps/crafter/FILTER_COMMAND_STATUS.md +174 -0
- examples/task_apps/crafter/FILTER_COMMAND_SUCCESS.md +268 -0
- examples/task_apps/crafter/QUERY_EXAMPLES.md +203 -0
- examples/task_apps/crafter/README_IMAGE_ONLY_EVAL.md +316 -0
- examples/task_apps/crafter/eval_image_only_gpt4o.toml +28 -0
- examples/task_apps/crafter/eval_text_only_groq_llama.toml +36 -0
- examples/task_apps/crafter/filter_sft_dataset.toml +16 -0
- examples/task_apps/crafter/task_app/__init__.py +3 -0
- examples/task_apps/crafter/task_app/grpo_crafter.py +309 -14
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/environment.py +10 -0
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/policy.py +75 -4
- examples/task_apps/crafter/task_app/synth_envs_hosted/envs/crafter/react_agent.py +17 -2
- examples/task_apps/crafter/task_app/synth_envs_hosted/inference/openai_client.py +55 -3
- examples/task_apps/crafter/task_app/synth_envs_hosted/policy_routes.py +114 -32
- examples/task_apps/crafter/task_app/synth_envs_hosted/rollout.py +127 -27
- examples/task_apps/crafter/task_app/synth_envs_hosted/utils.py +156 -0
- examples/task_apps/enron/__init__.py +1 -0
- examples/task_apps/enron/filter_sft.toml +5 -0
- examples/task_apps/enron/tests/__init__.py +2 -0
- examples/task_apps/enron/tests/integration/__init__.py +2 -0
- examples/task_apps/enron/tests/integration/test_enron_eval.py +2 -0
- examples/task_apps/enron/tests/unit/__init__.py +2 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_COMPLETE.md +283 -0
- examples/task_apps/pokemon_red/EVAL_IMAGE_ONLY_STATUS.md +155 -0
- examples/task_apps/pokemon_red/README_IMAGE_ONLY_EVAL.md +415 -0
- examples/task_apps/pokemon_red/eval_image_only_gpt4o.toml +29 -0
- examples/task_apps/pokemon_red/pallet_town_rl_config.toml +2 -0
- examples/task_apps/pokemon_red/task_app.py +199 -6
- examples/task_apps/pokemon_red/test_pallet_town_rewards.py +2 -0
- examples/task_apps/sokoban/filter_sft.toml +5 -0
- examples/task_apps/sokoban/tests/__init__.py +2 -0
- examples/task_apps/sokoban/tests/integration/__init__.py +2 -0
- examples/task_apps/sokoban/tests/unit/__init__.py +2 -0
- examples/task_apps/verilog/eval_groq_qwen32b.toml +8 -4
- examples/task_apps/verilog/filter_sft.toml +5 -0
- examples/task_apps/verilog/task_app/grpo_verilog.py +258 -23
- examples/task_apps/verilog/tests/__init__.py +2 -0
- examples/task_apps/verilog/tests/integration/__init__.py +2 -0
- examples/task_apps/verilog/tests/integration/test_verilog_eval.py +2 -0
- examples/task_apps/verilog/tests/unit/__init__.py +2 -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/groq_test.py +2 -0
- 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.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_modal.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_parallel.py +2 -0
- examples/warming_up_to_rl/run_local_rollout_traced.py +2 -0
- examples/warming_up_to_rl/run_rl_and_save.py +1 -1
- examples/warming_up_to_rl/run_rollout_remote.py +2 -0
- 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 +145 -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 +66 -49
- synth_ai/cli/_modal_wrapper.py +9 -6
- 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 +1 -0
- synth_ai/cli/setup.py +266 -0
- 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 +392 -141
- synth_ai/cli/train.py +18 -0
- synth_ai/cli/tui.py +62 -0
- 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/crafter_classic/environment.py +1 -1
- synth_ai/environments/examples/verilog/engine.py +76 -10
- 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/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/__init__.py +11 -1
- synth_ai/task/apps/__init__.py +5 -2
- synth_ai/task/config.py +259 -0
- synth_ai/task/contracts.py +15 -2
- synth_ai/task/rubrics/__init__.py +4 -2
- synth_ai/task/rubrics/loaders.py +27 -4
- synth_ai/task/rubrics/scoring.py +3 -0
- synth_ai/task/rubrics.py +219 -0
- synth_ai/task/trace_correlation_helpers.py +328 -0
- synth_ai/task/tracing_utils.py +14 -3
- synth_ai/task/validators.py +145 -2
- 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/session_tracer.py +10 -0
- synth_ai/tracing_v3/turso/daemon.py +2 -2
- synth_ai/tracing_v3/turso/native_manager.py +108 -77
- synth_ai/tracing_v3/utils.py +1 -1
- synth_ai/tui/__init__.py +5 -0
- synth_ai/tui/__main__.py +13 -0
- synth_ai/tui/cli/__init__.py +1 -0
- synth_ai/tui/cli/query_experiments.py +164 -0
- synth_ai/tui/cli/query_experiments_v3.py +164 -0
- synth_ai/tui/dashboard.py +911 -0
- 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.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/METADATA +85 -31
- {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/RECORD +286 -135
- 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.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/WHEEL +0 -0
- {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/entry_points.txt +0 -0
- {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/licenses/LICENSE +0 -0
- {synth_ai-0.2.13.dev2.dist-info → synth_ai-0.2.16.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
from . import task_app_state
|
|
2
|
+
from .base_url import PROD_BASE_URL_DEFAULT, get_backend_from_env, get_learning_v2_base_url
|
|
3
|
+
from .cli import PromptedChoiceOption, PromptedChoiceType, print_next_step
|
|
4
|
+
from .env import mask_str, resolve_env_var, write_env_var_to_dotenv, write_env_var_to_json
|
|
5
|
+
from .http import AsyncHttpClient, HTTPError, http_request
|
|
6
|
+
from .modal import (
|
|
7
|
+
ensure_modal_installed,
|
|
8
|
+
ensure_task_app_ready,
|
|
9
|
+
find_asgi_apps,
|
|
10
|
+
is_local_demo_url,
|
|
11
|
+
is_modal_public_url,
|
|
12
|
+
normalize_endpoint_url,
|
|
13
|
+
)
|
|
14
|
+
from .process import ensure_local_port_available, popen_capture, popen_stream, popen_stream_capture
|
|
15
|
+
from .sqld import SQLD_VERSION, find_sqld_binary, install_sqld
|
|
16
|
+
from .task_app_discovery import AppChoice, discover_eval_config_paths, select_app_choice
|
|
17
|
+
from .task_app_env import ensure_env_credentials, ensure_port_free, preflight_env_key
|
|
18
|
+
from .task_app_state import (
|
|
19
|
+
DEFAULT_TASK_APP_SECRET_NAME,
|
|
20
|
+
current_task_app_id,
|
|
21
|
+
load_demo_dir,
|
|
22
|
+
load_template_id,
|
|
23
|
+
now_iso,
|
|
24
|
+
persist_api_key,
|
|
25
|
+
persist_demo_dir,
|
|
26
|
+
persist_env_api_key,
|
|
27
|
+
persist_task_url,
|
|
28
|
+
persist_template_id,
|
|
29
|
+
read_task_app_config,
|
|
30
|
+
record_task_app,
|
|
31
|
+
resolve_task_app_entry,
|
|
32
|
+
task_app_config_path,
|
|
33
|
+
task_app_id_from_path,
|
|
34
|
+
update_task_app_entry,
|
|
35
|
+
write_task_app_config,
|
|
36
|
+
)
|
|
37
|
+
from .user_config import (
|
|
38
|
+
USER_CONFIG_PATH,
|
|
39
|
+
load_user_config,
|
|
40
|
+
load_user_env,
|
|
41
|
+
save_user_config,
|
|
42
|
+
update_user_config,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"AppChoice",
|
|
47
|
+
"AsyncHttpClient",
|
|
48
|
+
"DEFAULT_TASK_APP_SECRET_NAME",
|
|
49
|
+
"HTTPError",
|
|
50
|
+
"PROD_BASE_URL_DEFAULT",
|
|
51
|
+
"PromptedChoiceOption",
|
|
52
|
+
"PromptedChoiceType",
|
|
53
|
+
"SQLD_VERSION",
|
|
54
|
+
"USER_CONFIG_PATH",
|
|
55
|
+
"current_task_app_id",
|
|
56
|
+
"discover_eval_config_paths",
|
|
57
|
+
"ensure_env_credentials",
|
|
58
|
+
"ensure_local_port_available",
|
|
59
|
+
"ensure_modal_installed",
|
|
60
|
+
"ensure_port_free",
|
|
61
|
+
"ensure_task_app_ready",
|
|
62
|
+
"find_asgi_apps",
|
|
63
|
+
"find_sqld_binary",
|
|
64
|
+
"get_backend_from_env",
|
|
65
|
+
"get_learning_v2_base_url",
|
|
66
|
+
"http_request",
|
|
67
|
+
"install_sqld",
|
|
68
|
+
"is_local_demo_url",
|
|
69
|
+
"is_modal_public_url",
|
|
70
|
+
"load_demo_dir",
|
|
71
|
+
"load_template_id",
|
|
72
|
+
"load_user_config",
|
|
73
|
+
"load_user_env",
|
|
74
|
+
"mask_str",
|
|
75
|
+
"normalize_endpoint_url",
|
|
76
|
+
"now_iso",
|
|
77
|
+
"persist_api_key",
|
|
78
|
+
"persist_demo_dir",
|
|
79
|
+
"persist_env_api_key",
|
|
80
|
+
"persist_task_url",
|
|
81
|
+
"persist_template_id",
|
|
82
|
+
"popen_capture",
|
|
83
|
+
"popen_stream",
|
|
84
|
+
"popen_stream_capture",
|
|
85
|
+
"preflight_env_key",
|
|
86
|
+
"print_next_step",
|
|
87
|
+
"read_task_app_config",
|
|
88
|
+
"record_task_app",
|
|
89
|
+
"resolve_env_var",
|
|
90
|
+
"resolve_task_app_entry",
|
|
91
|
+
"save_user_config",
|
|
92
|
+
"select_app_choice",
|
|
93
|
+
"task_app_config_path",
|
|
94
|
+
"task_app_id_from_path",
|
|
95
|
+
"task_app_state",
|
|
96
|
+
"update_task_app_entry",
|
|
97
|
+
"update_user_config",
|
|
98
|
+
"write_env_var_to_dotenv",
|
|
99
|
+
"write_env_var_to_json",
|
|
100
|
+
"write_task_app_config",
|
|
101
|
+
]
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Base URL resolution for learning-v2 and related backend APIs.
|
|
3
|
+
|
|
4
|
+
Default to production, allow overrides via environment variables:
|
|
5
|
+
- LEARNING_V2_BASE_URL (highest precedence)
|
|
6
|
+
- SYNTH_BASE_URL (legacy)
|
|
7
|
+
- SYNTH_LOCAL_BASE_URL
|
|
8
|
+
- SYNTH_DEV_BASE_URL
|
|
9
|
+
- SYNTH_PROD_BASE_URL (fallback if none provided)
|
|
10
|
+
|
|
11
|
+
Normalization: ensure the returned URL ends with "/api".
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from typing import Literal
|
|
16
|
+
|
|
17
|
+
PROD_BASE_URL_DEFAULT = "https://agent-learning.onrender.com"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _normalize_base(url: str) -> str:
|
|
21
|
+
url = url.strip()
|
|
22
|
+
if url.endswith("/v1"):
|
|
23
|
+
url = url[:-3]
|
|
24
|
+
url = url.rstrip("/")
|
|
25
|
+
if not url.endswith("/api"):
|
|
26
|
+
url = f"{url}/api"
|
|
27
|
+
return url
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_learning_v2_base_url(mode: Literal["dev", "prod"] = "prod") -> str:
|
|
31
|
+
if mode == "prod":
|
|
32
|
+
prod = os.getenv("SYNTH_PROD_BASE_URL") or PROD_BASE_URL_DEFAULT
|
|
33
|
+
return _normalize_base(prod)
|
|
34
|
+
env_url = os.getenv("LEARNING_V2_BASE_URL")
|
|
35
|
+
if env_url:
|
|
36
|
+
return _normalize_base(env_url)
|
|
37
|
+
|
|
38
|
+
legacy = os.getenv("SYNTH_BASE_URL")
|
|
39
|
+
if legacy:
|
|
40
|
+
return _normalize_base(legacy)
|
|
41
|
+
|
|
42
|
+
local = os.getenv("SYNTH_LOCAL_BASE_URL")
|
|
43
|
+
if local:
|
|
44
|
+
return _normalize_base(local)
|
|
45
|
+
|
|
46
|
+
dev = os.getenv("SYNTH_DEV_BASE_URL")
|
|
47
|
+
if dev:
|
|
48
|
+
return _normalize_base(dev)
|
|
49
|
+
|
|
50
|
+
raise ValueError("No base URL configured. Set one of: LEARNING_V2_BASE_URL, SYNTH_BASE_URL, SYNTH_LOCAL_BASE_URL, SYNTH_DEV_BASE_URL")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _resolve_override_mode() -> str:
|
|
54
|
+
ov = (os.getenv("SYNTH_BACKEND_URL_OVERRIDE", "") or "").strip().lower()
|
|
55
|
+
if ov in {"local", "dev", "prod"}:
|
|
56
|
+
return ov
|
|
57
|
+
return "prod"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def get_backend_from_env() -> tuple[str, str]:
|
|
61
|
+
direct_override = (os.getenv("BACKEND_OVERRIDE") or "").strip()
|
|
62
|
+
if direct_override:
|
|
63
|
+
base = direct_override.rstrip("/")
|
|
64
|
+
if base.endswith("/api"):
|
|
65
|
+
base = base[: -len("/api")]
|
|
66
|
+
api_key = os.getenv("SYNTH_API_KEY", "").strip()
|
|
67
|
+
return base, api_key
|
|
68
|
+
|
|
69
|
+
mode = _resolve_override_mode()
|
|
70
|
+
if mode == "local":
|
|
71
|
+
base = os.getenv("LOCAL_BACKEND_URL", "http://localhost:8000")
|
|
72
|
+
key = os.getenv("TESTING_LOCAL_SYNTH_API_KEY", "")
|
|
73
|
+
return base.rstrip("/"), key
|
|
74
|
+
if mode == "dev":
|
|
75
|
+
base = os.getenv("DEV_BACKEND_URL", "") or "http://localhost:8000"
|
|
76
|
+
key = os.getenv("DEV_SYNTH_API_KEY", "")
|
|
77
|
+
return base.rstrip("/"), key
|
|
78
|
+
base = os.getenv("PROD_BACKEND_URL", f"{PROD_BASE_URL_DEFAULT}")
|
|
79
|
+
base = base.rstrip("/")
|
|
80
|
+
if base.endswith("/api"):
|
|
81
|
+
base = base[: -len("/api")]
|
|
82
|
+
key = (
|
|
83
|
+
os.getenv("PROD_SYNTH_API_KEY", "")
|
|
84
|
+
or os.getenv("TESTING_PROD_SYNTH_API_KEY", "")
|
|
85
|
+
or os.getenv("SYNTH_API_KEY", "")
|
|
86
|
+
)
|
|
87
|
+
return base, key
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = [
|
|
91
|
+
"PROD_BASE_URL_DEFAULT",
|
|
92
|
+
"get_backend_from_env",
|
|
93
|
+
"get_learning_v2_base_url",
|
|
94
|
+
]
|
synth_ai/utils/cli.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
from collections.abc import Sequence
|
|
2
|
+
from typing import Any, cast
|
|
3
|
+
|
|
4
|
+
import click
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class PromptedChoiceType(click.Choice):
|
|
8
|
+
"""`click.Choice` variant that reprompts with an interactive menu on failure.
|
|
9
|
+
|
|
10
|
+
Example
|
|
11
|
+
-------
|
|
12
|
+
```python
|
|
13
|
+
import click
|
|
14
|
+
|
|
15
|
+
from synth_ai.utils.cli import PromptedChoiceType, PromptedChoiceOption
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@click.command()
|
|
19
|
+
@click.option(
|
|
20
|
+
"--mode",
|
|
21
|
+
cls=PromptedChoiceOption,
|
|
22
|
+
type=PromptedChoiceType(["sft", "rl"]),
|
|
23
|
+
required=True,
|
|
24
|
+
)
|
|
25
|
+
def train(mode: str) -> None:
|
|
26
|
+
click.echo(f"Selected mode: {mode}")
|
|
27
|
+
```
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
def convert(
|
|
31
|
+
self,
|
|
32
|
+
value: Any,
|
|
33
|
+
param: click.Parameter | None,
|
|
34
|
+
ctx: click.Context | None,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Validate *value*; prompt for a replacement when it is missing or invalid."""
|
|
37
|
+
if param is None:
|
|
38
|
+
raise RuntimeError("Invalid parameter")
|
|
39
|
+
if ctx is None:
|
|
40
|
+
raise RuntimeError("Invalid context")
|
|
41
|
+
if value in (None, ""):
|
|
42
|
+
return self._prompt_user(param, ctx)
|
|
43
|
+
try:
|
|
44
|
+
return super().convert(value, param, ctx)
|
|
45
|
+
except click.BadParameter:
|
|
46
|
+
cmd_name = self._get_cmd_name(ctx)
|
|
47
|
+
if getattr(param, "opts", None):
|
|
48
|
+
click.echo(f'\n[{cmd_name}] Invalid value "{value}" for {self._get_arg_name(param)}')
|
|
49
|
+
else:
|
|
50
|
+
click.echo(f'\n[{cmd_name}] Invalid value "{value}"')
|
|
51
|
+
return self._prompt_user(param, ctx)
|
|
52
|
+
|
|
53
|
+
def _prompt_user(
|
|
54
|
+
self,
|
|
55
|
+
param: click.Parameter,
|
|
56
|
+
ctx: click.Context | None,
|
|
57
|
+
) -> str:
|
|
58
|
+
"""Render a numbered picker and return the user’s selection."""
|
|
59
|
+
arg_name = self._get_arg_name(param)
|
|
60
|
+
click.echo(f"\n[{self._get_cmd_name(ctx)}] Please choose a value for {arg_name}")
|
|
61
|
+
for index, choice in enumerate(self.choices, 1):
|
|
62
|
+
click.echo(f" [{index}] {choice}")
|
|
63
|
+
while True:
|
|
64
|
+
selection = click.prompt("> ", type=int)
|
|
65
|
+
if 1 <= selection <= len(self.choices):
|
|
66
|
+
return cast(str, self.choices[selection - 1])
|
|
67
|
+
click.echo(f"Invalid selection for {arg_name}, please try again")
|
|
68
|
+
|
|
69
|
+
def _get_cmd_name(self, ctx: click.Context | None) -> str:
|
|
70
|
+
"""Safely extract the current command name for diagnostic output."""
|
|
71
|
+
cmd = getattr(ctx, "command", None) if ctx is not None else None
|
|
72
|
+
if cmd is None:
|
|
73
|
+
return "?"
|
|
74
|
+
name = getattr(cmd, "name", None)
|
|
75
|
+
return name or "?"
|
|
76
|
+
|
|
77
|
+
def _get_arg_name(self, param: click.Parameter) -> str:
|
|
78
|
+
"""Return the most human-friendly identifier for the parameter."""
|
|
79
|
+
opts = getattr(param, "opts", None)
|
|
80
|
+
if opts:
|
|
81
|
+
return opts[-1]
|
|
82
|
+
name = getattr(param, "name", None)
|
|
83
|
+
if name:
|
|
84
|
+
return name
|
|
85
|
+
human_name = getattr(param, "human_readable_name", None)
|
|
86
|
+
if human_name:
|
|
87
|
+
return human_name
|
|
88
|
+
return "?"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class PromptedChoiceOption(click.Option):
|
|
92
|
+
"""`click.Option` subclass that triggers the interactive picker when missing.
|
|
93
|
+
|
|
94
|
+
Example
|
|
95
|
+
-------
|
|
96
|
+
```python
|
|
97
|
+
import click
|
|
98
|
+
|
|
99
|
+
from synth_ai.utils.cli import PromptedChoiceType, PromptedChoiceOption
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@click.command()
|
|
103
|
+
@click.option(
|
|
104
|
+
"--mode",
|
|
105
|
+
cls=PromptedChoiceOption,
|
|
106
|
+
type=PromptedChoiceType(["sft", "rl"]),
|
|
107
|
+
required=True,
|
|
108
|
+
)
|
|
109
|
+
def train(mode: str) -> None:
|
|
110
|
+
click.echo(f"Selected mode: {mode}")
|
|
111
|
+
```
|
|
112
|
+
"""
|
|
113
|
+
|
|
114
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
115
|
+
kwargs.setdefault("prompt", True)
|
|
116
|
+
kwargs.setdefault("prompt_required", True)
|
|
117
|
+
super().__init__(*args, **kwargs)
|
|
118
|
+
|
|
119
|
+
def prompt_for_value(self, ctx: click.Context) -> Any:
|
|
120
|
+
"""Invoke the choice picker when the option was omitted."""
|
|
121
|
+
option_type = getattr(self, "type", None)
|
|
122
|
+
if isinstance(option_type, PromptedChoiceType):
|
|
123
|
+
return option_type._prompt_user(self, ctx)
|
|
124
|
+
return super().prompt_for_value(ctx)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def print_next_step(message: str, lines: Sequence[str]) -> None:
|
|
128
|
+
print(f"\n➡️ Next, {message}:")
|
|
129
|
+
for line in lines:
|
|
130
|
+
print(f" {line}")
|
|
131
|
+
print("")
|
synth_ai/utils/env.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import string
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
_ENV_SAFE_CHARS = set(string.ascii_letters + string.digits + "_-./:@+=")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _format_env_value(value: str) -> str:
|
|
12
|
+
if value == "":
|
|
13
|
+
return '""'
|
|
14
|
+
if all(char in _ENV_SAFE_CHARS for char in value):
|
|
15
|
+
return value
|
|
16
|
+
return json.dumps(value)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _strip_inline_comment(value: str) -> str:
|
|
20
|
+
in_single = False
|
|
21
|
+
in_double = False
|
|
22
|
+
escaped = False
|
|
23
|
+
for idx, char in enumerate(value):
|
|
24
|
+
if escaped:
|
|
25
|
+
escaped = False
|
|
26
|
+
continue
|
|
27
|
+
if char == "\\":
|
|
28
|
+
escaped = True
|
|
29
|
+
continue
|
|
30
|
+
if char == "'" and not in_double:
|
|
31
|
+
in_single = not in_single
|
|
32
|
+
continue
|
|
33
|
+
if char == '"' and not in_single:
|
|
34
|
+
in_double = not in_double
|
|
35
|
+
continue
|
|
36
|
+
if char == '#' and not in_single and not in_double:
|
|
37
|
+
return value[:idx].rstrip()
|
|
38
|
+
return value.rstrip()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _parse_env_assignment(line: str) -> tuple[str, str] | None:
|
|
42
|
+
stripped = line.strip()
|
|
43
|
+
if not stripped or stripped.startswith('#'):
|
|
44
|
+
return None
|
|
45
|
+
if stripped.lower().startswith("export "):
|
|
46
|
+
stripped = stripped[7:].lstrip()
|
|
47
|
+
if '=' not in stripped:
|
|
48
|
+
return None
|
|
49
|
+
key_part, value_part = stripped.split('=', 1)
|
|
50
|
+
key = key_part.strip()
|
|
51
|
+
if not key:
|
|
52
|
+
return None
|
|
53
|
+
value_candidate = _strip_inline_comment(value_part.strip())
|
|
54
|
+
if not value_candidate:
|
|
55
|
+
return key, ""
|
|
56
|
+
if (
|
|
57
|
+
len(value_candidate) >= 2
|
|
58
|
+
and value_candidate[0] in {'"', "'"}
|
|
59
|
+
and value_candidate[-1] == value_candidate[0]
|
|
60
|
+
):
|
|
61
|
+
value = value_candidate[1:-1]
|
|
62
|
+
else:
|
|
63
|
+
value = value_candidate
|
|
64
|
+
return key, value
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _prompt_manual_env_value(key: str) -> str:
|
|
68
|
+
while True:
|
|
69
|
+
value = click.prompt(
|
|
70
|
+
f"Enter value for {key}",
|
|
71
|
+
hide_input=False,
|
|
72
|
+
default="",
|
|
73
|
+
show_default=False,
|
|
74
|
+
type=str,
|
|
75
|
+
).strip()
|
|
76
|
+
if value:
|
|
77
|
+
return value
|
|
78
|
+
if click.confirm("Save empty value?", default=False):
|
|
79
|
+
return ""
|
|
80
|
+
click.echo("Empty value discarded; enter a value or confirm empty to continue")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mask_str(input: str, position: int = 3) -> str:
|
|
84
|
+
return input[:position] + "..." + input[-position:] if len(input) > position * 2 else "***"
|
|
85
|
+
|
|
86
|
+
|
|
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
|
+
def filter_env_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
|
|
100
|
+
matches: list[tuple[Path, str]] = []
|
|
101
|
+
for path in paths:
|
|
102
|
+
try:
|
|
103
|
+
with path.open('r', encoding="utf-8") as file:
|
|
104
|
+
for line in file:
|
|
105
|
+
parsed = _parse_env_assignment(line)
|
|
106
|
+
if parsed is None:
|
|
107
|
+
continue
|
|
108
|
+
parsed_key, value = parsed
|
|
109
|
+
if parsed_key == key:
|
|
110
|
+
matches.append((path, value))
|
|
111
|
+
break
|
|
112
|
+
except (OSError, UnicodeDecodeError):
|
|
113
|
+
continue
|
|
114
|
+
return matches
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def filter_json_files_by_key(key: str, paths: list[Path]) -> list[tuple[Path, str]]:
|
|
118
|
+
matches: list[tuple[Path, str]] = []
|
|
119
|
+
for path in paths:
|
|
120
|
+
try:
|
|
121
|
+
with path.open('r', encoding="utf-8") as file:
|
|
122
|
+
data = json.load(file)
|
|
123
|
+
if key in data and isinstance(data[key], str):
|
|
124
|
+
matches.append((path, data[key]))
|
|
125
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
|
|
126
|
+
continue
|
|
127
|
+
return matches
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_env_var(key: str) -> None:
|
|
131
|
+
env_value = os.getenv(key)
|
|
132
|
+
if env_value is not None:
|
|
133
|
+
click.echo(f"Using {key}={mask_str(env_value)} from process environment")
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
value: str = ""
|
|
137
|
+
|
|
138
|
+
env_file_paths = filter_env_files_by_key(key, get_env_file_paths())
|
|
139
|
+
synth_file_paths = filter_json_files_by_key(key, get_synth_config_file_paths())
|
|
140
|
+
|
|
141
|
+
options: list[tuple[str, str]] = []
|
|
142
|
+
for path, value in env_file_paths:
|
|
143
|
+
resolved_path = path.resolve()
|
|
144
|
+
try:
|
|
145
|
+
rel_path = str(resolved_path.relative_to(Path.cwd()))
|
|
146
|
+
except ValueError:
|
|
147
|
+
rel_path = str(resolved_path)
|
|
148
|
+
label = f"({rel_path}) {mask_str(value)}"
|
|
149
|
+
options.append((label, value))
|
|
150
|
+
for path, value in synth_file_paths:
|
|
151
|
+
label = f"({path}) {mask_str(value)}"
|
|
152
|
+
options.append((label, value))
|
|
153
|
+
|
|
154
|
+
if options:
|
|
155
|
+
click.echo(f"\nFound the following options for {key}")
|
|
156
|
+
for i, (label, _) in enumerate(options, start=1):
|
|
157
|
+
click.echo(f" [{i}] {label}")
|
|
158
|
+
click.echo(" [m] Enter value manually")
|
|
159
|
+
click.echo()
|
|
160
|
+
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
choice = click.prompt(
|
|
164
|
+
"Select option",
|
|
165
|
+
default=1,
|
|
166
|
+
type=str,
|
|
167
|
+
show_choices=False,
|
|
168
|
+
).strip()
|
|
169
|
+
except click.Abort:
|
|
170
|
+
return
|
|
171
|
+
|
|
172
|
+
if choice.lower() == 'm':
|
|
173
|
+
value = _prompt_manual_env_value(key)
|
|
174
|
+
break
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
index = int(choice)
|
|
178
|
+
except ValueError:
|
|
179
|
+
click.echo('Invalid selection. Enter a number or "m".')
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
if 1 <= index <= len(options):
|
|
183
|
+
_, value = options[index - 1]
|
|
184
|
+
break
|
|
185
|
+
|
|
186
|
+
click.echo(f"Invalid selection. Enter a number between 1 and {len(options)} or 'm'.")
|
|
187
|
+
|
|
188
|
+
else:
|
|
189
|
+
click.echo(f"No value found for {key}")
|
|
190
|
+
value = _prompt_manual_env_value(key)
|
|
191
|
+
|
|
192
|
+
os.environ[key] = value
|
|
193
|
+
click.echo(f"Loaded {key}={mask_str(value)} into process environment")
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def write_env_var_to_dotenv(
|
|
198
|
+
key: str,
|
|
199
|
+
value: str,
|
|
200
|
+
output_file_path: str | Path,
|
|
201
|
+
) -> None:
|
|
202
|
+
path = Path(output_file_path).expanduser()
|
|
203
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
204
|
+
|
|
205
|
+
encoded_value = _format_env_value(value)
|
|
206
|
+
|
|
207
|
+
lines: list[str] = []
|
|
208
|
+
key_written = False
|
|
209
|
+
|
|
210
|
+
if path.is_file():
|
|
211
|
+
try:
|
|
212
|
+
with path.open('r', encoding="utf-8") as handle:
|
|
213
|
+
lines = handle.readlines()
|
|
214
|
+
except OSError as exc:
|
|
215
|
+
raise click.ClickException(f"Failed to read {path}: {exc}") from exc
|
|
216
|
+
|
|
217
|
+
for index, line in enumerate(lines):
|
|
218
|
+
parsed = _parse_env_assignment(line)
|
|
219
|
+
if parsed is None or parsed[0] != key:
|
|
220
|
+
continue
|
|
221
|
+
|
|
222
|
+
leading_len = len(line) - len(line.lstrip(' \t'))
|
|
223
|
+
leading = line[:leading_len]
|
|
224
|
+
stripped = line.lstrip()
|
|
225
|
+
has_export = stripped.lower().startswith('export ')
|
|
226
|
+
newline = '\n' if line.endswith('\n') else ''
|
|
227
|
+
prefix = 'export ' if has_export else ''
|
|
228
|
+
lines[index] = f"{leading}{prefix}{key}={encoded_value}{newline}"
|
|
229
|
+
key_written = True
|
|
230
|
+
break
|
|
231
|
+
|
|
232
|
+
if not key_written:
|
|
233
|
+
if lines and not lines[-1].endswith('\n'):
|
|
234
|
+
lines[-1] = f"{lines[-1]}\n"
|
|
235
|
+
lines.append(f"{key}={encoded_value}\n")
|
|
236
|
+
|
|
237
|
+
try:
|
|
238
|
+
with path.open('w', encoding="utf-8") as handle:
|
|
239
|
+
handle.writelines(lines)
|
|
240
|
+
except OSError as exc:
|
|
241
|
+
raise click.ClickException(f"Failed to write {path}: {exc}") from exc
|
|
242
|
+
|
|
243
|
+
click.echo(f"Wrote {key}={mask_str(value)} to {path}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def write_env_var_to_json(
|
|
247
|
+
key: str,
|
|
248
|
+
value: str,
|
|
249
|
+
output_file_path: str | Path,
|
|
250
|
+
) -> None:
|
|
251
|
+
path = Path(output_file_path).expanduser()
|
|
252
|
+
if path.exists() and not path.is_file():
|
|
253
|
+
raise click.ClickException(f"{path} exists and is not a file")
|
|
254
|
+
|
|
255
|
+
data: dict[str, str] = {}
|
|
256
|
+
|
|
257
|
+
if path.is_file():
|
|
258
|
+
try:
|
|
259
|
+
with path.open('r', encoding="utf-8") as handle:
|
|
260
|
+
existing = json.load(handle)
|
|
261
|
+
except json.JSONDecodeError as exc:
|
|
262
|
+
raise click.ClickException(f"Invalid JSON in {path}: {exc}") from exc
|
|
263
|
+
except OSError as exc:
|
|
264
|
+
raise click.ClickException(f"Failed to read {path}: {exc}") from exc
|
|
265
|
+
|
|
266
|
+
if not isinstance(existing, dict):
|
|
267
|
+
raise click.ClickException(f"Expected JSON object in {path}")
|
|
268
|
+
|
|
269
|
+
for existing_key, existing_value in existing.items():
|
|
270
|
+
if existing_key == key:
|
|
271
|
+
continue
|
|
272
|
+
data[str(existing_key)] = (
|
|
273
|
+
existing_value if isinstance(existing_value, str) else str(existing_value)
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
data[key] = value
|
|
277
|
+
|
|
278
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
|
|
280
|
+
try:
|
|
281
|
+
with path.open('w', encoding="utf-8") as handle:
|
|
282
|
+
json.dump(data, handle, indent=2, sort_keys=True)
|
|
283
|
+
handle.write('\n')
|
|
284
|
+
except OSError as exc:
|
|
285
|
+
raise click.ClickException(f"Failed to write {path}: {exc}") from exc
|
|
286
|
+
|
|
287
|
+
click.echo(f"Wrote {key}={mask_str(value)} to {path}")
|