Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refact: support absolute paths in local source manager #450

Merged
merged 3 commits into from
Feb 7, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e_tests/apiserver/rc/deployment.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@ services:
host: localhost
source:
type: local
name: ./e2e_tests/apiserver/rc/src
name: src
path: workflow:echo_workflow
10 changes: 5 additions & 5 deletions llama_deploy/apiserver/deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from multiprocessing.pool import ThreadPool
from pathlib import Path
from shutil import rmtree
from typing import Any
from typing import Any, Type

import httpx
from dotenv import dotenv_values
Expand Down Expand Up @@ -40,9 +40,9 @@
)
from .source_managers import GitSourceManager, LocalSourceManager, SourceManager

SOURCE_MANAGERS: dict[SourceType, SourceManager] = {
SourceType.git: GitSourceManager(),
SourceType.local: LocalSourceManager(),
SOURCE_MANAGERS: dict[SourceType, Type[SourceManager]] = {
SourceType.git: GitSourceManager,
SourceType.local: LocalSourceManager,
}


Expand Down Expand Up @@ -228,7 +228,7 @@ def _load_services(self, config: DeploymentConfig) -> list[WorkflowService]:
# for any source manager currently supported.
rmtree(str(destination))

source_manager = SOURCE_MANAGERS[source.type]
source_manager = SOURCE_MANAGERS[source.type](config)
source_manager.sync(source.name, str(destination))

# Install dependencies
Expand Down
3 changes: 2 additions & 1 deletion llama_deploy/apiserver/deployment_config_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ class DeploymentConfig(BaseModel):
message_queue: MessageQueueConfig | None = Field(None, alias="message-queue")
default_service: str | None = Field(None, alias="default-service")
services: dict[str, Service]
base_path: Path = Path()

@classmethod
def from_yaml_bytes(cls, src: bytes) -> Self:
Expand All @@ -83,4 +84,4 @@ def from_yaml(cls, path: Path) -> Self:
"""Read config data from a yaml file."""
with open(path, "r") as yaml_file:
config = yaml.safe_load(yaml_file) or {}
return cls(**config)
return cls(**config, base_path=path.parent)
18 changes: 2 additions & 16 deletions llama_deploy/apiserver/source_managers/__init__.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,5 @@
from typing import Protocol

from .base import SourceManager
from .git import GitSourceManager
from .local import LocalSourceManager

__all__ = ["GitSourceManager", "LocalSourceManager"]


class SourceManager(Protocol):
"""Protocol to be implemented by classes responsible for managing Deployment sources."""

def sync(
self, source: str, destination: str | None = None
) -> None: # pragma: no cover
"""Fetches resources from `source` so they can be used in a deployment.

Optionally uses `destination` to store data when this makes sense for the
specific source type.
"""
__all__ = ["GitSourceManager", "LocalSourceManager", "SourceManager"]
20 changes: 20 additions & 0 deletions llama_deploy/apiserver/source_managers/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
from abc import ABC, abstractmethod

from llama_deploy.apiserver.deployment_config_parser import DeploymentConfig


class SourceManager(ABC):
"""Protocol to be implemented by classes responsible for managing Deployment sources."""

def __init__(self, config: DeploymentConfig) -> None:
self._config = config

@abstractmethod
def sync(
self, source: str, destination: str | None = None
) -> None: # pragma: no cover
"""Fetches resources from `source` so they can be used in a deployment.

Optionally uses `destination` to store data when this makes sense for the
specific source type.
"""
4 changes: 3 additions & 1 deletion llama_deploy/apiserver/source_managers/git.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

from git import Repo

from .base import SourceManager

class GitSourceManager:

class GitSourceManager(SourceManager):
"""A SourceManager specialized for sources of type `git`."""

def sync(self, source: str, destination: str | None = None) -> None:
Expand Down
9 changes: 6 additions & 3 deletions llama_deploy/apiserver/source_managers/local.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import shutil

from .base import SourceManager

class LocalSourceManager:

class LocalSourceManager(SourceManager):
"""A SourceManager specialized for sources of type `local`."""

def sync(self, source: str, destination: str | None = None) -> None:
Expand All @@ -15,7 +17,8 @@ def sync(self, source: str, destination: str | None = None) -> None:
raise ValueError("Destination cannot be empty")

try:
shutil.copytree(source, destination, dirs_exist_ok=True)
except shutil.Error as e:
final_path = self._config.base_path / source
shutil.copytree(final_path, destination, dirs_exist_ok=True)
except Exception as e:
msg = f"Unable to copy {source} into {destination}: {e}"
raise ValueError(msg) from e
13 changes: 13 additions & 0 deletions tests/apiserver/data/local.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
name: LocalDeploymentRelativePath

control-plane: {}

services:
test-workflow:
name: Test Workflow
port: 8002
host: localhost
source:
type: local
name: workflow
path: workflow:my_workflow
19 changes: 13 additions & 6 deletions tests/apiserver/source_managers/test_git.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
from pathlib import Path
from unittest import mock

import pytest

from llama_deploy.apiserver.deployment_config_parser import DeploymentConfig
from llama_deploy.apiserver.source_managers.git import GitSourceManager


def test_parse_source() -> None:
sm = GitSourceManager()
@pytest.fixture
def config(data_path: Path) -> DeploymentConfig:
return DeploymentConfig.from_yaml(data_path / "git_service.yaml")


def test_parse_source(config: DeploymentConfig) -> None:
sm = GitSourceManager(config)
assert sm._parse_source("https://example.com/llama_deploy.git@branch_name") == (
"https://example.com/llama_deploy.git",
"branch_name",
Expand All @@ -17,14 +24,14 @@ def test_parse_source() -> None:
)


def test_sync_wrong_params() -> None:
sm = GitSourceManager()
def test_sync_wrong_params(config: DeploymentConfig) -> None:
sm = GitSourceManager(config)
with pytest.raises(ValueError, match="Destination cannot be empty"):
sm.sync("some_source")


def test_sync() -> None:
sm = GitSourceManager()
def test_sync(config: DeploymentConfig) -> None:
sm = GitSourceManager(config)
with mock.patch("llama_deploy.apiserver.source_managers.git.Repo") as repo_mock:
sm.sync("source", "dest")
repo_mock.clone_from.assert_called_with(to_path="dest", url="source")
Expand Down
51 changes: 51 additions & 0 deletions tests/apiserver/source_managers/test_local.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
from pathlib import Path
from unittest import mock

import pytest

from llama_deploy.apiserver import DeploymentConfig
from llama_deploy.apiserver.source_managers.local import LocalSourceManager


@pytest.fixture
def config(data_path: Path) -> DeploymentConfig:
return DeploymentConfig.from_yaml(data_path / "local.yaml")


def test_dest_missing(config: DeploymentConfig) -> None:
sm = LocalSourceManager(config)
with pytest.raises(ValueError, match="Destination cannot be empty"):
sm.sync("source", "")


def test_sync_error(config: DeploymentConfig) -> None:
sm = LocalSourceManager(config)
with mock.patch(
"llama_deploy.apiserver.source_managers.local.shutil"
) as shutil_mock:
shutil_mock.copytree.side_effect = Exception("this was a test")
with pytest.raises(
ValueError, match="Unable to copy source into dest: this was a test"
):
sm.sync("source", "dest")


def test_relative_path(tmp_path: Path, data_path: Path) -> None:
config = DeploymentConfig.from_yaml(data_path / "local.yaml")
sm = LocalSourceManager(config)

sm.sync("workflow", str(tmp_path))
fnames = list(f.name for f in tmp_path.iterdir())
assert "workflow_test.py" in fnames
assert "__init__.py" in fnames


def test_absolute_path(tmp_path: Path, data_path: Path) -> None:
config = DeploymentConfig.from_yaml(data_path / "local.yaml")
wf_dir = data_path / "workflow"
sm = LocalSourceManager(config)

sm.sync(str(wf_dir.absolute()), str(tmp_path))
fnames = list(f.name for f in tmp_path.iterdir())
assert "workflow_test.py" in fnames
assert "__init__.py" in fnames
2 changes: 1 addition & 1 deletion tests/apiserver/test_deployment.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ def test_deployment_ctor(data_path: Path, mock_importlib: Any) -> None:
sm_dict["git"] = mock.MagicMock()
d = Deployment(config=config, root_path=Path("."))

sm_dict["git"].sync.assert_called_once()
sm_dict["git"].return_value.sync.assert_called_once()
assert d.name == "TestDeployment"
assert d.path.name == "TestDeployment"
assert type(d._control_plane) is ControlPlaneServer
Expand Down
Loading