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 9 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,45 +97,36 @@ 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
""" """
project_root = get_package_root(project_dir)
if dependencies is None:
dependencies = []
# cannot have both repo and dependencies parsed_deps = parse_dependencies(dependencies, repo, branch, api_path)
if len(repo) != 0:
if len(dependencies) != 0:
raise typer.BadParameter(
"Cannot specify both repo and dependencies. "
"Please specify one or the other."
)
dependencies = [f"git+https://github.com/{r}" for r in repo]
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."
)
# get installed packages from pyproject.toml project_root = get_package_root(project_dir)
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())
package_dir = project_root / "packages" package_dir = project_root / "packages"
create_events( create_events(
[{"event": "serve add", "properties": {"package": d}} for d in dependencies] [{"event": "serve add", "properties": dict(parsed_dep=d)} for d in parsed_deps]
) )
for i, dependency in enumerate(dependencies): # group by repo/ref
# update repo grouped: Dict[Tuple[str, Optional[str]], List[DependencySource]] = {}
typer.echo(f"Adding {dependency}...") for dep in parsed_deps:
dep = parse_dependency_string(dependency) key_tup = (dep["git"], dep["ref"])
source_repo_path = update_repo(dep["git"], dep["ref"], REPO_DIR) lst = grouped.get(key_tup, [])
lst.append(dep)
grouped[key_tup] = lst
installed_destination_paths: List[Path] = []
installed_exports: List[Dict] = []
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_path = (
source_repo_path / dep["subdirectory"] source_repo_path / dep["subdirectory"]
if dep["subdirectory"] if dep["subdirectory"]
@ -144,30 +138,71 @@ def add(
continue continue
langserve_export = get_langserve_export(pyproject_path) langserve_export = get_langserve_export(pyproject_path)
# detect name conflict # default path to package_name
if langserve_export["package_name"] in installed_names: inner_api_path = dep["api_path"] or langserve_export["package_name"]
typer.echo(
f"Package with name {langserve_export['package_name']} already "
"installed. Skipping...",
)
continue
inner_api_path = (
api_path[i] if len(api_path) != 0 else langserve_export["package_name"]
)
destination_path = package_dir / inner_api_path destination_path = package_dir / inner_api_path
if destination_path.exists(): if destination_path.exists():
typer.echo( typer.echo(
f"Endpoint {langserve_export['package_name']} already exists. " f"Folder {str(inner_api_path)} already exists. " "Skipping...",
"Skipping...",
) )
continue continue
copy_repo(source_path, destination_path) copy_repo(source_path, destination_path)
# poetry install typer.echo(f" - Downloaded {dep['subdirectory']} to {inner_api_path}")
installed_destination_paths.append(destination_path)
installed_exports.append(langserve_export)
if len(installed_destination_paths) == 0:
typer.echo("No packages installed. Exiting.")
return
cwd = Path.cwd()
installed_desination_strs = [
str(p.relative_to(cwd)) for p in installed_destination_paths
]
if with_poetry: if with_poetry:
subprocess.run( subprocess.run(
["poetry", "add", "--editable", destination_path], cwd=project_root ["poetry", "add", "--editable"] + installed_desination_strs,
cwd=cwd,
)
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)
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)
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
) )
typer.echo("\n".join(lines))
@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