diff --git a/libs/cli/langchain_cli/namespaces/serve.py b/libs/cli/langchain_cli/namespaces/serve.py index 5a077d1295..94c794eaeb 100644 --- a/libs/cli/langchain_cli/namespaces/serve.py +++ b/libs/cli/langchain_cli/namespaces/serve.py @@ -5,17 +5,17 @@ Manage LangServe application projects. import shutil import subprocess from pathlib import Path -from typing import List, Optional +from typing import Dict, List, Optional, Tuple -import tomli import typer from langserve.packages import get_langserve_export, list_packages from typing_extensions import Annotated from langchain_cli.utils.events import create_events from langchain_cli.utils.git import ( + DependencySource, copy_repo, - parse_dependency_string, + parse_dependencies, update_repo, ) from langchain_cli.utils.packages import get_package_root @@ -79,7 +79,10 @@ def add( Optional[Path], typer.Option(help="The project directory") ] = None, repo: Annotated[ - List[str], typer.Option(help="Shorthand for installing a GitHub Repo") + List[str], typer.Option(help="Install deps from a specific github repo instead") + ] = [], + branch: Annotated[ + List[str], typer.Option(help="Install deps from a specific branch") ] = [], with_poetry: Annotated[ bool, @@ -94,80 +97,112 @@ def add( langchain serve add git+ssh://git@github.com/efriis/simple-pirate.git langchain serve add git+https://github.com/efriis/hub.git#devbranch#subdirectory=mypackage """ + + parsed_deps = parse_dependencies(dependencies, repo, branch, api_path) + project_root = get_package_root(project_dir) - if dependencies is None: - dependencies = [] + package_dir = project_root / "packages" + + create_events( + [{"event": "serve add", "properties": dict(parsed_dep=d)} for d in parsed_deps] + ) + + # group by repo/ref + grouped: Dict[Tuple[str, Optional[str]], List[DependencySource]] = {} + for dep in parsed_deps: + key_tup = (dep["git"], dep["ref"]) + lst = grouped.get(key_tup, []) + lst.append(dep) + grouped[key_tup] = lst + + installed_destination_paths: List[Path] = [] + installed_exports: List[Dict] = [] - # cannot have both repo and dependencies - if len(repo) != 0: - if len(dependencies) != 0: - raise typer.BadParameter( - "Cannot specify both repo and dependencies. " - "Please specify one or the other." + for (git, ref), group_deps in grouped.items(): + if len(group_deps) == 1: + typer.echo(f"Adding {git}@{ref}...") + else: + typer.echo(f"Adding {len(group_deps)} dependencies from {git}@{ref}") + source_repo_path = update_repo(git, ref, REPO_DIR) + + for dep in group_deps: + source_path = ( + source_repo_path / dep["subdirectory"] + if dep["subdirectory"] + else source_repo_path ) - dependencies = [f"git+https://github.com/{r}" for r in repo] + pyproject_path = source_path / "pyproject.toml" + if not pyproject_path.exists(): + typer.echo(f"Could not find {pyproject_path}") + continue + langserve_export = get_langserve_export(pyproject_path) - if len(api_path) != 0 and len(api_path) != len(dependencies): - raise typer.BadParameter( - "The number of API paths must match the number of dependencies." - ) + # default path to package_name + inner_api_path = dep["api_path"] or langserve_export["package_name"] - # get installed packages from pyproject.toml - root_pyproject_path = project_root / "pyproject.toml" - with open(root_pyproject_path, "rb") as pyproject_file: - pyproject = tomli.load(pyproject_file) - installed_packages = ( - pyproject.get("tool", {}).get("poetry", {}).get("dependencies", {}) - ) - installed_names = set(installed_packages.keys()) + destination_path = package_dir / inner_api_path + if destination_path.exists(): + typer.echo( + f"Folder {str(inner_api_path)} already exists. " "Skipping...", + ) + continue + copy_repo(source_path, destination_path) + typer.echo(f" - Downloaded {dep['subdirectory']} to {inner_api_path}") + installed_destination_paths.append(destination_path) + installed_exports.append(langserve_export) - package_dir = project_root / "packages" + if len(installed_destination_paths) == 0: + typer.echo("No packages installed. Exiting.") + return - create_events( - [{"event": "serve add", "properties": {"package": d}} for d in dependencies] - ) + cwd = Path.cwd() + installed_desination_strs = [ + str(p.relative_to(cwd)) for p in installed_destination_paths + ] - for i, dependency in enumerate(dependencies): - # update repo - typer.echo(f"Adding {dependency}...") - dep = parse_dependency_string(dependency) - source_repo_path = update_repo(dep["git"], dep["ref"], REPO_DIR) - source_path = ( - source_repo_path / dep["subdirectory"] - if dep["subdirectory"] - else source_repo_path + if with_poetry: + subprocess.run( + ["poetry", "add", "--editable"] + installed_desination_strs, + cwd=cwd, ) - pyproject_path = source_path / "pyproject.toml" - if not pyproject_path.exists(): - typer.echo(f"Could not find {pyproject_path}") - continue - langserve_export = get_langserve_export(pyproject_path) + else: + cmd = ["pip", "install", "-e"] + installed_desination_strs + cmd_str = " \\\n ".join(installed_desination_strs) + install_str = f"To install:\n\npip install -e \\\n {cmd_str}" + typer.echo(install_str) - # detect name conflict - if langserve_export["package_name"] in installed_names: - typer.echo( - f"Package with name {langserve_export['package_name']} already " - "installed. Skipping...", - ) - continue + if typer.confirm("Run it?"): + subprocess.run(cmd, cwd=cwd) + if typer.confirm("\nGenerate route code for these packages?", default=True): + chain_names = [] + for e in installed_exports: + original_candidate = f'{e["package_name"].replace("-", "_")}_chain' + candidate = original_candidate + i = 2 + while candidate in chain_names: + candidate = original_candidate + "_" + str(i) + i += 1 + chain_names.append(candidate) - inner_api_path = ( - api_path[i] if len(api_path) != 0 else langserve_export["package_name"] + api_paths = [ + str(Path("/") / path.relative_to(package_dir)) + for path in installed_destination_paths + ] + + imports = [ + f"from {e['module']} import {e['attr']} as {name}" + for e, name in zip(installed_exports, chain_names) + ] + routes = [ + f'add_routes(app, {name}, path="{path}")' + for name, path in zip(chain_names, api_paths) + ] + + lines = ( + ["", "Great! Add the following to your app:", ""] + imports + [""] + routes ) - destination_path = package_dir / inner_api_path - if destination_path.exists(): - typer.echo( - f"Endpoint {langserve_export['package_name']} already exists. " - "Skipping...", - ) - continue - copy_repo(source_path, destination_path) - # poetry install - if with_poetry: - subprocess.run( - ["poetry", "add", "--editable", destination_path], cwd=project_root - ) + typer.echo("\n".join(lines)) @serve.command() diff --git a/libs/cli/langchain_cli/utils/git.py b/libs/cli/langchain_cli/utils/git.py index 1050d96720..ab4e016a1c 100644 --- a/libs/cli/langchain_cli/utils/git.py +++ b/libs/cli/langchain_cli/utils/git.py @@ -2,7 +2,7 @@ import hashlib import re import shutil from pathlib import Path -from typing import Optional, TypedDict +from typing import Dict, List, Optional, Sequence, TypedDict from git import Repo @@ -17,13 +17,25 @@ class DependencySource(TypedDict): git: str ref: Optional[str] subdirectory: Optional[str] + api_path: Optional[str] + event_metadata: Dict # use poetry dependency string format -def parse_dependency_string(package_string: str) -> DependencySource: - if package_string.startswith("git+"): +def parse_dependency_string( + dep: Optional[str], + repo: Optional[str], + branch: Optional[str], + api_path: Optional[str], +) -> DependencySource: + if dep is not None and dep.startswith("git+"): + if repo is not None or branch is not None: + raise ValueError( + "If a dependency starts with git+, you cannot manually specify " + "a repo or branch." + ) # remove git+ - gitstring = package_string[4:] + gitstring = dep[4:] subdirectory = None ref = None # first check for #subdirectory= on the end @@ -62,16 +74,77 @@ def parse_dependency_string(package_string: str) -> DependencySource: git=gitstring, ref=ref, subdirectory=subdirectory, + api_path=api_path, + event_metadata={"dependency_string": dep}, ) - elif package_string.startswith("https://"): - raise NotImplementedError("url dependencies are not supported yet") + elif dep is not None and dep.startswith("https://"): + raise ValueError("Only git dependencies are supported") else: + # if repo is none, use default, including subdirectory + base_subdir = Path(DEFAULT_GIT_SUBDIRECTORY) if repo is None else Path() + subdir = str(base_subdir / dep) if dep is not None else None + gitstring = ( + DEFAULT_GIT_REPO + if repo is None + else f"https://github.com/{repo.strip('/')}.git" + ) + ref = DEFAULT_GIT_REF if branch is None else branch # it's a default git repo dependency - subdirectory = str(Path(DEFAULT_GIT_SUBDIRECTORY) / package_string) return DependencySource( - git=DEFAULT_GIT_REPO, ref=DEFAULT_GIT_REF, subdirectory=subdirectory + git=gitstring, + ref=ref, + subdirectory=subdir, + api_path=api_path, + event_metadata={ + "dependency_string": dep, + "used_repo_flag": repo is not None, + "used_branch_flag": branch is not None, + }, + ) + + +def _list_arg_to_length(arg: Optional[List[str]], num: int) -> Sequence[Optional[str]]: + if not arg: + return [None] * num + elif len(arg) == 1: + return arg * num + elif len(arg) == num: + return arg + else: + raise ValueError(f"Argument must be of length 1 or {num}") + + +def parse_dependencies( + dependencies: Optional[List[str]], + repo: List[str], + branch: List[str], + api_path: List[str], +) -> List[DependencySource]: + num_deps = max( + len(dependencies) if dependencies is not None else 0, len(repo), len(branch) + ) + if ( + (dependencies and len(dependencies) != num_deps) + or (api_path and len(api_path) != num_deps) + or (repo and len(repo) not in [1, num_deps]) + or (branch and len(branch) not in [1, num_deps]) + ): + raise ValueError( + "Number of defined repos/branches/api_paths did not match the " + "number of dependencies." + ) + inner_deps = _list_arg_to_length(dependencies, num_deps) + inner_api_paths = _list_arg_to_length(api_path, num_deps) + inner_repos = _list_arg_to_length(repo, num_deps) + inner_branches = _list_arg_to_length(branch, num_deps) + + return [ + parse_dependency_string(iter_dep, iter_repo, iter_branch, iter_api_path) + for iter_dep, iter_repo, iter_branch, iter_api_path in zip( + inner_deps, inner_repos, inner_branches, inner_api_paths ) + ] def _get_repo_path(gitstring: str, repo_dir: Path) -> Path: diff --git a/libs/cli/pyproject.toml b/libs/cli/pyproject.toml index 032524c98c..728152945e 100644 --- a/libs/cli/pyproject.toml +++ b/libs/cli/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "langchain-cli" -version = "0.0.7" +version = "0.0.8" description = "CLI for interacting with LangChain" authors = ["Erick Friis "] readme = "README.md" diff --git a/libs/cli/tests/test_utils.py b/libs/cli/tests/test_utils.py index aa63603465..afe3d280b6 100644 --- a/libs/cli/tests/test_utils.py +++ b/libs/cli/tests/test_utils.py @@ -1,3 +1,5 @@ +from typing import Dict, Optional + import pytest from langchain_cli.constants import ( @@ -8,44 +10,74 @@ from langchain_cli.constants import ( from langchain_cli.utils.git import DependencySource, parse_dependency_string +def _assert_dependency_equals( + dep: DependencySource, + *, + git: Optional[str] = None, + ref: Optional[str] = None, + subdirectory: Optional[str] = None, + event_metadata: Optional[Dict] = None, +) -> None: + assert dep["git"] == git + assert dep["ref"] == ref + assert dep["subdirectory"] == subdirectory + if event_metadata is not None: + assert dep["event_metadata"] == event_metadata + + def test_dependency_string() -> None: - assert parse_dependency_string( - "git+ssh://git@github.com/efriis/myrepo.git" - ) == DependencySource( + _assert_dependency_equals( + parse_dependency_string( + "git+ssh://git@github.com/efriis/myrepo.git", None, None, None + ), git="ssh://git@github.com/efriis/myrepo.git", ref=None, subdirectory=None, ) - assert parse_dependency_string( - "git+https://github.com/efriis/myrepo.git#subdirectory=src" - ) == DependencySource( + _assert_dependency_equals( + parse_dependency_string( + "git+https://github.com/efriis/myrepo.git#subdirectory=src", + None, + None, + None, + ), git="https://github.com/efriis/myrepo.git", subdirectory="src", ref=None, ) - assert parse_dependency_string( - "git+ssh://git@github.com:efriis/myrepo.git#develop" - ) == DependencySource( - git="ssh://git@github.com:efriis/myrepo.git", ref="develop", subdirectory=None + _assert_dependency_equals( + parse_dependency_string( + "git+ssh://git@github.com:efriis/myrepo.git#develop", None, None, None + ), + git="ssh://git@github.com:efriis/myrepo.git", + ref="develop", + subdirectory=None, ) # also support a slash in ssh - assert parse_dependency_string( - "git+ssh://git@github.com/efriis/myrepo.git#develop" - ) == DependencySource( - git="ssh://git@github.com/efriis/myrepo.git", ref="develop", subdirectory=None + _assert_dependency_equals( + parse_dependency_string( + "git+ssh://git@github.com/efriis/myrepo.git#develop", None, None, None + ), + git="ssh://git@github.com/efriis/myrepo.git", + ref="develop", + subdirectory=None, ) # looks like poetry supports both an @ and a # - assert parse_dependency_string( - "git+ssh://git@github.com:efriis/myrepo.git@develop" - ) == DependencySource( - git="ssh://git@github.com:efriis/myrepo.git", ref="develop", subdirectory=None + _assert_dependency_equals( + parse_dependency_string( + "git+ssh://git@github.com:efriis/myrepo.git@develop", None, None, None + ), + git="ssh://git@github.com:efriis/myrepo.git", + ref="develop", + subdirectory=None, ) - assert parse_dependency_string("simple-pirate") == DependencySource( + _assert_dependency_equals( + parse_dependency_string("simple-pirate", None, None, None), git=DEFAULT_GIT_REPO, subdirectory=f"{DEFAULT_GIT_SUBDIRECTORY}/simple-pirate", ref=DEFAULT_GIT_REF, @@ -53,9 +85,13 @@ def test_dependency_string() -> None: def test_dependency_string_both() -> None: - assert parse_dependency_string( - "git+https://github.com/efriis/myrepo.git@branch#subdirectory=src" - ) == DependencySource( + _assert_dependency_equals( + parse_dependency_string( + "git+https://github.com/efriis/myrepo.git@branch#subdirectory=src", + None, + None, + None, + ), git="https://github.com/efriis/myrepo.git", subdirectory="src", ref="branch", @@ -66,7 +102,10 @@ def test_dependency_string_invalids() -> None: # expect error for wrong order with pytest.raises(ValueError): parse_dependency_string( - "git+https://github.com/efriis/myrepo.git#subdirectory=src@branch" + "git+https://github.com/efriis/myrepo.git#subdirectory=src@branch", + None, + None, + None, ) # expect error for @subdirectory @@ -77,16 +116,21 @@ def test_dependency_string_edge_case() -> None: # this could be a ssh dep with user=a, and default ref # or a ssh dep at a with ref=b. # in this case, assume the first case (be greedy with the '@') - assert parse_dependency_string("git+ssh://a@b") == DependencySource( + _assert_dependency_equals( + parse_dependency_string("git+ssh://a@b", None, None, None), git="ssh://a@b", subdirectory=None, ref=None, ) # weird one that is actually valid - assert parse_dependency_string( - "git+https://github.com/efriis/myrepo.git@subdirectory=src" - ) == DependencySource( + _assert_dependency_equals( + parse_dependency_string( + "git+https://github.com/efriis/myrepo.git@subdirectory=src", + None, + None, + None, + ), git="https://github.com/efriis/myrepo.git", subdirectory=None, ref="subdirectory=src",