cli improvements (#12465)

Features
- add multiple repos by their branch/repo
- generate `pip install` commands and `add_route()` code
![Screenshot 2023-10-27 at 4 49 52
PM](https://github.com/langchain-ai/langchain/assets/9557659/3aec4cbb-3f67-4f04-8370-5b54ea983b2a)

Optimizations:
- group installs by repo/branch to avoid duplicate cloning
pull/12470/head^2
Erick Friis 8 months ago committed by GitHub
parent 5545de0466
commit 9adaa78c65
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -5,17 +5,17 @@ Manage LangServe application projects.
import shutil import shutil
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from typing import List, Optional from typing import Dict, List, Optional, Tuple
import tomli
import typer import typer
from langserve.packages import get_langserve_export, list_packages from langserve.packages import get_langserve_export, list_packages
from typing_extensions import Annotated from typing_extensions import Annotated
from langchain_cli.utils.events import create_events from langchain_cli.utils.events import create_events
from langchain_cli.utils.git import ( from langchain_cli.utils.git import (
DependencySource,
copy_repo, copy_repo,
parse_dependency_string, parse_dependencies,
update_repo, update_repo,
) )
from langchain_cli.utils.packages import get_package_root from langchain_cli.utils.packages import get_package_root
@ -79,7 +79,10 @@ def add(
Optional[Path], typer.Option(help="The project directory") Optional[Path], typer.Option(help="The project directory")
] = None, ] = None,
repo: Annotated[ 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[ with_poetry: Annotated[
bool, bool,
@ -94,80 +97,112 @@ def add(
langchain serve add git+ssh://git@github.com/efriis/simple-pirate.git 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 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) project_root = get_package_root(project_dir)
if dependencies is None: package_dir = project_root / "packages"
dependencies = []
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 for (git, ref), group_deps in grouped.items():
if len(repo) != 0: if len(group_deps) == 1:
if len(dependencies) != 0: typer.echo(f"Adding {git}@{ref}...")
raise typer.BadParameter( else:
"Cannot specify both repo and dependencies. " typer.echo(f"Adding {len(group_deps)} dependencies from {git}@{ref}")
"Please specify one or the other." 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): # default path to package_name
raise typer.BadParameter( inner_api_path = dep["api_path"] or langserve_export["package_name"]
"The number of API paths must match the number of dependencies."
)
# get installed packages from pyproject.toml destination_path = package_dir / inner_api_path
root_pyproject_path = project_root / "pyproject.toml" if destination_path.exists():
with open(root_pyproject_path, "rb") as pyproject_file: typer.echo(
pyproject = tomli.load(pyproject_file) f"Folder {str(inner_api_path)} already exists. " "Skipping...",
installed_packages = ( )
pyproject.get("tool", {}).get("poetry", {}).get("dependencies", {}) continue
) copy_repo(source_path, destination_path)
installed_names = set(installed_packages.keys()) 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( cwd = Path.cwd()
[{"event": "serve add", "properties": {"package": d}} for d in dependencies] installed_desination_strs = [
) str(p.relative_to(cwd)) for p in installed_destination_paths
]
for i, dependency in enumerate(dependencies): if with_poetry:
# update repo subprocess.run(
typer.echo(f"Adding {dependency}...") ["poetry", "add", "--editable"] + installed_desination_strs,
dep = parse_dependency_string(dependency) cwd=cwd,
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
) )
pyproject_path = source_path / "pyproject.toml" else:
if not pyproject_path.exists(): cmd = ["pip", "install", "-e"] + installed_desination_strs
typer.echo(f"Could not find {pyproject_path}") cmd_str = " \\\n ".join(installed_desination_strs)
continue install_str = f"To install:\n\npip install -e \\\n {cmd_str}"
langserve_export = get_langserve_export(pyproject_path) typer.echo(install_str)
# detect name conflict if typer.confirm("Run it?"):
if langserve_export["package_name"] in installed_names: subprocess.run(cmd, cwd=cwd)
typer.echo( if typer.confirm("\nGenerate route code for these packages?", default=True):
f"Package with name {langserve_export['package_name']} already " chain_names = []
"installed. Skipping...", for e in installed_exports:
) original_candidate = f'{e["package_name"].replace("-", "_")}_chain'
continue 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_paths = [
api_path[i] if len(api_path) != 0 else langserve_export["package_name"] 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 typer.echo("\n".join(lines))
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
)
@serve.command() @serve.command()

@ -2,7 +2,7 @@ import hashlib
import re import re
import shutil import shutil
from pathlib import Path from pathlib import Path
from typing import Optional, TypedDict from typing import Dict, List, Optional, Sequence, TypedDict
from git import Repo from git import Repo
@ -17,13 +17,25 @@ class DependencySource(TypedDict):
git: str git: str
ref: Optional[str] ref: Optional[str]
subdirectory: Optional[str] subdirectory: Optional[str]
api_path: Optional[str]
event_metadata: Dict
# use poetry dependency string format # use poetry dependency string format
def parse_dependency_string(package_string: str) -> DependencySource: def parse_dependency_string(
if package_string.startswith("git+"): 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+ # remove git+
gitstring = package_string[4:] gitstring = dep[4:]
subdirectory = None subdirectory = None
ref = None ref = None
# first check for #subdirectory= on the end # first check for #subdirectory= on the end
@ -62,16 +74,77 @@ def parse_dependency_string(package_string: str) -> DependencySource:
git=gitstring, git=gitstring,
ref=ref, ref=ref,
subdirectory=subdirectory, subdirectory=subdirectory,
api_path=api_path,
event_metadata={"dependency_string": dep},
) )
elif package_string.startswith("https://"): elif dep is not None and dep.startswith("https://"):
raise NotImplementedError("url dependencies are not supported yet") raise ValueError("Only git dependencies are supported")
else: 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 # it's a default git repo dependency
subdirectory = str(Path(DEFAULT_GIT_SUBDIRECTORY) / package_string)
return DependencySource( 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: def _get_repo_path(gitstring: str, repo_dir: Path) -> Path:

@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "langchain-cli" name = "langchain-cli"
version = "0.0.7" version = "0.0.8"
description = "CLI for interacting with LangChain" description = "CLI for interacting with LangChain"
authors = ["Erick Friis <erick@langchain.dev>"] authors = ["Erick Friis <erick@langchain.dev>"]
readme = "README.md" readme = "README.md"

@ -1,3 +1,5 @@
from typing import Dict, Optional
import pytest import pytest
from langchain_cli.constants import ( from langchain_cli.constants import (
@ -8,44 +10,74 @@ from langchain_cli.constants import (
from langchain_cli.utils.git import DependencySource, parse_dependency_string 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: def test_dependency_string() -> None:
assert parse_dependency_string( _assert_dependency_equals(
"git+ssh://git@github.com/efriis/myrepo.git" parse_dependency_string(
) == DependencySource( "git+ssh://git@github.com/efriis/myrepo.git", None, None, None
),
git="ssh://git@github.com/efriis/myrepo.git", git="ssh://git@github.com/efriis/myrepo.git",
ref=None, ref=None,
subdirectory=None, subdirectory=None,
) )
assert parse_dependency_string( _assert_dependency_equals(
"git+https://github.com/efriis/myrepo.git#subdirectory=src" parse_dependency_string(
) == DependencySource( "git+https://github.com/efriis/myrepo.git#subdirectory=src",
None,
None,
None,
),
git="https://github.com/efriis/myrepo.git", git="https://github.com/efriis/myrepo.git",
subdirectory="src", subdirectory="src",
ref=None, ref=None,
) )
assert parse_dependency_string( _assert_dependency_equals(
"git+ssh://git@github.com:efriis/myrepo.git#develop" parse_dependency_string(
) == DependencySource( "git+ssh://git@github.com:efriis/myrepo.git#develop", None, None, None
git="ssh://git@github.com:efriis/myrepo.git", ref="develop", subdirectory=None ),
git="ssh://git@github.com:efriis/myrepo.git",
ref="develop",
subdirectory=None,
) )
# also support a slash in ssh # also support a slash in ssh
assert parse_dependency_string( _assert_dependency_equals(
"git+ssh://git@github.com/efriis/myrepo.git#develop" parse_dependency_string(
) == DependencySource( "git+ssh://git@github.com/efriis/myrepo.git#develop", None, None, None
git="ssh://git@github.com/efriis/myrepo.git", ref="develop", subdirectory=None ),
git="ssh://git@github.com/efriis/myrepo.git",
ref="develop",
subdirectory=None,
) )
# looks like poetry supports both an @ and a # # looks like poetry supports both an @ and a #
assert parse_dependency_string( _assert_dependency_equals(
"git+ssh://git@github.com:efriis/myrepo.git@develop" parse_dependency_string(
) == DependencySource( "git+ssh://git@github.com:efriis/myrepo.git@develop", None, None, None
git="ssh://git@github.com:efriis/myrepo.git", ref="develop", subdirectory=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, git=DEFAULT_GIT_REPO,
subdirectory=f"{DEFAULT_GIT_SUBDIRECTORY}/simple-pirate", subdirectory=f"{DEFAULT_GIT_SUBDIRECTORY}/simple-pirate",
ref=DEFAULT_GIT_REF, ref=DEFAULT_GIT_REF,
@ -53,9 +85,13 @@ def test_dependency_string() -> None:
def test_dependency_string_both() -> None: def test_dependency_string_both() -> None:
assert parse_dependency_string( _assert_dependency_equals(
"git+https://github.com/efriis/myrepo.git@branch#subdirectory=src" parse_dependency_string(
) == DependencySource( "git+https://github.com/efriis/myrepo.git@branch#subdirectory=src",
None,
None,
None,
),
git="https://github.com/efriis/myrepo.git", git="https://github.com/efriis/myrepo.git",
subdirectory="src", subdirectory="src",
ref="branch", ref="branch",
@ -66,7 +102,10 @@ def test_dependency_string_invalids() -> None:
# expect error for wrong order # expect error for wrong order
with pytest.raises(ValueError): with pytest.raises(ValueError):
parse_dependency_string( 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 # 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 # this could be a ssh dep with user=a, and default ref
# or a ssh dep at a with ref=b. # or a ssh dep at a with ref=b.
# in this case, assume the first case (be greedy with the '@') # 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", git="ssh://a@b",
subdirectory=None, subdirectory=None,
ref=None, ref=None,
) )
# weird one that is actually valid # weird one that is actually valid
assert parse_dependency_string( _assert_dependency_equals(
"git+https://github.com/efriis/myrepo.git@subdirectory=src" parse_dependency_string(
) == DependencySource( "git+https://github.com/efriis/myrepo.git@subdirectory=src",
None,
None,
None,
),
git="https://github.com/efriis/myrepo.git", git="https://github.com/efriis/myrepo.git",
subdirectory=None, subdirectory=None,
ref="subdirectory=src", ref="subdirectory=src",

Loading…
Cancel
Save