From d9b7db429e7f9235140031da0de9d76d7171102a Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani Date: Thu, 31 Jul 2025 19:00:42 +0500 Subject: [PATCH 1/6] refactor(trajectories): introduce AsyncTrajectoryGroup to handle async creation --- src/art/cli.py | 14 +++----------- src/art/trajectories.py | 13 ++++++++----- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/src/art/cli.py b/src/art/cli.py index cb49ced3..0d4d9cb3 100644 --- a/src/art/cli.py +++ b/src/art/cli.py @@ -12,7 +12,7 @@ from . import dev from .local import LocalBackend from .model import Model, TrainableModel -from .trajectories import TrajectoryGroup +from .trajectories import TrajectoryGroup, AsyncTrajectoryGroup from .types import TrainConfig from .utils.deploy_model import LoRADeploymentProvider from .errors import ARTError @@ -35,15 +35,7 @@ def is_port_available(port: int) -> bool: ) return - # Reset the custom __new__ and __init__ methods for TrajectoryGroup - def __new__(cls, *args: Any, **kwargs: Any) -> TrajectoryGroup: - return pydantic.BaseModel.__new__(cls) - - def __init__(self, *args: Any, **kwargs: Any) -> None: - return pydantic.BaseModel.__init__(self, *args, **kwargs) - - TrajectoryGroup.__new__ = __new__ # type: ignore - TrajectoryGroup.__init__ = __init__ + backend = LocalBackend() app = FastAPI() @@ -77,7 +69,7 @@ async def _log( @app.post("/_train_model") async def _train_model( model: TrainableModel, - trajectory_groups: list[TrajectoryGroup], + trajectory_groups: list[AsyncTrajectoryGroup], config: TrainConfig, dev_config: dev.TrainConfig, verbose: bool = Body(False), diff --git a/src/art/trajectories.py b/src/art/trajectories.py index 4915f954..cb6aa618 100644 --- a/src/art/trajectories.py +++ b/src/art/trajectories.py @@ -119,7 +119,7 @@ class TrajectoryGroup(pydantic.BaseModel): def __init__( self, trajectories: ( - Iterable[Trajectory | BaseException] | Iterable[Awaitable[Trajectory]] + Iterable[Trajectory | BaseException] ), *, exceptions: list[BaseException] = [], @@ -158,13 +158,15 @@ def __iter__(self) -> Iterator[Trajectory]: def __len__(self) -> int: return len(self.trajectories) + +class AsyncTrajectoryGroup(TrajectoryGroup): @overload def __new__( cls, trajectories: Iterable[Trajectory | BaseException], *, exceptions: list[BaseException] = [], - ) -> "TrajectoryGroup": ... + ) -> "AsyncTrajectoryGroup": ... @overload def __new__( @@ -172,7 +174,7 @@ def __new__( trajectories: Iterable[Awaitable[Trajectory]], *, exceptions: list[BaseException] = [], - ) -> Awaitable["TrajectoryGroup"]: ... + ) -> Awaitable["AsyncTrajectoryGroup"]: ... def __new__( cls, @@ -181,7 +183,7 @@ def __new__( ), *, exceptions: list[BaseException] = [], - ) -> "TrajectoryGroup | Awaitable[TrajectoryGroup]": + ) -> "AsyncTrajectoryGroup | Awaitable[AsyncTrajectoryGroup]": ts = list(trajectories) if any(hasattr(t, "__await__") for t in ts): @@ -204,7 +206,7 @@ async def _(exceptions: list[BaseException]): context.update_pbar(n=0) if context.too_many_exceptions(): raise - return TrajectoryGroup( + return AsyncTrajectoryGroup( trajectories=trajectories, exceptions=exceptions, ) @@ -226,3 +228,4 @@ def __await__(self): exceptions=exceptions, ) return group + From 3e4d852e781ce0339f8d973ab675aa443598a561 Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani Date: Thu, 31 Jul 2025 19:06:21 +0500 Subject: [PATCH 2/6] feat(model): implement safe model deletion with trash functionality --- src/art/cli.py | 37 ++++++++++++++++++++++++++- src/art/model.py | 66 ++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/art/cli.py b/src/art/cli.py index 0d4d9cb3..c53f664b 100644 --- a/src/art/cli.py +++ b/src/art/cli.py @@ -11,7 +11,7 @@ from . import dev from .local import LocalBackend -from .model import Model, TrainableModel +from .model import Model, TrainableModel, list_trash, restore_from_trash, empty_trash, delete_model from .trajectories import TrajectoryGroup, AsyncTrajectoryGroup from .types import TrainConfig from .utils.deploy_model import LoRADeploymentProvider @@ -139,3 +139,38 @@ async def _experimental_deploy( ) uvicorn.run(app, host=host, port=port, loop="asyncio") + +@app.command() +def delete(project_name: str, model_name: str): + """Move a model to the trash.""" + try: + delete_model(project_name, model_name) + print(f"Model '{model_name}' in project '{project_name}' moved to trash.") + except FileNotFoundError as e: + print(e) + +@app.command() +def list_trashed_models(project_name: str): + """List models in the trash.""" + trashed_models = list_trash(project_name) + if not trashed_models: + print("Trash is empty.") + else: + print("Models in trash:") + for model_name in trashed_models: + print(f"- {model_name}") + +@app.command() +def restore_model(project_name: str, model_name: str): + """Restore a model from the trash.""" + try: + restore_from_trash(project_name, model_name) + print(f"Model '{model_name}' in project '{project_name}' restored.") + except FileNotFoundError as e: + print(e) + +@app.command() +def empty_trashed_models(project_name: str): + """Permanently delete all models in the trash.""" + empty_trash(project_name) + print("Trash has been emptied.") \ No newline at end of file diff --git a/src/art/model.py b/src/art/model.py index 37655fe9..709c8650 100644 --- a/src/art/model.py +++ b/src/art/model.py @@ -1,4 +1,6 @@ import httpx +import os +import shutil from openai import AsyncOpenAI, DefaultAsyncHttpxClient from pydantic import BaseModel from typing import TYPE_CHECKING, cast, Generic, Iterable, Optional, overload, TypeVar @@ -8,6 +10,7 @@ from .openai import patch_openai from .trajectories import Trajectory, TrajectoryGroup from .types import TrainConfig +from .utils.output_dirs import get_models_dir, get_model_dir if TYPE_CHECKING: from art.backend import Backend @@ -30,22 +33,26 @@ class Model( You can instantiate a prompted model like so: - ``python model = art.Model( - name="gpt-4.1", project="my-project", + ```python + model = art.Model( + name="gpt-4.1", + project="my-project", inference_api_key=os.getenv("OPENAI_API_KEY"), inference_base_url="https://api.openai.com/v1/", ) - `` + ``` Or, if you're pointing at OpenRouter: - ``python model = art.Model( - name="gemini-2.5-pro", project="my-project", + ```python + model = art.Model( + name="gemini-2.5-pro", + project="my-project", inference_api_key=os.getenv("OPENROUTER_API_KEY"), inference_base_url="https://openrouter.ai/api/v1", inference_model_name="google/gemini-2.5-pro-preview-03-25", ) - `` + ``` For trainable (`art.TrainableModel`) models the inference values will be populated automatically by `model.register(api)` so you generally don't need @@ -227,9 +234,9 @@ async def log( ) -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- # Trainable models -# --------------------------------------------------------------------------- +# -------------------------------------------------------------------------- class TrainableModel(Model[ModelConfig], Generic[ModelConfig]): @@ -354,3 +361,46 @@ async def train( self, list(trajectory_groups), config, _config or {}, verbose ): pass + + +def _get_trash_dir(project_name: str) -> str: + models_dir = get_models_dir(project_name) + return os.path.join(models_dir, ".trash") + + +def move_to_trash(project_name: str, model_name: str): + model_dir = get_model_dir(Model(name=model_name, project=project_name, config=None)) + if not os.path.exists(model_dir): + raise FileNotFoundError(f"Model '{model_name}' not found in project '{project_name}'.") + + trash_dir = _get_trash_dir(project_name) + os.makedirs(trash_dir, exist_ok=True) + + shutil.move(model_dir, os.path.join(trash_dir, model_name)) + + +def list_trash(project_name: str) -> list[str]: + trash_dir = _get_trash_dir(project_name) + if not os.path.exists(trash_dir): + return [] + return os.listdir(trash_dir) + + +def restore_from_trash(project_name: str, model_name: str): + trash_dir = _get_trash_dir(project_name) + trashed_model_path = os.path.join(trash_dir, model_name) + if not os.path.exists(trashed_model_path): + raise FileNotFoundError(f"Model '{model_name}' not found in trash.") + + models_dir = get_models_dir(project_name) + shutil.move(trashed_model_path, os.path.join(models_dir, model_name)) + + +def empty_trash(project_name: str): + trash_dir = _get_trash_dir(project_name) + if os.path.exists(trash_dir): + shutil.rmtree(trash_dir) + + +def delete_model(project_name: str, model_name: str): + move_to_trash(project_name, model_name) \ No newline at end of file From aad0b4659fdf6feba7afb9fa0c8c8659c1597f65 Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani <90501474+SyedaAnshrahGillani@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:21:57 +0500 Subject: [PATCH 3/6] Update trajectories.py Revert AsyncTrajectoryGroup --- src/art/trajectories.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/art/trajectories.py b/src/art/trajectories.py index cb6aa618..6ddecac1 100644 --- a/src/art/trajectories.py +++ b/src/art/trajectories.py @@ -119,7 +119,7 @@ class TrajectoryGroup(pydantic.BaseModel): def __init__( self, trajectories: ( - Iterable[Trajectory | BaseException] + Iterable[Trajectory | BaseException] | Iterable[Awaitable[Trajectory]] ), *, exceptions: list[BaseException] = [], @@ -159,14 +159,14 @@ def __len__(self) -> int: return len(self.trajectories) -class AsyncTrajectoryGroup(TrajectoryGroup): + @overload def __new__( cls, trajectories: Iterable[Trajectory | BaseException], *, exceptions: list[BaseException] = [], - ) -> "AsyncTrajectoryGroup": ... + ) -> "TrajectoryGroup": ... @overload def __new__( @@ -174,7 +174,7 @@ def __new__( trajectories: Iterable[Awaitable[Trajectory]], *, exceptions: list[BaseException] = [], - ) -> Awaitable["AsyncTrajectoryGroup"]: ... + ) -> Awaitable["TrajectoryGroup"]: ... def __new__( cls, @@ -183,7 +183,7 @@ def __new__( ), *, exceptions: list[BaseException] = [], - ) -> "AsyncTrajectoryGroup | Awaitable[AsyncTrajectoryGroup]": + ) -> "TrajectoryGroup | Awaitable[TrajectoryGroup]": ts = list(trajectories) if any(hasattr(t, "__await__") for t in ts): @@ -206,7 +206,7 @@ async def _(exceptions: list[BaseException]): context.update_pbar(n=0) if context.too_many_exceptions(): raise - return AsyncTrajectoryGroup( + return TrajectoryGroup( trajectories=trajectories, exceptions=exceptions, ) From 11080dcd3b2ec219e50ba40473f24a1d31b3c828 Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani <90501474+SyedaAnshrahGillani@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:23:47 +0500 Subject: [PATCH 4/6] Remove AsyncTrajectory group --- src/art/cli.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/art/cli.py b/src/art/cli.py index c53f664b..0be7d805 100644 --- a/src/art/cli.py +++ b/src/art/cli.py @@ -12,7 +12,7 @@ from . import dev from .local import LocalBackend from .model import Model, TrainableModel, list_trash, restore_from_trash, empty_trash, delete_model -from .trajectories import TrajectoryGroup, AsyncTrajectoryGroup +from .trajectories import TrajectoryGroup from .types import TrainConfig from .utils.deploy_model import LoRADeploymentProvider from .errors import ARTError @@ -69,7 +69,7 @@ async def _log( @app.post("/_train_model") async def _train_model( model: TrainableModel, - trajectory_groups: list[AsyncTrajectoryGroup], + trajectory_groups: list[TrajectoryGroup], config: TrainConfig, dev_config: dev.TrainConfig, verbose: bool = Body(False), @@ -173,4 +173,4 @@ def restore_model(project_name: str, model_name: str): def empty_trashed_models(project_name: str): """Permanently delete all models in the trash.""" empty_trash(project_name) - print("Trash has been emptied.") \ No newline at end of file + print("Trash has been emptied.") From eec768faf7d74190d617aa6302cdbdb30d7a3301 Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani <90501474+SyedaAnshrahGillani@users.noreply.github.com> Date: Mon, 11 Aug 2025 13:27:56 +0500 Subject: [PATCH 5/6] Use preferred names --- src/art/cli.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/art/cli.py b/src/art/cli.py index 0be7d805..398edc52 100644 --- a/src/art/cli.py +++ b/src/art/cli.py @@ -36,6 +36,16 @@ def is_port_available(port: int) -> bool: return + # Reset the custom __new__ and __init__ methods for TrajectoryGroup + def __new__(cls, *args: Any, **kwargs: Any) -> TrajectoryGroup: + return pydantic.BaseModel.__new__(cls) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + return pydantic.BaseModel.__init__(self, *args, **kwargs) + + TrajectoryGroup.__new__ = __new__ # type: ignore + TrajectoryGroup.__init__ = __init__ + backend = LocalBackend() app = FastAPI() @@ -141,7 +151,7 @@ async def _experimental_deploy( uvicorn.run(app, host=host, port=port, loop="asyncio") @app.command() -def delete(project_name: str, model_name: str): +def delete_model(project_name: str, model_name: str): """Move a model to the trash.""" try: delete_model(project_name, model_name) @@ -150,7 +160,7 @@ def delete(project_name: str, model_name: str): print(e) @app.command() -def list_trashed_models(project_name: str): +def list_trash(project_name: str): """List models in the trash.""" trashed_models = list_trash(project_name) if not trashed_models: @@ -170,7 +180,7 @@ def restore_model(project_name: str, model_name: str): print(e) @app.command() -def empty_trashed_models(project_name: str): +def empty_trash(project_name: str): """Permanently delete all models in the trash.""" empty_trash(project_name) print("Trash has been emptied.") From 233528256a15b807306bfa3db18467421540f177 Mon Sep 17 00:00:00 2001 From: SyedaAnshrahGillani Date: Mon, 11 Aug 2025 13:40:37 +0500 Subject: [PATCH 6/6] refactor: move model trash to top-level .art/trash//models --- src/art/model.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/art/model.py b/src/art/model.py index 709c8650..ba2fd5b1 100644 --- a/src/art/model.py +++ b/src/art/model.py @@ -10,7 +10,7 @@ from .openai import patch_openai from .trajectories import Trajectory, TrajectoryGroup from .types import TrainConfig -from .utils.output_dirs import get_models_dir, get_model_dir +from .utils.output_dirs import get_default_art_path, get_models_dir, get_model_dir if TYPE_CHECKING: from art.backend import Backend @@ -364,8 +364,8 @@ async def train( def _get_trash_dir(project_name: str) -> str: - models_dir = get_models_dir(project_name) - return os.path.join(models_dir, ".trash") + art_path = get_default_art_path() + return os.path.join(art_path, "trash", project_name, "models") def move_to_trash(project_name: str, model_name: str):