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
|
@@ -14,8 +14,11 @@ import re
|
|
|
14
14
|
from collections.abc import Callable
|
|
15
15
|
from dataclasses import asdict, dataclass
|
|
16
16
|
from datetime import UTC, datetime
|
|
17
|
+
from pathlib import Path
|
|
17
18
|
from typing import TYPE_CHECKING, Any, cast
|
|
19
|
+
from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse
|
|
18
20
|
|
|
21
|
+
import httpx
|
|
19
22
|
import libsql
|
|
20
23
|
from sqlalchemy.engine import make_url
|
|
21
24
|
|
|
@@ -60,36 +63,70 @@ class _ConnectionTarget:
|
|
|
60
63
|
auth_token: str | None = None
|
|
61
64
|
|
|
62
65
|
|
|
66
|
+
def _strip_auth_component(url: str) -> tuple[str, str | None]:
|
|
67
|
+
"""Remove auth_token query parameter from URL, returning the token separately."""
|
|
68
|
+
parsed = urlparse(url)
|
|
69
|
+
if not parsed.query:
|
|
70
|
+
return url, None
|
|
71
|
+
|
|
72
|
+
params = dict(parse_qsl(parsed.query, keep_blank_values=True))
|
|
73
|
+
token = params.pop("auth_token", None)
|
|
74
|
+
query = urlencode(params, doseq=True)
|
|
75
|
+
sanitised = urlunparse(parsed._replace(query=query))
|
|
76
|
+
return sanitised, token
|
|
77
|
+
|
|
78
|
+
|
|
63
79
|
def _resolve_connection_target(db_url: str | None, auth_token: str | None) -> _ConnectionTarget:
|
|
64
80
|
"""Normalise the configured database URL."""
|
|
65
81
|
url = db_url or CONFIG.db_url
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
if url.startswith("sqlite+aiosqlite:///"):
|
|
69
|
-
return _ConnectionTarget(database=url.replace("sqlite+aiosqlite:///", ""), auth_token=auth_token)
|
|
82
|
+
sanitised, token_from_url = _strip_auth_component(url)
|
|
83
|
+
effective_token = auth_token or token_from_url or CONFIG.auth_token
|
|
70
84
|
|
|
71
85
|
# SQLAlchemy-compatible libsql scheme (`sqlite+libsql://<endpoint or path>`)
|
|
72
|
-
if
|
|
73
|
-
|
|
74
|
-
|
|
86
|
+
if sanitised.startswith("sqlite+libsql://"):
|
|
87
|
+
raise RuntimeError("sqlite+libsql scheme is no longer supported; use libsql://")
|
|
88
|
+
|
|
89
|
+
# Plain SQLite files: file://, /absolute/path, or relative path
|
|
90
|
+
# libsql.connect() handles these without sync_url or auth_token
|
|
91
|
+
if sanitised.startswith("file://") or sanitised.startswith("/") or "://" not in sanitised:
|
|
92
|
+
# Strip file:// prefix if present, libsql.connect handles both formats
|
|
93
|
+
db_path = sanitised.replace("file://", "") if sanitised.startswith("file://") else sanitised
|
|
94
|
+
return _ConnectionTarget(database=db_path, sync_url=None, auth_token=None)
|
|
75
95
|
|
|
76
96
|
# Native libsql URLs (`libsql://...`).
|
|
77
|
-
if
|
|
78
|
-
return _ConnectionTarget(database=
|
|
97
|
+
if sanitised.startswith("libsql://"):
|
|
98
|
+
return _ConnectionTarget(database=sanitised, sync_url=sanitised, auth_token=effective_token)
|
|
79
99
|
|
|
80
100
|
# Fallback to SQLAlchemy URL parsing for anything else we missed.
|
|
81
101
|
try:
|
|
82
|
-
parsed = make_url(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
102
|
+
parsed = make_url(sanitised)
|
|
103
|
+
driver = parsed.drivername.lower()
|
|
104
|
+
if driver.startswith("sqlite"):
|
|
105
|
+
database = parsed.database or ""
|
|
106
|
+
if database and database not in {":memory:", ":memory"}:
|
|
107
|
+
# Absolute paths are passed through; relative paths are resolved to cwd
|
|
108
|
+
if database.startswith("/"):
|
|
109
|
+
db_path = database
|
|
110
|
+
else:
|
|
111
|
+
db_path = str(Path(database).expanduser().resolve())
|
|
112
|
+
elif database in {":memory:", ":memory"}:
|
|
113
|
+
db_path = ":memory:"
|
|
114
|
+
else:
|
|
115
|
+
raise RuntimeError("SQLite URL missing database path.")
|
|
116
|
+
return _ConnectionTarget(database=db_path, sync_url=None, auth_token=None)
|
|
117
|
+
if driver.startswith("libsql"):
|
|
86
118
|
database = parsed.render_as_string(hide_password=False)
|
|
87
|
-
return _ConnectionTarget(database=database, sync_url=database, auth_token=
|
|
119
|
+
return _ConnectionTarget(database=database, sync_url=database, auth_token=effective_token)
|
|
88
120
|
except Exception: # pragma: no cover - defensive guardrail
|
|
89
121
|
logger.debug("Unable to parse db_url via SQLAlchemy", exc_info=True)
|
|
90
122
|
|
|
91
|
-
#
|
|
92
|
-
|
|
123
|
+
# Python libsql client uses HTTP API for http:// URLs, not Hrana WebSocket
|
|
124
|
+
# For local sqld with http:// URL, we need to ensure it points to the HTTP API port
|
|
125
|
+
# sqld uses two ports: Hrana WebSocket (e.g. 8080) and HTTP API (e.g. 8081)
|
|
126
|
+
# libsql.connect() with http:// uses HTTP API, so URL should point to HTTP API port
|
|
127
|
+
if sanitised.startswith(("http://", "https://", "libsql://")):
|
|
128
|
+
return _ConnectionTarget(database=sanitised, sync_url=sanitised, auth_token=effective_token)
|
|
129
|
+
raise RuntimeError(f"Unsupported tracing database URL: {sanitised}")
|
|
93
130
|
|
|
94
131
|
|
|
95
132
|
def _json_dumps(value: Any) -> str | None:
|
|
@@ -350,6 +387,45 @@ class NativeLibsqlTraceManager(TraceStorage):
|
|
|
350
387
|
if self._initialized:
|
|
351
388
|
return
|
|
352
389
|
|
|
390
|
+
# Fast-fail preflight: if using remote endpoint or local sqld, check health
|
|
391
|
+
# Skip health check for plain SQLite files (sync_url is None)
|
|
392
|
+
if self._target.sync_url:
|
|
393
|
+
try:
|
|
394
|
+
parsed = urlparse(self._target.database or "")
|
|
395
|
+
# Check for local sqld: http://, https://, or libsql://
|
|
396
|
+
if parsed.scheme in ("http", "https", "libsql"):
|
|
397
|
+
host_port = parsed.netloc or ""
|
|
398
|
+
host = (host_port.split(":", 1)[0] or "").strip().lower()
|
|
399
|
+
if host in {"127.0.0.1", "localhost"} and host_port:
|
|
400
|
+
# For http:// URLs, the port should already be the HTTP API port
|
|
401
|
+
# For libsql:// URLs, we need to calculate health check port
|
|
402
|
+
if ":" in host_port:
|
|
403
|
+
port = int(host_port.split(":", 1)[1])
|
|
404
|
+
if parsed.scheme == "libsql":
|
|
405
|
+
# libsql:// uses Hrana port, health check is on HTTP API port (Hrana + 1)
|
|
406
|
+
health_url = f"http://{host}:{port + 1}/health"
|
|
407
|
+
else:
|
|
408
|
+
# http:// already points to HTTP API port
|
|
409
|
+
health_url = f"http://{host}:{port}/health"
|
|
410
|
+
else:
|
|
411
|
+
health_url = f"http://{host_port}/health"
|
|
412
|
+
try:
|
|
413
|
+
async with httpx.AsyncClient(timeout=httpx.Timeout(1.0)) as client:
|
|
414
|
+
resp = await client.get(health_url)
|
|
415
|
+
if resp.status_code != 200:
|
|
416
|
+
raise RuntimeError(
|
|
417
|
+
f"Tracing backend unhealthy at {health_url} (status={resp.status_code})"
|
|
418
|
+
)
|
|
419
|
+
except Exception as exc: # pragma: no cover - network env dependent
|
|
420
|
+
raise RuntimeError(
|
|
421
|
+
f"Tracing backend not reachable at {health_url}. "
|
|
422
|
+
f"Start sqld with both ports: sqld --db-path <path> --hrana-listen-addr {host}:HRANA_PORT --http-listen-addr {host}:HTTP_PORT "
|
|
423
|
+
f"or disable tracing (TASKAPP_TRACING_ENABLED=0)."
|
|
424
|
+
) from exc
|
|
425
|
+
except Exception:
|
|
426
|
+
# Propagate any preflight failure to abort early
|
|
427
|
+
raise
|
|
428
|
+
|
|
353
429
|
# Establish a libsql connection for future native operations.
|
|
354
430
|
self._conn = self._open_connection()
|
|
355
431
|
self._ensure_schema()
|
synth_ai/types.py
ADDED
synth_ai/urls.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Base URL for all backends
|
|
2
|
+
BACKEND_URL_BASE = "https://agent-learning.onrender.com"
|
|
3
|
+
|
|
4
|
+
# Synth Research API base (supports OpenAI, Anthropic, and custom formats)
|
|
5
|
+
# Real routes: /api/synth-research/chat/completions, /api/synth-research/messages
|
|
6
|
+
# V1 routes: /api/synth-research/v1/chat/completions, /api/synth-research/v1/messages
|
|
7
|
+
BACKEND_URL_SYNTH_RESEARCH_BASE = BACKEND_URL_BASE + "/api/synth-research"
|
|
8
|
+
|
|
9
|
+
# Provider-specific URLs (for SDKs that expect standard paths)
|
|
10
|
+
BACKEND_URL_SYNTH_RESEARCH_OPENAI = BACKEND_URL_SYNTH_RESEARCH_BASE + "/v1" # For OpenAI SDKs (appends /chat/completions)
|
|
11
|
+
BACKEND_URL_SYNTH_RESEARCH_ANTHROPIC = BACKEND_URL_SYNTH_RESEARCH_BASE # For Anthropic SDKs (appends /v1/messages)
|
synth_ai/utils/__init__.py
CHANGED
|
@@ -1,8 +1,18 @@
|
|
|
1
1
|
from . import task_app_state
|
|
2
|
+
from .agents import write_agents_md
|
|
2
3
|
from .base_url import PROD_BASE_URL_DEFAULT, get_backend_from_env, get_learning_v2_base_url
|
|
3
|
-
from .
|
|
4
|
+
from .bin import install_bin, verify_bin
|
|
5
|
+
from .cli import (
|
|
6
|
+
PromptedChoiceOption,
|
|
7
|
+
PromptedChoiceType,
|
|
8
|
+
PromptedPathOption,
|
|
9
|
+
print_next_step,
|
|
10
|
+
prompt_choice,
|
|
11
|
+
prompt_for_path,
|
|
12
|
+
)
|
|
4
13
|
from .env import mask_str, resolve_env_var, write_env_var_to_dotenv, write_env_var_to_json
|
|
5
14
|
from .http import AsyncHttpClient, HTTPError, http_request
|
|
15
|
+
from .json import create_and_write_json, load_json_to_dict, strip_json_comments
|
|
6
16
|
from .modal import (
|
|
7
17
|
ensure_modal_installed,
|
|
8
18
|
ensure_task_app_ready,
|
|
@@ -11,6 +21,12 @@ from .modal import (
|
|
|
11
21
|
is_modal_public_url,
|
|
12
22
|
normalize_endpoint_url,
|
|
13
23
|
)
|
|
24
|
+
from .paths import (
|
|
25
|
+
find_bin_path,
|
|
26
|
+
find_config_path,
|
|
27
|
+
get_env_file_paths,
|
|
28
|
+
get_home_config_file_paths,
|
|
29
|
+
)
|
|
14
30
|
from .process import ensure_local_port_available, popen_capture, popen_stream, popen_stream_capture
|
|
15
31
|
from .sqld import SQLD_VERSION, find_sqld_binary, install_sqld
|
|
16
32
|
from .task_app_discovery import AppChoice, discover_eval_config_paths, select_app_choice
|
|
@@ -50,8 +66,11 @@ __all__ = [
|
|
|
50
66
|
"PROD_BASE_URL_DEFAULT",
|
|
51
67
|
"PromptedChoiceOption",
|
|
52
68
|
"PromptedChoiceType",
|
|
69
|
+
"PromptedPathOption",
|
|
70
|
+
"prompt_for_path",
|
|
53
71
|
"SQLD_VERSION",
|
|
54
72
|
"USER_CONFIG_PATH",
|
|
73
|
+
"create_and_write_json",
|
|
55
74
|
"current_task_app_id",
|
|
56
75
|
"discover_eval_config_paths",
|
|
57
76
|
"ensure_env_credentials",
|
|
@@ -60,14 +79,20 @@ __all__ = [
|
|
|
60
79
|
"ensure_port_free",
|
|
61
80
|
"ensure_task_app_ready",
|
|
62
81
|
"find_asgi_apps",
|
|
82
|
+
"find_bin_path",
|
|
83
|
+
"find_config_path",
|
|
63
84
|
"find_sqld_binary",
|
|
64
85
|
"get_backend_from_env",
|
|
86
|
+
"get_env_file_paths",
|
|
87
|
+
"get_home_config_file_paths",
|
|
65
88
|
"get_learning_v2_base_url",
|
|
66
89
|
"http_request",
|
|
90
|
+
"install_bin",
|
|
67
91
|
"install_sqld",
|
|
68
92
|
"is_local_demo_url",
|
|
69
93
|
"is_modal_public_url",
|
|
70
94
|
"load_demo_dir",
|
|
95
|
+
"load_json_to_dict",
|
|
71
96
|
"load_template_id",
|
|
72
97
|
"load_user_config",
|
|
73
98
|
"load_user_env",
|
|
@@ -84,17 +109,21 @@ __all__ = [
|
|
|
84
109
|
"popen_stream_capture",
|
|
85
110
|
"preflight_env_key",
|
|
86
111
|
"print_next_step",
|
|
112
|
+
"prompt_choice",
|
|
87
113
|
"read_task_app_config",
|
|
88
114
|
"record_task_app",
|
|
89
115
|
"resolve_env_var",
|
|
90
116
|
"resolve_task_app_entry",
|
|
91
117
|
"save_user_config",
|
|
92
118
|
"select_app_choice",
|
|
119
|
+
"strip_json_comments",
|
|
93
120
|
"task_app_config_path",
|
|
94
121
|
"task_app_id_from_path",
|
|
95
122
|
"task_app_state",
|
|
96
123
|
"update_task_app_entry",
|
|
97
124
|
"update_user_config",
|
|
125
|
+
"verify_bin",
|
|
126
|
+
'write_agents_md',
|
|
98
127
|
"write_env_var_to_dotenv",
|
|
99
128
|
"write_env_var_to_json",
|
|
100
129
|
"write_task_app_config",
|
synth_ai/utils/agents.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
|
|
3
|
+
AGENTS_TEXT = """
|
|
4
|
+
sinf
|
|
5
|
+
"""
|
|
6
|
+
SYNTH_DIV_START = "### --- SYNTH SECTION START ---"
|
|
7
|
+
SYNTH_DIV_END = "### ---- SYNTH SECTION END ----"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _render_block() -> str:
|
|
11
|
+
return f"{SYNTH_DIV_START}\n{AGENTS_TEXT}\n{SYNTH_DIV_END}"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _append_block(prefix: str) -> str:
|
|
15
|
+
prefix = prefix.rstrip()
|
|
16
|
+
block = _render_block()
|
|
17
|
+
if prefix:
|
|
18
|
+
return f"{prefix}\n\n{block}\n"
|
|
19
|
+
return f"{block}\n"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def write_agents_md() -> None:
|
|
23
|
+
path = Path.cwd() / "AGENTS.md"
|
|
24
|
+
if not path.exists():
|
|
25
|
+
path.write_text(_append_block(""), encoding="utf-8")
|
|
26
|
+
return
|
|
27
|
+
|
|
28
|
+
file_text = path.read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
# Remove orphan end markers first (end markers without a preceding start marker)
|
|
31
|
+
cleaned = file_text
|
|
32
|
+
end_pos = cleaned.find(SYNTH_DIV_END)
|
|
33
|
+
start_pos = cleaned.find(SYNTH_DIV_START)
|
|
34
|
+
|
|
35
|
+
# If there's an end marker before any start marker
|
|
36
|
+
if end_pos != -1 and (start_pos == -1 or end_pos < start_pos):
|
|
37
|
+
if start_pos == -1:
|
|
38
|
+
# No start markers at all - remove everything including content before orphan
|
|
39
|
+
cleaned = cleaned[end_pos + len(SYNTH_DIV_END):].lstrip()
|
|
40
|
+
else:
|
|
41
|
+
# There are start markers after the orphan - preserve content before orphan
|
|
42
|
+
before_orphan = cleaned[:end_pos].rstrip()
|
|
43
|
+
after_orphan = cleaned[end_pos + len(SYNTH_DIV_END):].lstrip()
|
|
44
|
+
cleaned = "\n\n".join(filter(None, [before_orphan, after_orphan]))
|
|
45
|
+
|
|
46
|
+
# Find the first start and last end marker to consolidate multiple sections
|
|
47
|
+
first_start = cleaned.find(SYNTH_DIV_START)
|
|
48
|
+
last_end = cleaned.rfind(SYNTH_DIV_END)
|
|
49
|
+
|
|
50
|
+
if first_start != -1 and last_end != -1 and last_end > first_start:
|
|
51
|
+
# We have at least one valid section, consolidate all into one
|
|
52
|
+
before = cleaned[:first_start].rstrip()
|
|
53
|
+
after = cleaned[last_end + len(SYNTH_DIV_END):].lstrip()
|
|
54
|
+
|
|
55
|
+
parts: list[str] = []
|
|
56
|
+
if before:
|
|
57
|
+
parts.append(before)
|
|
58
|
+
parts.append(_render_block())
|
|
59
|
+
if after:
|
|
60
|
+
parts.append(after)
|
|
61
|
+
|
|
62
|
+
new_text = "\n\n".join(parts)
|
|
63
|
+
if not new_text.endswith("\n"):
|
|
64
|
+
new_text += "\n"
|
|
65
|
+
path.write_text(new_text, encoding="utf-8")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# No valid sections found, remove any remaining orphan markers
|
|
69
|
+
cleaned = cleaned.replace(SYNTH_DIV_END, "")
|
|
70
|
+
cleaned = cleaned.replace(AGENTS_TEXT, "")
|
|
71
|
+
cleaned = cleaned.strip()
|
|
72
|
+
if cleaned:
|
|
73
|
+
cleaned += "\n\n"
|
|
74
|
+
path.write_text(f"{cleaned}{_render_block()}\n", encoding="utf-8")
|
synth_ai/utils/bin.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import shlex
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .cli import prompt_choice
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def install_bin(name: str, install_options: list[str]) -> bool:
|
|
9
|
+
cmd = prompt_choice(
|
|
10
|
+
f"How would you like to install {name}?",
|
|
11
|
+
install_options
|
|
12
|
+
)
|
|
13
|
+
div_start = f"{'-' * 29} INSTALL START {'-' * 29}"
|
|
14
|
+
div_end = f"{'-' * 30} INSTALL END {'-' * 30}"
|
|
15
|
+
try:
|
|
16
|
+
print(f"Installing {name} via `{cmd}`")
|
|
17
|
+
print('\n' + div_start)
|
|
18
|
+
subprocess.run(shlex.split(cmd), check=True)
|
|
19
|
+
print(div_end + '\n')
|
|
20
|
+
return True
|
|
21
|
+
except subprocess.CalledProcessError as e:
|
|
22
|
+
print(f"Failed to install {name}: {e}")
|
|
23
|
+
print(div_end + '\n')
|
|
24
|
+
return False
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def verify_bin(bin_path: Path) -> bool:
|
|
28
|
+
try:
|
|
29
|
+
result = subprocess.run(
|
|
30
|
+
[str(bin_path), "--version"],
|
|
31
|
+
capture_output=True,
|
|
32
|
+
text=True,
|
|
33
|
+
timeout=3,
|
|
34
|
+
check=False
|
|
35
|
+
)
|
|
36
|
+
return result.returncode == 0
|
|
37
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
38
|
+
print(e)
|
|
39
|
+
return False
|
synth_ai/utils/cli.py
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
from collections.abc import Sequence
|
|
2
|
-
from
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any, Callable, cast
|
|
3
4
|
|
|
4
5
|
import click
|
|
5
6
|
|
|
6
7
|
|
|
8
|
+
def prompt_choice(msg: str, choices: list[str]) -> str:
|
|
9
|
+
print(msg)
|
|
10
|
+
for i, label in enumerate(choices, start=1):
|
|
11
|
+
print(f" [{i}] {label}")
|
|
12
|
+
while True:
|
|
13
|
+
try:
|
|
14
|
+
choice = click.prompt(
|
|
15
|
+
"Select an option",
|
|
16
|
+
default=1,
|
|
17
|
+
type=int,
|
|
18
|
+
show_choices=False
|
|
19
|
+
)
|
|
20
|
+
except click.Abort:
|
|
21
|
+
raise
|
|
22
|
+
if 1 <= choice <= len(choices):
|
|
23
|
+
return choices[choice - 1]
|
|
24
|
+
print(f"Invalid selection. Enter a number between 1 and {len(choices)}")
|
|
25
|
+
|
|
26
|
+
|
|
7
27
|
class PromptedChoiceType(click.Choice):
|
|
8
28
|
"""`click.Choice` variant that reprompts with an interactive menu on failure.
|
|
9
29
|
|
|
@@ -61,9 +81,15 @@ class PromptedChoiceType(click.Choice):
|
|
|
61
81
|
for index, choice in enumerate(self.choices, 1):
|
|
62
82
|
click.echo(f" [{index}] {choice}")
|
|
63
83
|
while True:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
84
|
+
choice = click.prompt(
|
|
85
|
+
"Select an option",
|
|
86
|
+
default=1,
|
|
87
|
+
type=int,
|
|
88
|
+
show_choices=False
|
|
89
|
+
)
|
|
90
|
+
if 1 <= choice <= len(self.choices):
|
|
91
|
+
print('')
|
|
92
|
+
return cast(str, self.choices[choice - 1])
|
|
67
93
|
click.echo(f"Invalid selection for {arg_name}, please try again")
|
|
68
94
|
|
|
69
95
|
def _get_cmd_name(self, ctx: click.Context | None) -> str:
|
|
@@ -122,7 +148,125 @@ class PromptedChoiceOption(click.Option):
|
|
|
122
148
|
if isinstance(option_type, PromptedChoiceType):
|
|
123
149
|
return option_type._prompt_user(self, ctx)
|
|
124
150
|
return super().prompt_for_value(ctx)
|
|
125
|
-
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def prompt_for_path(
|
|
154
|
+
label: str,
|
|
155
|
+
*,
|
|
156
|
+
available_paths: Sequence[str | Path] | None = None,
|
|
157
|
+
file_type: str | None = None,
|
|
158
|
+
path_type: click.Path | None = None,
|
|
159
|
+
) -> Path:
|
|
160
|
+
"""Prompt for a filesystem path, optionally offering curated choices."""
|
|
161
|
+
|
|
162
|
+
def _normalize_suffix(ext: str | None) -> str | None:
|
|
163
|
+
if not ext:
|
|
164
|
+
return None
|
|
165
|
+
stripped = ext.strip()
|
|
166
|
+
if not stripped:
|
|
167
|
+
return None
|
|
168
|
+
if not stripped.startswith("."):
|
|
169
|
+
stripped = f".{stripped}"
|
|
170
|
+
return stripped.lower()
|
|
171
|
+
|
|
172
|
+
def _format_label(text: str) -> str:
|
|
173
|
+
return text.strip() or "path"
|
|
174
|
+
|
|
175
|
+
expected_suffix = _normalize_suffix(file_type)
|
|
176
|
+
prompt_label = _format_label(label)
|
|
177
|
+
|
|
178
|
+
path_type = path_type or click.Path(
|
|
179
|
+
exists=True,
|
|
180
|
+
dir_okay=False,
|
|
181
|
+
file_okay=True,
|
|
182
|
+
path_type=Path,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
candidates: list[str] = []
|
|
186
|
+
if available_paths:
|
|
187
|
+
seen: set[str] = set()
|
|
188
|
+
for entry in available_paths:
|
|
189
|
+
candidate = str(Path(entry))
|
|
190
|
+
suffix = Path(candidate).suffix.lower()
|
|
191
|
+
if candidate in seen:
|
|
192
|
+
continue
|
|
193
|
+
if expected_suffix and suffix != expected_suffix:
|
|
194
|
+
continue
|
|
195
|
+
seen.add(candidate)
|
|
196
|
+
candidates.append(candidate)
|
|
197
|
+
|
|
198
|
+
ctx = click.get_current_context(silent=True)
|
|
199
|
+
|
|
200
|
+
while True:
|
|
201
|
+
if candidates:
|
|
202
|
+
click.echo(f"\nPlease choose a {prompt_label}:")
|
|
203
|
+
for index, option in enumerate(candidates, 1):
|
|
204
|
+
click.echo(f" [{index}] {option}")
|
|
205
|
+
custom_index = len(candidates) + 1
|
|
206
|
+
click.echo(f" [{custom_index}] Enter a custom path")
|
|
207
|
+
|
|
208
|
+
selection = click.prompt("> ", type=int)
|
|
209
|
+
if 1 <= selection <= len(candidates):
|
|
210
|
+
raw_value = candidates[selection - 1]
|
|
211
|
+
elif selection == custom_index:
|
|
212
|
+
raw_value = click.prompt(prompt_label, type=path_type)
|
|
213
|
+
else:
|
|
214
|
+
click.echo("Invalid selection, please try again")
|
|
215
|
+
continue
|
|
216
|
+
else:
|
|
217
|
+
raw_value = click.prompt(prompt_label, type=path_type)
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
converted = path_type.convert(str(raw_value), None, ctx)
|
|
221
|
+
except click.BadParameter as exc:
|
|
222
|
+
click.echo(str(exc))
|
|
223
|
+
continue
|
|
224
|
+
|
|
225
|
+
result = converted if isinstance(converted, Path) else Path(converted)
|
|
226
|
+
if expected_suffix and result.suffix.lower() != expected_suffix:
|
|
227
|
+
click.echo(f"Expected a {expected_suffix} file. Received: {result}")
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
return result
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class PromptedPathOption(click.Option):
|
|
234
|
+
"""Option that prompts for a filesystem path when omitted."""
|
|
235
|
+
|
|
236
|
+
def __init__(
|
|
237
|
+
self,
|
|
238
|
+
*args: Any,
|
|
239
|
+
available_paths: Sequence[str | Path] | None = None,
|
|
240
|
+
file_type: str | None = None,
|
|
241
|
+
path_type: click.Path | None = None,
|
|
242
|
+
prompt_guard: Callable[[click.Context], bool] | None = None,
|
|
243
|
+
**kwargs: Any,
|
|
244
|
+
) -> None:
|
|
245
|
+
self._available_paths = available_paths
|
|
246
|
+
self._file_type = file_type
|
|
247
|
+
self._path_type = path_type
|
|
248
|
+
self._prompt_guard = prompt_guard
|
|
249
|
+
kwargs.setdefault("prompt", True)
|
|
250
|
+
kwargs.setdefault("prompt_required", True)
|
|
251
|
+
super().__init__(*args, **kwargs)
|
|
252
|
+
|
|
253
|
+
def prompt_for_value(self, ctx: click.Context) -> Any:
|
|
254
|
+
if not ctx:
|
|
255
|
+
return super().prompt_for_value(ctx)
|
|
256
|
+
if self._prompt_guard is not None:
|
|
257
|
+
try:
|
|
258
|
+
if not self._prompt_guard(ctx):
|
|
259
|
+
return None
|
|
260
|
+
except Exception:
|
|
261
|
+
return None
|
|
262
|
+
label = self.help or self.name or "path"
|
|
263
|
+
return prompt_for_path(
|
|
264
|
+
label,
|
|
265
|
+
available_paths=self._available_paths,
|
|
266
|
+
file_type=self._file_type,
|
|
267
|
+
path_type=self._path_type or getattr(self, "type", None),
|
|
268
|
+
)
|
|
269
|
+
|
|
126
270
|
|
|
127
271
|
def print_next_step(message: str, lines: Sequence[str]) -> None:
|
|
128
272
|
print(f"\n➡️ Next, {message}:")
|