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 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()

@ -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:

@ -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 <erick@langchain.dev>"]
readme = "README.md"

@ -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",

Loading…
Cancel
Save