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,882 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import ast
|
|
4
|
+
import contextlib
|
|
5
|
+
import hashlib
|
|
6
|
+
import importlib
|
|
7
|
+
import importlib.util
|
|
8
|
+
import inspect
|
|
9
|
+
import os
|
|
10
|
+
import sys
|
|
11
|
+
import types
|
|
12
|
+
from collections.abc import Callable, Iterable, Iterator, Sequence
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import click
|
|
19
|
+
from click.exceptions import Abort
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
_task_apps_module = importlib.import_module("synth_ai.task.apps")
|
|
23
|
+
ModalDeploymentConfig = _task_apps_module.ModalDeploymentConfig
|
|
24
|
+
TaskAppConfig = _task_apps_module.TaskAppConfig
|
|
25
|
+
TaskAppEntry = _task_apps_module.TaskAppEntry
|
|
26
|
+
registry = _task_apps_module.registry
|
|
27
|
+
except Exception:
|
|
28
|
+
class _UnavailableTaskAppType: # pragma: no cover - used when optional deps missing
|
|
29
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
30
|
+
raise RuntimeError("Task app registry is unavailable in this environment")
|
|
31
|
+
|
|
32
|
+
ModalDeploymentConfig = TaskAppConfig = TaskAppEntry = _UnavailableTaskAppType # type: ignore[assignment]
|
|
33
|
+
registry: dict[str, Any] = {}
|
|
34
|
+
|
|
35
|
+
REPO_ROOT = Path(__file__).resolve().parents[3]
|
|
36
|
+
|
|
37
|
+
DEFAULT_IGNORE_DIRS = {
|
|
38
|
+
".git",
|
|
39
|
+
"__pycache__",
|
|
40
|
+
"node_modules",
|
|
41
|
+
"venv",
|
|
42
|
+
".venv",
|
|
43
|
+
"build",
|
|
44
|
+
"dist",
|
|
45
|
+
".mypy_cache",
|
|
46
|
+
".pytest_cache",
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
DEFAULT_SEARCH_RELATIVE = (
|
|
50
|
+
Path("."),
|
|
51
|
+
Path("examples"),
|
|
52
|
+
Path("synth_ai"),
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class AppChoice:
|
|
58
|
+
app_id: str
|
|
59
|
+
label: str
|
|
60
|
+
path: Path
|
|
61
|
+
source: str
|
|
62
|
+
description: str | None = None
|
|
63
|
+
aliases: tuple[str, ...] = ()
|
|
64
|
+
entry: TaskAppEntry | None = None
|
|
65
|
+
entry_loader: Callable[[], TaskAppEntry] | None = None
|
|
66
|
+
modal_script: Path | None = None
|
|
67
|
+
lineno: int | None = None
|
|
68
|
+
|
|
69
|
+
def ensure_entry(self) -> TaskAppEntry:
|
|
70
|
+
if self.entry is not None:
|
|
71
|
+
return self.entry
|
|
72
|
+
if self.entry_loader is None:
|
|
73
|
+
raise click.ClickException(f"Unable to load task app '{self.app_id}' from {self.path}")
|
|
74
|
+
entry = self.entry_loader()
|
|
75
|
+
self.entry = entry
|
|
76
|
+
return entry
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _temporary_sys_path(paths: Sequence[Path]):
|
|
80
|
+
"""Context manager to prepend entries to sys.path temporarily."""
|
|
81
|
+
|
|
82
|
+
@contextlib.contextmanager
|
|
83
|
+
def _manager() -> Iterator[None]:
|
|
84
|
+
added: list[str] = []
|
|
85
|
+
for p in paths:
|
|
86
|
+
try:
|
|
87
|
+
resolved = str(p.resolve())
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
if resolved in sys.path:
|
|
91
|
+
continue
|
|
92
|
+
sys.path.insert(0, resolved)
|
|
93
|
+
added.append(resolved)
|
|
94
|
+
try:
|
|
95
|
+
yield None
|
|
96
|
+
finally:
|
|
97
|
+
for entry in added:
|
|
98
|
+
with contextlib.suppress(ValueError):
|
|
99
|
+
sys.path.remove(entry)
|
|
100
|
+
|
|
101
|
+
return _manager()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _possible_module_names(path: Path, module_search_roots: Sequence[Path]) -> list[tuple[str, Path]]:
|
|
105
|
+
candidates: list[tuple[str, Path]] = []
|
|
106
|
+
for root in module_search_roots:
|
|
107
|
+
try:
|
|
108
|
+
resolved_root = root.resolve()
|
|
109
|
+
except Exception:
|
|
110
|
+
continue
|
|
111
|
+
if not resolved_root.exists():
|
|
112
|
+
continue
|
|
113
|
+
with contextlib.suppress(ValueError):
|
|
114
|
+
relative = path.resolve().relative_to(resolved_root)
|
|
115
|
+
stem = relative.with_suffix("")
|
|
116
|
+
parts = list(stem.parts)
|
|
117
|
+
if not parts:
|
|
118
|
+
continue
|
|
119
|
+
module_name = ".".join(parts)
|
|
120
|
+
if module_name:
|
|
121
|
+
candidates.append((module_name, resolved_root))
|
|
122
|
+
return candidates
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _ensure_parent_namespace(module_name: str, search_root: Path) -> None:
|
|
126
|
+
"""Ensure namespace packages exist for dotted module names."""
|
|
127
|
+
|
|
128
|
+
parts = module_name.split(".")
|
|
129
|
+
for depth in range(1, len(parts)):
|
|
130
|
+
parent_name = ".".join(parts[:depth])
|
|
131
|
+
if parent_name in sys.modules:
|
|
132
|
+
continue
|
|
133
|
+
parent_module = types.ModuleType(parent_name)
|
|
134
|
+
candidate_dir = search_root.joinpath(*parts[:depth])
|
|
135
|
+
try:
|
|
136
|
+
resolved = candidate_dir.resolve()
|
|
137
|
+
except Exception:
|
|
138
|
+
resolved = search_root.resolve()
|
|
139
|
+
parent_module.__path__ = [str(resolved)] # type: ignore[attr-defined]
|
|
140
|
+
sys.modules[parent_name] = parent_module
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _should_ignore_path(path: Path) -> bool:
|
|
144
|
+
return any(part in DEFAULT_IGNORE_DIRS for part in path.parts)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _candidate_search_roots() -> list[Path]:
|
|
148
|
+
roots: list[Path] = []
|
|
149
|
+
|
|
150
|
+
try:
|
|
151
|
+
demo_module = importlib.import_module("synth_ai.demos.demo_task_apps.core")
|
|
152
|
+
except Exception:
|
|
153
|
+
demo_module = None
|
|
154
|
+
if demo_module:
|
|
155
|
+
load_demo_dir = getattr(demo_module, "load_demo_dir", None)
|
|
156
|
+
if callable(load_demo_dir):
|
|
157
|
+
try:
|
|
158
|
+
demo_dir = load_demo_dir()
|
|
159
|
+
except Exception:
|
|
160
|
+
demo_dir = None
|
|
161
|
+
if demo_dir:
|
|
162
|
+
demo_path = Path(demo_dir)
|
|
163
|
+
if demo_path.exists() and demo_path.is_dir():
|
|
164
|
+
roots.append(demo_path.resolve())
|
|
165
|
+
|
|
166
|
+
env_paths = os.environ.get("SYNTH_TASK_APP_SEARCH_PATH")
|
|
167
|
+
if env_paths:
|
|
168
|
+
for chunk in env_paths.split(os.pathsep):
|
|
169
|
+
if chunk:
|
|
170
|
+
roots.append(Path(chunk).expanduser())
|
|
171
|
+
|
|
172
|
+
cwd = Path.cwd().resolve()
|
|
173
|
+
roots.append(cwd)
|
|
174
|
+
|
|
175
|
+
for rel in DEFAULT_SEARCH_RELATIVE:
|
|
176
|
+
try:
|
|
177
|
+
candidate = (cwd / rel).resolve()
|
|
178
|
+
except Exception:
|
|
179
|
+
continue
|
|
180
|
+
roots.append(candidate)
|
|
181
|
+
|
|
182
|
+
seen: set[Path] = set()
|
|
183
|
+
ordered: list[Path] = []
|
|
184
|
+
for root in roots:
|
|
185
|
+
try:
|
|
186
|
+
resolved = root.resolve()
|
|
187
|
+
except Exception:
|
|
188
|
+
continue
|
|
189
|
+
if resolved in seen or not resolved.exists():
|
|
190
|
+
continue
|
|
191
|
+
seen.add(resolved)
|
|
192
|
+
ordered.append(resolved)
|
|
193
|
+
return ordered
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _eval_config_sort_key(path: Path) -> tuple[int, int, int, str]:
|
|
197
|
+
name = path.name.lower()
|
|
198
|
+
parent_names = {p.name.lower() for p in path.parents}
|
|
199
|
+
in_configs = 0 if "configs" in parent_names else 1
|
|
200
|
+
in_examples = 0 if "examples" in parent_names else 1
|
|
201
|
+
starts_eval = 0 if name.startswith("eval") else 1
|
|
202
|
+
return (in_configs, in_examples, starts_eval, str(path))
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def discover_eval_config_paths() -> list[Path]:
|
|
206
|
+
candidates: list[Path] = []
|
|
207
|
+
seen: set[Path] = set()
|
|
208
|
+
for root in _candidate_search_roots():
|
|
209
|
+
if not root.exists() or not root.is_dir():
|
|
210
|
+
continue
|
|
211
|
+
try:
|
|
212
|
+
root = root.resolve()
|
|
213
|
+
except Exception:
|
|
214
|
+
continue
|
|
215
|
+
for path in root.rglob("*.toml"):
|
|
216
|
+
if not path.is_file() or _should_ignore_path(path):
|
|
217
|
+
continue
|
|
218
|
+
name_lower = path.name.lower()
|
|
219
|
+
if "eval" not in name_lower and "evaluation" not in name_lower:
|
|
220
|
+
continue
|
|
221
|
+
try:
|
|
222
|
+
resolved = path.resolve()
|
|
223
|
+
except Exception:
|
|
224
|
+
continue
|
|
225
|
+
if resolved in seen:
|
|
226
|
+
continue
|
|
227
|
+
seen.add(resolved)
|
|
228
|
+
candidates.append(resolved)
|
|
229
|
+
candidates.sort(key=_eval_config_sort_key)
|
|
230
|
+
return candidates
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _TaskAppConfigVisitor(ast.NodeVisitor):
|
|
234
|
+
def __init__(self) -> None:
|
|
235
|
+
self.matches: list[tuple[str, int]] = []
|
|
236
|
+
|
|
237
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
238
|
+
if _is_task_app_config_call(node):
|
|
239
|
+
app_id = _extract_app_id(node)
|
|
240
|
+
if app_id:
|
|
241
|
+
self.matches.append((app_id, getattr(node, "lineno", 0)))
|
|
242
|
+
elif _is_register_task_app_call(node):
|
|
243
|
+
app_id = _extract_register_app_id(node)
|
|
244
|
+
if app_id:
|
|
245
|
+
self.matches.append((app_id, getattr(node, "lineno", 0)))
|
|
246
|
+
self.generic_visit(node)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _ModalAppVisitor(ast.NodeVisitor):
|
|
250
|
+
def __init__(self) -> None:
|
|
251
|
+
self.app_aliases: set[str] = set()
|
|
252
|
+
self.modal_aliases: set[str] = set()
|
|
253
|
+
self.matches: list[tuple[str, int]] = []
|
|
254
|
+
|
|
255
|
+
def visit_ImportFrom(self, node: ast.ImportFrom) -> None: # noqa: D401
|
|
256
|
+
if node.module == "modal":
|
|
257
|
+
for alias in node.names:
|
|
258
|
+
if alias.name == "App":
|
|
259
|
+
self.app_aliases.add(alias.asname or alias.name)
|
|
260
|
+
self.generic_visit(node)
|
|
261
|
+
|
|
262
|
+
def visit_Import(self, node: ast.Import) -> None: # noqa: D401
|
|
263
|
+
for alias in node.names:
|
|
264
|
+
if alias.name == "modal":
|
|
265
|
+
self.modal_aliases.add(alias.asname or alias.name)
|
|
266
|
+
self.generic_visit(node)
|
|
267
|
+
|
|
268
|
+
def visit_Call(self, node: ast.Call) -> None: # noqa: D401
|
|
269
|
+
func = node.func
|
|
270
|
+
if isinstance(func, ast.Name) and func.id in self.app_aliases:
|
|
271
|
+
name = _extract_modal_app_name(node)
|
|
272
|
+
if name:
|
|
273
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
274
|
+
elif isinstance(func, ast.Attribute):
|
|
275
|
+
if (
|
|
276
|
+
isinstance(func.value, ast.Name)
|
|
277
|
+
and func.value.id in self.modal_aliases
|
|
278
|
+
and func.attr == "App"
|
|
279
|
+
):
|
|
280
|
+
name = _extract_modal_app_name(node)
|
|
281
|
+
if name:
|
|
282
|
+
self.matches.append((name, getattr(node, "lineno", 0)))
|
|
283
|
+
self.generic_visit(node)
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _is_task_app_config_call(node: ast.Call) -> bool:
|
|
287
|
+
func = node.func
|
|
288
|
+
return (isinstance(func, ast.Name) and func.id == "TaskAppConfig") or (
|
|
289
|
+
isinstance(func, ast.Attribute) and func.attr == "TaskAppConfig"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def _extract_app_id(node: ast.Call) -> str | None:
|
|
294
|
+
for kw in node.keywords:
|
|
295
|
+
if (
|
|
296
|
+
kw.arg == "app_id"
|
|
297
|
+
and isinstance(kw.value, ast.Constant)
|
|
298
|
+
and isinstance(kw.value.value, str)
|
|
299
|
+
):
|
|
300
|
+
return kw.value.value
|
|
301
|
+
if node.args:
|
|
302
|
+
first = node.args[0]
|
|
303
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
304
|
+
return first.value
|
|
305
|
+
return None
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _is_register_task_app_call(node: ast.Call) -> bool:
|
|
309
|
+
func = node.func
|
|
310
|
+
return (isinstance(func, ast.Name) and func.id == "register_task_app") or (
|
|
311
|
+
isinstance(func, ast.Attribute) and func.attr == "register_task_app"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _extract_register_app_id(node: ast.Call) -> str | None:
|
|
316
|
+
for kw in node.keywords:
|
|
317
|
+
if kw.arg == "entry" and isinstance(kw.value, ast.Call):
|
|
318
|
+
entry_call = kw.value
|
|
319
|
+
if isinstance(entry_call.func, ast.Name) and entry_call.func.id == "TaskAppEntry":
|
|
320
|
+
for entry_kw in entry_call.keywords:
|
|
321
|
+
if (
|
|
322
|
+
entry_kw.arg == "app_id"
|
|
323
|
+
and isinstance(entry_kw.value, ast.Constant)
|
|
324
|
+
and isinstance(entry_kw.value.value, str)
|
|
325
|
+
):
|
|
326
|
+
return entry_kw.value.value
|
|
327
|
+
return None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _extract_modal_app_name(node: ast.Call) -> str | None:
|
|
331
|
+
if node.args:
|
|
332
|
+
first = node.args[0]
|
|
333
|
+
if isinstance(first, ast.Constant) and isinstance(first.value, str):
|
|
334
|
+
return first.value
|
|
335
|
+
for kw in node.keywords:
|
|
336
|
+
if kw.arg == "name" and isinstance(kw.value, ast.Constant) and isinstance(kw.value.value, str):
|
|
337
|
+
return kw.value.value
|
|
338
|
+
return None
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _collect_registered_choices() -> list[AppChoice]:
|
|
342
|
+
result: list[AppChoice] = []
|
|
343
|
+
for entry in registry.list():
|
|
344
|
+
module_name = entry.config_factory.__module__
|
|
345
|
+
module = sys.modules.get(module_name)
|
|
346
|
+
if module is None:
|
|
347
|
+
module = importlib.import_module(module_name)
|
|
348
|
+
module_file = getattr(module, "__file__", None)
|
|
349
|
+
path = Path(module_file).resolve() if module_file else REPO_ROOT
|
|
350
|
+
result.append(
|
|
351
|
+
AppChoice(
|
|
352
|
+
app_id=entry.app_id,
|
|
353
|
+
label=entry.app_id,
|
|
354
|
+
path=path,
|
|
355
|
+
source="registered",
|
|
356
|
+
description=entry.description,
|
|
357
|
+
aliases=tuple(entry.aliases),
|
|
358
|
+
entry=entry,
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
return result
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _collect_scanned_task_configs() -> list[AppChoice]:
|
|
365
|
+
results: list[AppChoice] = []
|
|
366
|
+
seen: set[tuple[str, Path]] = set()
|
|
367
|
+
for root in _candidate_search_roots():
|
|
368
|
+
if not root.exists() or not root.is_dir():
|
|
369
|
+
continue
|
|
370
|
+
try:
|
|
371
|
+
root_resolved = root.resolve()
|
|
372
|
+
except Exception:
|
|
373
|
+
continue
|
|
374
|
+
for path in root.rglob("*.py"):
|
|
375
|
+
if not path.is_file() or _should_ignore_path(path):
|
|
376
|
+
continue
|
|
377
|
+
try:
|
|
378
|
+
source = path.read_text(encoding="utf-8")
|
|
379
|
+
tree = ast.parse(source, filename=str(path))
|
|
380
|
+
except Exception:
|
|
381
|
+
continue
|
|
382
|
+
visitor = _TaskAppConfigVisitor()
|
|
383
|
+
visitor.visit(tree)
|
|
384
|
+
for app_id, lineno in visitor.matches:
|
|
385
|
+
key = (app_id, path.resolve())
|
|
386
|
+
if key in seen:
|
|
387
|
+
continue
|
|
388
|
+
seen.add(key)
|
|
389
|
+
|
|
390
|
+
def _loader(p: Path = path.resolve(), a: str = app_id, roots: tuple[Path, ...] = (root_resolved,)):
|
|
391
|
+
return _load_entry_from_path(p, a, module_search_roots=roots)
|
|
392
|
+
|
|
393
|
+
results.append(
|
|
394
|
+
AppChoice(
|
|
395
|
+
app_id=app_id,
|
|
396
|
+
label=app_id,
|
|
397
|
+
path=path.resolve(),
|
|
398
|
+
source="discovered",
|
|
399
|
+
description=f"TaskAppConfig in {path.name} (line {lineno})",
|
|
400
|
+
entry_loader=_loader,
|
|
401
|
+
lineno=lineno,
|
|
402
|
+
)
|
|
403
|
+
)
|
|
404
|
+
return results
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _collect_modal_scripts() -> list[AppChoice]:
|
|
408
|
+
results: list[AppChoice] = []
|
|
409
|
+
seen: set[tuple[str, Path]] = set()
|
|
410
|
+
for root in _candidate_search_roots():
|
|
411
|
+
if not root.exists() or not root.is_dir():
|
|
412
|
+
continue
|
|
413
|
+
for path in root.rglob("*.py"):
|
|
414
|
+
if not path.is_file() or _should_ignore_path(path):
|
|
415
|
+
continue
|
|
416
|
+
try:
|
|
417
|
+
source = path.read_text(encoding="utf-8")
|
|
418
|
+
tree = ast.parse(source, filename=str(path))
|
|
419
|
+
except Exception:
|
|
420
|
+
continue
|
|
421
|
+
visitor = _ModalAppVisitor()
|
|
422
|
+
visitor.visit(tree)
|
|
423
|
+
for app_name, lineno in visitor.matches:
|
|
424
|
+
key = (app_name, path.resolve())
|
|
425
|
+
if key in seen:
|
|
426
|
+
continue
|
|
427
|
+
seen.add(key)
|
|
428
|
+
results.append(
|
|
429
|
+
AppChoice(
|
|
430
|
+
app_id=app_name,
|
|
431
|
+
label=app_name,
|
|
432
|
+
path=path.resolve(),
|
|
433
|
+
source="modal-script",
|
|
434
|
+
description=f"Modal App '{app_name}' in {path.name} (line {lineno})",
|
|
435
|
+
modal_script=path.resolve(),
|
|
436
|
+
lineno=lineno,
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
return results
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _app_choice_sort_key(choice: AppChoice) -> tuple[int, int, int, int, int, str, str]:
|
|
443
|
+
demo_rank = 1
|
|
444
|
+
try:
|
|
445
|
+
demo_module = importlib.import_module("synth_ai.demos.demo_task_apps.core")
|
|
446
|
+
except Exception:
|
|
447
|
+
demo_module = None
|
|
448
|
+
if demo_module:
|
|
449
|
+
load_demo_dir = getattr(demo_module, "load_demo_dir", None)
|
|
450
|
+
if callable(load_demo_dir):
|
|
451
|
+
try:
|
|
452
|
+
demo_dir = load_demo_dir()
|
|
453
|
+
except Exception:
|
|
454
|
+
demo_dir = None
|
|
455
|
+
if demo_dir:
|
|
456
|
+
demo_path = Path(demo_dir).resolve()
|
|
457
|
+
if choice.path.is_relative_to(demo_path):
|
|
458
|
+
demo_rank = 0
|
|
459
|
+
|
|
460
|
+
cwd_rank = 1
|
|
461
|
+
try:
|
|
462
|
+
cwd = Path.cwd().resolve()
|
|
463
|
+
if choice.path.is_relative_to(cwd):
|
|
464
|
+
try:
|
|
465
|
+
rel_path = choice.path.relative_to(cwd)
|
|
466
|
+
if len(rel_path.parts) <= 2:
|
|
467
|
+
cwd_rank = 0
|
|
468
|
+
except Exception:
|
|
469
|
+
pass
|
|
470
|
+
except Exception:
|
|
471
|
+
pass
|
|
472
|
+
|
|
473
|
+
modal_rank = 1 if choice.modal_script else 0
|
|
474
|
+
name = choice.path.name.lower()
|
|
475
|
+
if name.endswith("_task_app.py") or name.endswith("task_app.py"):
|
|
476
|
+
file_rank = 0
|
|
477
|
+
elif name.endswith("_app.py") or "task_app" in name:
|
|
478
|
+
file_rank = 1
|
|
479
|
+
elif name.endswith(".py"):
|
|
480
|
+
file_rank = 2
|
|
481
|
+
else:
|
|
482
|
+
file_rank = 3
|
|
483
|
+
|
|
484
|
+
directory_rank = 0 if choice.path.parent.name.lower() in {"task_app", "task_apps"} else 1
|
|
485
|
+
|
|
486
|
+
return (
|
|
487
|
+
demo_rank,
|
|
488
|
+
cwd_rank,
|
|
489
|
+
modal_rank,
|
|
490
|
+
file_rank,
|
|
491
|
+
directory_rank,
|
|
492
|
+
choice.app_id,
|
|
493
|
+
str(choice.path),
|
|
494
|
+
)
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def _choice_matches_identifier(choice: AppChoice, identifier: str) -> bool:
|
|
498
|
+
ident = identifier.strip()
|
|
499
|
+
if not ident:
|
|
500
|
+
return False
|
|
501
|
+
return ident == choice.app_id or ident == choice.label or ident in choice.aliases
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _choice_has_modal_support(choice: AppChoice) -> bool:
|
|
505
|
+
if choice.modal_script:
|
|
506
|
+
return True
|
|
507
|
+
try:
|
|
508
|
+
entry = choice.ensure_entry()
|
|
509
|
+
except click.ClickException:
|
|
510
|
+
return _has_modal_support_in_file(choice.path)
|
|
511
|
+
return entry.modal is not None
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _choice_has_local_support(choice: AppChoice) -> bool:
|
|
515
|
+
if choice.modal_script:
|
|
516
|
+
return False
|
|
517
|
+
try:
|
|
518
|
+
choice.ensure_entry()
|
|
519
|
+
except click.ClickException:
|
|
520
|
+
return False
|
|
521
|
+
return True
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _has_modal_support_in_file(path: Path) -> bool:
|
|
525
|
+
try:
|
|
526
|
+
source = path.read_text(encoding="utf-8")
|
|
527
|
+
tree = ast.parse(source, filename=str(path))
|
|
528
|
+
except Exception:
|
|
529
|
+
return False
|
|
530
|
+
|
|
531
|
+
for node in ast.walk(tree):
|
|
532
|
+
if isinstance(node, ast.Call) and _is_register_task_app_call(node):
|
|
533
|
+
for kw in node.keywords:
|
|
534
|
+
if kw.arg == "entry" and isinstance(kw.value, ast.Call):
|
|
535
|
+
entry_call = kw.value
|
|
536
|
+
if (
|
|
537
|
+
isinstance(entry_call.func, ast.Name)
|
|
538
|
+
and entry_call.func.id == "TaskAppEntry"
|
|
539
|
+
):
|
|
540
|
+
for entry_kw in entry_call.keywords:
|
|
541
|
+
if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
|
|
542
|
+
modal_call = entry_kw.value
|
|
543
|
+
if (
|
|
544
|
+
isinstance(modal_call.func, ast.Name)
|
|
545
|
+
and modal_call.func.id == "ModalDeploymentConfig"
|
|
546
|
+
):
|
|
547
|
+
return True
|
|
548
|
+
return False
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _extract_modal_config_from_file(path: Path) -> ModalDeploymentConfig | None:
|
|
552
|
+
try:
|
|
553
|
+
source = path.read_text(encoding="utf-8")
|
|
554
|
+
tree = ast.parse(source, filename=str(path))
|
|
555
|
+
except Exception:
|
|
556
|
+
return None
|
|
557
|
+
|
|
558
|
+
for node in ast.walk(tree):
|
|
559
|
+
if isinstance(node, ast.Call) and _is_register_task_app_call(node):
|
|
560
|
+
for kw in node.keywords:
|
|
561
|
+
if kw.arg == "entry" and isinstance(kw.value, ast.Call):
|
|
562
|
+
entry_call = kw.value
|
|
563
|
+
if (
|
|
564
|
+
isinstance(entry_call.func, ast.Name)
|
|
565
|
+
and entry_call.func.id == "TaskAppEntry"
|
|
566
|
+
):
|
|
567
|
+
for entry_kw in entry_call.keywords:
|
|
568
|
+
if entry_kw.arg == "modal" and isinstance(entry_kw.value, ast.Call):
|
|
569
|
+
modal_call = entry_kw.value
|
|
570
|
+
if (
|
|
571
|
+
isinstance(modal_call.func, ast.Name)
|
|
572
|
+
and modal_call.func.id == "ModalDeploymentConfig"
|
|
573
|
+
):
|
|
574
|
+
return _build_modal_config_from_ast(modal_call)
|
|
575
|
+
return None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _build_modal_config_from_ast(modal_call: ast.Call) -> ModalDeploymentConfig | None:
|
|
579
|
+
try:
|
|
580
|
+
kwargs = {}
|
|
581
|
+
for kw in modal_call.keywords:
|
|
582
|
+
if kw.arg and isinstance(kw.value, ast.Constant):
|
|
583
|
+
kwargs[kw.arg] = kw.value.value
|
|
584
|
+
elif kw.arg == "pip_packages" and isinstance(kw.value, ast.List | ast.Tuple):
|
|
585
|
+
packages = []
|
|
586
|
+
for elt in kw.value.elts:
|
|
587
|
+
if isinstance(elt, ast.Constant):
|
|
588
|
+
packages.append(elt.value)
|
|
589
|
+
kwargs[kw.arg] = tuple(packages)
|
|
590
|
+
elif kw.arg == "extra_local_dirs" and isinstance(kw.value, ast.List | ast.Tuple):
|
|
591
|
+
dirs = []
|
|
592
|
+
for elt in kw.value.elts:
|
|
593
|
+
if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
|
|
594
|
+
src = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
|
|
595
|
+
dst = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
|
|
596
|
+
if src and dst:
|
|
597
|
+
dirs.append((src, dst))
|
|
598
|
+
kwargs[kw.arg] = tuple(dirs)
|
|
599
|
+
elif kw.arg == "secret_names" and isinstance(kw.value, ast.List | ast.Tuple):
|
|
600
|
+
secrets = []
|
|
601
|
+
for elt in kw.value.elts:
|
|
602
|
+
if isinstance(elt, ast.Constant):
|
|
603
|
+
secrets.append(elt.value)
|
|
604
|
+
kwargs[kw.arg] = tuple(secrets)
|
|
605
|
+
elif kw.arg == "volume_mounts" and isinstance(kw.value, ast.List | ast.Tuple):
|
|
606
|
+
mounts = []
|
|
607
|
+
for elt in kw.value.elts:
|
|
608
|
+
if isinstance(elt, ast.List | ast.Tuple) and len(elt.elts) == 2:
|
|
609
|
+
name = elt.elts[0].value if isinstance(elt.elts[0], ast.Constant) else None
|
|
610
|
+
mount = elt.elts[1].value if isinstance(elt.elts[1], ast.Constant) else None
|
|
611
|
+
if name and mount:
|
|
612
|
+
mounts.append((name, mount))
|
|
613
|
+
kwargs[kw.arg] = tuple(mounts)
|
|
614
|
+
return ModalDeploymentConfig(**kwargs)
|
|
615
|
+
except Exception:
|
|
616
|
+
return None
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def _format_choice(choice: AppChoice, index: int | None = None) -> str:
|
|
620
|
+
prefix = f"[{index}] " if index is not None else ""
|
|
621
|
+
try:
|
|
622
|
+
mtime = choice.path.stat().st_mtime
|
|
623
|
+
modified_str = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
|
624
|
+
details = f"Modified: {modified_str}"
|
|
625
|
+
except Exception:
|
|
626
|
+
details = choice.description or "No timestamp available"
|
|
627
|
+
return f"{prefix}{choice.app_id} ({choice.source}) – {details}"
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def _prompt_user_for_choice(choices: list[AppChoice]) -> AppChoice:
|
|
631
|
+
click.echo("Select a task app:")
|
|
632
|
+
for idx, choice in enumerate(choices, start=1):
|
|
633
|
+
click.echo(_format_choice(choice, idx))
|
|
634
|
+
try:
|
|
635
|
+
response = click.prompt("Enter choice", default="1", type=str).strip() or "1"
|
|
636
|
+
except (Abort, EOFError, KeyboardInterrupt) as exc:
|
|
637
|
+
raise click.ClickException("Task app selection cancelled by user") from exc
|
|
638
|
+
if not response.isdigit():
|
|
639
|
+
raise click.ClickException("Selection must be a number")
|
|
640
|
+
index = int(response)
|
|
641
|
+
if not 1 <= index <= len(choices):
|
|
642
|
+
raise click.ClickException("Selection out of range")
|
|
643
|
+
return choices[index - 1]
|
|
644
|
+
|
|
645
|
+
|
|
646
|
+
def _collect_task_app_choices() -> list[AppChoice]:
|
|
647
|
+
registry.clear()
|
|
648
|
+
choices: list[AppChoice] = []
|
|
649
|
+
with contextlib.suppress(Exception):
|
|
650
|
+
importlib.import_module("synth_ai.demos.demo_task_apps")
|
|
651
|
+
choices.extend(_collect_registered_choices())
|
|
652
|
+
choices.extend(_collect_scanned_task_configs())
|
|
653
|
+
choices.extend(_collect_modal_scripts())
|
|
654
|
+
|
|
655
|
+
unique: dict[tuple[str, Path], AppChoice] = {}
|
|
656
|
+
ordered: list[AppChoice] = []
|
|
657
|
+
for choice in choices:
|
|
658
|
+
key = (choice.app_id, choice.path.resolve())
|
|
659
|
+
if key in unique:
|
|
660
|
+
existing = unique[key]
|
|
661
|
+
if existing.source == "registered" and choice.source != "registered":
|
|
662
|
+
continue
|
|
663
|
+
if choice.source == "registered" and existing.source != "registered":
|
|
664
|
+
unique[key] = choice
|
|
665
|
+
idx = ordered.index(existing)
|
|
666
|
+
ordered[idx] = choice
|
|
667
|
+
continue
|
|
668
|
+
unique[key] = choice
|
|
669
|
+
ordered.append(choice)
|
|
670
|
+
ordered.sort(key=_app_choice_sort_key)
|
|
671
|
+
return ordered
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def select_app_choice(app_id: str | None, purpose: str) -> AppChoice:
|
|
675
|
+
choices = _collect_task_app_choices()
|
|
676
|
+
if purpose in {"serve", "eval"}:
|
|
677
|
+
filtered = [c for c in choices if _choice_has_local_support(c)]
|
|
678
|
+
elif purpose in {"deploy", "modal-serve"}:
|
|
679
|
+
filtered = [c for c in choices if _choice_has_modal_support(c)]
|
|
680
|
+
else:
|
|
681
|
+
filtered = choices
|
|
682
|
+
|
|
683
|
+
if not filtered:
|
|
684
|
+
raise click.ClickException("No task apps discovered for this command.")
|
|
685
|
+
|
|
686
|
+
if app_id:
|
|
687
|
+
matches = [c for c in filtered if _choice_matches_identifier(c, app_id)]
|
|
688
|
+
if not matches:
|
|
689
|
+
available = ", ".join(sorted({c.app_id for c in filtered}))
|
|
690
|
+
raise click.ClickException(f"Task app '{app_id}' not found. Available: {available}")
|
|
691
|
+
if len(matches) == 1:
|
|
692
|
+
return matches[0]
|
|
693
|
+
if purpose in {"deploy", "modal-serve"}:
|
|
694
|
+
modal_matches = [c for c in matches if _choice_has_modal_support(c)]
|
|
695
|
+
if len(modal_matches) == 1:
|
|
696
|
+
return modal_matches[0]
|
|
697
|
+
if modal_matches:
|
|
698
|
+
matches = modal_matches
|
|
699
|
+
filtered = matches
|
|
700
|
+
|
|
701
|
+
filtered.sort(key=_app_choice_sort_key)
|
|
702
|
+
if len(filtered) == 1:
|
|
703
|
+
choice = filtered[0]
|
|
704
|
+
click.echo(_format_choice(choice))
|
|
705
|
+
return choice
|
|
706
|
+
return _prompt_user_for_choice(filtered)
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def _import_task_app_module(
|
|
710
|
+
resolved: Path,
|
|
711
|
+
module_name: str,
|
|
712
|
+
*,
|
|
713
|
+
namespace_root: Path | None,
|
|
714
|
+
sys_path_roots: Sequence[Path],
|
|
715
|
+
ensure_namespace: bool = True,
|
|
716
|
+
) -> types.ModuleType:
|
|
717
|
+
spec = importlib.util.spec_from_file_location(module_name, str(resolved))
|
|
718
|
+
if spec is None or spec.loader is None:
|
|
719
|
+
raise click.ClickException(f"Unable to load Python module from {resolved}")
|
|
720
|
+
|
|
721
|
+
module = importlib.util.module_from_spec(spec)
|
|
722
|
+
sys.modules[module_name] = module
|
|
723
|
+
|
|
724
|
+
with _temporary_sys_path(sys_path_roots):
|
|
725
|
+
if ensure_namespace and namespace_root is not None and "." in module_name:
|
|
726
|
+
_ensure_parent_namespace(module_name, namespace_root)
|
|
727
|
+
|
|
728
|
+
registry.clear()
|
|
729
|
+
|
|
730
|
+
try:
|
|
731
|
+
spec.loader.exec_module(module)
|
|
732
|
+
except Exception:
|
|
733
|
+
sys.modules.pop(module_name, None)
|
|
734
|
+
raise
|
|
735
|
+
|
|
736
|
+
return module
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _load_entry_from_path(
|
|
740
|
+
path: Path, app_id: str, module_search_roots: Sequence[Path] | None = None
|
|
741
|
+
) -> TaskAppEntry:
|
|
742
|
+
resolved = path.resolve()
|
|
743
|
+
search_roots: list[Path] = []
|
|
744
|
+
seen_roots: set[Path] = set()
|
|
745
|
+
|
|
746
|
+
def _append_root(candidate: Path) -> None:
|
|
747
|
+
try:
|
|
748
|
+
resolved_root = candidate.resolve()
|
|
749
|
+
except Exception:
|
|
750
|
+
return
|
|
751
|
+
if resolved_root in seen_roots:
|
|
752
|
+
return
|
|
753
|
+
seen_roots.add(resolved_root)
|
|
754
|
+
search_roots.append(resolved_root)
|
|
755
|
+
|
|
756
|
+
for root in module_search_roots or []:
|
|
757
|
+
_append_root(root)
|
|
758
|
+
_append_root(resolved.parent)
|
|
759
|
+
_append_root(REPO_ROOT)
|
|
760
|
+
|
|
761
|
+
last_error: Exception | None = None
|
|
762
|
+
module: types.ModuleType | None = None
|
|
763
|
+
|
|
764
|
+
for module_name, namespace_root in _possible_module_names(resolved, search_roots):
|
|
765
|
+
try:
|
|
766
|
+
module = _import_task_app_module(
|
|
767
|
+
resolved,
|
|
768
|
+
module_name,
|
|
769
|
+
namespace_root=namespace_root,
|
|
770
|
+
sys_path_roots=search_roots,
|
|
771
|
+
ensure_namespace=True,
|
|
772
|
+
)
|
|
773
|
+
break
|
|
774
|
+
except Exception as exc:
|
|
775
|
+
last_error = exc
|
|
776
|
+
continue
|
|
777
|
+
|
|
778
|
+
if module is None:
|
|
779
|
+
hashed_name = f"_synth_task_app_{hashlib.md5(str(resolved).encode(), usedforsecurity=False).hexdigest()}"
|
|
780
|
+
try:
|
|
781
|
+
module = _import_task_app_module(
|
|
782
|
+
resolved,
|
|
783
|
+
hashed_name,
|
|
784
|
+
namespace_root=None,
|
|
785
|
+
sys_path_roots=search_roots,
|
|
786
|
+
ensure_namespace=False,
|
|
787
|
+
)
|
|
788
|
+
except Exception as exc:
|
|
789
|
+
detail = last_error or exc
|
|
790
|
+
raise click.ClickException(f"Failed to import {resolved}: {detail}") from detail
|
|
791
|
+
|
|
792
|
+
config_obj: TaskAppConfig | None = None
|
|
793
|
+
factory_callable: Callable[[], TaskAppConfig] | None = None
|
|
794
|
+
|
|
795
|
+
for attr_name in dir(module):
|
|
796
|
+
try:
|
|
797
|
+
attr = getattr(module, attr_name)
|
|
798
|
+
except Exception:
|
|
799
|
+
continue
|
|
800
|
+
if isinstance(attr, TaskAppConfig) and attr.app_id == app_id:
|
|
801
|
+
|
|
802
|
+
def _return_config(cfg: TaskAppConfig = attr) -> TaskAppConfig:
|
|
803
|
+
return cfg
|
|
804
|
+
|
|
805
|
+
factory_callable = _return_config
|
|
806
|
+
config_obj = attr
|
|
807
|
+
break
|
|
808
|
+
|
|
809
|
+
if factory_callable is None:
|
|
810
|
+
for attr_name in dir(module):
|
|
811
|
+
if attr_name.startswith("_"):
|
|
812
|
+
continue
|
|
813
|
+
try:
|
|
814
|
+
attr = getattr(module, attr_name)
|
|
815
|
+
except Exception:
|
|
816
|
+
continue
|
|
817
|
+
if not callable(attr):
|
|
818
|
+
continue
|
|
819
|
+
try:
|
|
820
|
+
sig = inspect.signature(attr)
|
|
821
|
+
except (TypeError, ValueError):
|
|
822
|
+
continue
|
|
823
|
+
has_required = any(
|
|
824
|
+
param.kind
|
|
825
|
+
in (inspect.Parameter.POSITIONAL_ONLY, inspect.Parameter.POSITIONAL_OR_KEYWORD)
|
|
826
|
+
and param.default is inspect._empty
|
|
827
|
+
for param in sig.parameters.values()
|
|
828
|
+
)
|
|
829
|
+
if has_required:
|
|
830
|
+
continue
|
|
831
|
+
try:
|
|
832
|
+
result = attr()
|
|
833
|
+
except Exception:
|
|
834
|
+
continue
|
|
835
|
+
if isinstance(result, TaskAppConfig) and result.app_id == app_id:
|
|
836
|
+
|
|
837
|
+
def _factory_noargs(func: Callable[[], TaskAppConfig] = attr) -> TaskAppConfig:
|
|
838
|
+
return func()
|
|
839
|
+
|
|
840
|
+
factory_callable = _factory_noargs
|
|
841
|
+
config_obj = result
|
|
842
|
+
break
|
|
843
|
+
|
|
844
|
+
if factory_callable is None or config_obj is None:
|
|
845
|
+
try:
|
|
846
|
+
entry = registry.get(app_id)
|
|
847
|
+
return entry
|
|
848
|
+
except KeyError as exc:
|
|
849
|
+
raise click.ClickException(
|
|
850
|
+
f"Could not locate TaskAppConfig for '{app_id}' in {resolved}."
|
|
851
|
+
) from exc
|
|
852
|
+
|
|
853
|
+
modal_cfg: ModalDeploymentConfig | None = None
|
|
854
|
+
for attr_name in dir(module):
|
|
855
|
+
try:
|
|
856
|
+
attr = getattr(module, attr_name)
|
|
857
|
+
except Exception:
|
|
858
|
+
continue
|
|
859
|
+
if isinstance(attr, ModalDeploymentConfig):
|
|
860
|
+
modal_cfg = attr
|
|
861
|
+
break
|
|
862
|
+
|
|
863
|
+
if modal_cfg is None:
|
|
864
|
+
modal_cfg = _extract_modal_config_from_file(resolved)
|
|
865
|
+
|
|
866
|
+
env_files: Iterable[str] = getattr(module, "ENV_FILES", ()) # type: ignore[arg-type]
|
|
867
|
+
|
|
868
|
+
return TaskAppEntry(
|
|
869
|
+
app_id=app_id,
|
|
870
|
+
description=inspect.getdoc(module) or f"Discovered task app in {resolved.name}",
|
|
871
|
+
config_factory=factory_callable,
|
|
872
|
+
aliases=(),
|
|
873
|
+
env_files=tuple(str(Path(p)) for p in env_files if p),
|
|
874
|
+
modal=modal_cfg,
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
__all__ = [
|
|
879
|
+
"AppChoice",
|
|
880
|
+
"discover_eval_config_paths",
|
|
881
|
+
"select_app_choice",
|
|
882
|
+
]
|