Co-authored-by: Harrison Chase <hw.chase.17@gmail.com>
This commit is contained in:
Erick Friis
2023-10-25 11:06:58 -07:00
committed by GitHub
parent 07c2649753
commit 47070b8314
31 changed files with 5549 additions and 0 deletions

View File

View File

@@ -0,0 +1,36 @@
import typer
import subprocess
from typing import Optional
from typing_extensions import Annotated
from langchain_cli.namespaces import hub
from langchain_cli.namespaces import serve
app = typer.Typer(no_args_is_help=True, add_completion=False)
app.add_typer(hub.hub, name="hub", help=hub.__doc__)
app.add_typer(serve.serve, name="serve", help=serve.__doc__)
@app.command()
def start(
*,
port: Annotated[
Optional[int], typer.Option(help="The port to run the server on")
] = None,
host: Annotated[
Optional[str], typer.Option(help="The host to run the server on")
] = None,
) -> None:
"""
Start the LangServe instance, whether it's a hub package or a serve project.
"""
cmd = ["poetry", "run", "poe", "start"]
if port is not None:
cmd += ["--port", str(port)]
if host is not None:
cmd += ["--host", host]
subprocess.run(cmd)
if __name__ == "__main__":
app()

View File

@@ -0,0 +1,2 @@
DEFAULT_GIT_REPO = "https://github.com/langchain-ai/langchain.git"
DEFAULT_GIT_SUBDIRECTORY = "templates"

View File

@@ -0,0 +1,17 @@
"""
Development Scripts for Hub Packages
"""
from fastapi import FastAPI
from langserve.packages import add_package_route
from langchain_cli.utils.packages import get_package_root
def create_demo_server():
"""
Creates a demo server for the current hub package.
"""
app = FastAPI()
package_root = get_package_root()
add_package_route(app, package_root, "")
return app

View File

@@ -0,0 +1,89 @@
"""
Manage installable hub packages.
"""
import typer
from typing import Optional
from typing_extensions import Annotated
from pathlib import Path
import shutil
import subprocess
import re
hub = typer.Typer(no_args_is_help=True, add_completion=False)
@hub.command()
def new(
name: Annotated[str, typer.Argument(help="The name of the folder to create")],
with_poetry: Annotated[
bool,
typer.Option(
"--with-poetry/--no-poetry", help="Don't run poetry install"
),
] = False,
):
"""
Creates a new hub package.
"""
computed_name = name if name != "." else Path.cwd().name
destination_dir = Path.cwd() / name if name != "." else Path.cwd()
# copy over template from ../package_template
project_template_dir = Path(__file__).parent.parent.parent / "package_template"
shutil.copytree(project_template_dir, destination_dir, dirs_exist_ok=name == ".")
package_name_split = computed_name.split("/")
package_name_last = (
package_name_split[-2]
if len(package_name_split) > 1 and package_name_split[-1] == ""
else package_name_split[-1]
)
default_package_name = re.sub(
r"[^a-zA-Z0-9_]",
"_",
package_name_last,
)
# replace template strings
pyproject = destination_dir / "pyproject.toml"
pyproject_contents = pyproject.read_text()
pyproject.write_text(
pyproject_contents.replace("__package_name__", default_package_name)
)
# move module folder
package_dir = destination_dir / default_package_name
shutil.move(destination_dir / "package_template", package_dir)
# replace readme
readme = destination_dir / "README.md"
readme_contents = readme.read_text()
readme.write_text(
readme_contents.replace("__package_name_last__", package_name_last)
)
# poetry install
if with_poetry:
subprocess.run(["poetry", "install"], cwd=destination_dir)
@hub.command()
def start(
*,
port: Annotated[
Optional[int], typer.Option(help="The port to run the server on")
] = None,
host: Annotated[
Optional[str], typer.Option(help="The host to run the server on")
] = None,
) -> None:
"""
Starts a demo LangServe instance for this hub package.
"""
cmd = ["poetry", "run", "poe", "start"]
if port is not None:
cmd += ["--port", str(port)]
if host is not None:
cmd += ["--host", host]
subprocess.run(cmd)

View File

@@ -0,0 +1,218 @@
"""
Manage LangServe application projects.
"""
import typer
from typing import Optional, List
from typing_extensions import Annotated
from pathlib import Path
import shutil
import subprocess
from langchain_cli.utils.git import copy_repo, update_repo
from langchain_cli.utils.packages import get_package_root
from langchain_cli.utils.events import create_events
from langserve.packages import list_packages, get_langserve_export
import tomli
REPO_DIR = Path(typer.get_app_dir("langchain")) / "git_repos"
serve = typer.Typer(no_args_is_help=True, add_completion=False)
@serve.command()
def new(
name: Annotated[str, typer.Argument(help="The name of the folder to create")],
*,
package: Annotated[
Optional[List[str]],
typer.Option(help="Packages to seed the project with"),
] = None,
with_poetry: Annotated[
bool,
typer.Option(
"--with-poetry/--no-poetry", help="Run poetry install"
),
] = False,
):
"""
Create a new LangServe application.
"""
# copy over template from ../project_template
project_template_dir = Path(__file__).parent.parent.parent / "project_template"
destination_dir = Path.cwd() / name if name != "." else Path.cwd()
shutil.copytree(project_template_dir, destination_dir, dirs_exist_ok=name == ".")
# poetry install
if with_poetry:
subprocess.run(["poetry", "install"], cwd=destination_dir)
# add packages if specified
if package is not None and len(package) > 0:
add(package, project_dir=destination_dir, with_poetry=with_poetry)
@serve.command()
def install():
package_root = get_package_root() / "packages"
for package_path in list_packages(package_root):
try:
pyproject_path = package_path / "pyproject.toml"
langserve_export = get_langserve_export(pyproject_path)
typer.echo(f"Installing {langserve_export['package_name']}...")
subprocess.run(["poetry", "add", "--editable", package_path])
except Exception as e:
typer.echo(f"Skipping installing {package_path} due to error: {e}")
@serve.command()
def add(
dependencies: Annotated[
Optional[List[str]], typer.Argument(help="The dependency to add")
] = None,
*,
api_path: Annotated[List[str], typer.Option(help="API paths to add")] = [],
project_dir: Annotated[
Optional[Path], typer.Option(help="The project directory")
] = None,
repo: Annotated[
List[str], typer.Option(help="Shorthand for installing a GitHub Repo")
] = [],
with_poetry: Annotated[
bool,
typer.Option(
"--with-poetry/--no-poetry", help="Run poetry install"
),
] = False,
):
"""
Adds the specified package to the current LangServe instance.
e.g.:
langchain serve add simple-pirate
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
"""
project_root = get_package_root(project_dir)
if dependencies is None:
dependencies = []
# 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."
)
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
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"
create_events(
[{"event": "serve add", "properties": {"package": d}} for d in dependencies]
)
for i, dependency in enumerate(dependencies):
# update repo
typer.echo(f"Adding {dependency}...")
source_path = update_repo(dependency, REPO_DIR)
pyproject_path = source_path / "pyproject.toml"
langserve_export = get_langserve_export(pyproject_path)
# 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
inner_api_path = (
api_path[i] if len(api_path) != 0 else langserve_export["package_name"]
)
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
)
@serve.command()
def remove(
api_paths: Annotated[List[str], typer.Argument(help="The API paths to remove")],
with_poetry: Annotated[
bool,
typer.Option(
"--with_poetry/--no-poetry", help="Don't run poetry remove"
),
] = False,
):
"""
Removes the specified package from the current LangServe instance.
"""
for api_path in api_paths:
package_dir = Path.cwd() / "packages" / api_path
if not package_dir.exists():
typer.echo(f"Endpoint {api_path} does not exist. Skipping...")
continue
pyproject = package_dir / "pyproject.toml"
langserve_export = get_langserve_export(pyproject)
typer.echo(f"Removing {langserve_export['package_name']}...")
if with_poetry:
subprocess.run(["poetry", "remove", langserve_export["package_name"]])
shutil.rmtree(package_dir)
@serve.command()
def list():
"""
Lists all packages in the current LangServe instance.
"""
package_root = get_package_root() / "packages"
for package_path in list_packages(package_root):
relative = package_path.relative_to(package_root)
pyproject_path = package_path / "pyproject.toml"
langserve_export = get_langserve_export(pyproject_path)
typer.echo(
f"{relative}: ({langserve_export['module']}.{langserve_export['attr']})"
)
@serve.command()
def start(
*,
port: Annotated[
Optional[int], typer.Option(help="The port to run the server on")
] = None,
host: Annotated[
Optional[str], typer.Option(help="The host to run the server on")
] = None,
) -> None:
"""
Starts the LangServe instance.
"""
cmd = ["poetry", "run", "poe", "start"]
if port is not None:
cmd += ["--port", str(port)]
if host is not None:
cmd += ["--host", host]
subprocess.run(cmd)

View File

View File

@@ -0,0 +1,52 @@
import urllib3
import json
from typing import List, Dict, Any, Optional, TypedDict
WRITE_KEY = "310apTK0HUFl4AOv"
class EventDict(TypedDict):
event: str
properties: Optional[Dict[str, Any]]
def create_event(event: EventDict) -> None:
"""
Creates a new event with the given type and payload.
"""
data = {
"write_key": WRITE_KEY,
"event": event["event"],
"properties": event.get("properties"),
}
try:
urllib3.request(
"POST",
"https://app.firstpartyhq.com/events/v1/track",
body=json.dumps(data),
headers={"Content-Type": "application/json"},
)
except Exception:
pass
def create_events(events: List[EventDict]) -> None:
data = {
"events": [
{
"write_key": WRITE_KEY,
"event": event["event"],
"properties": event.get("properties"),
}
for event in events
]
}
try:
urllib3.request(
"POST",
"https://app.firstpartyhq.com/events/v1/track/bulk",
body=json.dumps(data),
headers={"Content-Type": "application/json"},
)
except Exception:
pass

View File

@@ -0,0 +1,130 @@
from typing import Optional, TypedDict
from pathlib import Path
import shutil
import re
from langchain_cli.constants import DEFAULT_GIT_REPO, DEFAULT_GIT_SUBDIRECTORY
import hashlib
from git import Repo
class DependencySource(TypedDict):
git: str
ref: Optional[str]
subdirectory: Optional[str]
def _get_main_branch(repo: Repo) -> Optional[str]:
"""
Get the name of the main branch of a git repo.
From https://stackoverflow.com/questions/69651536/how-to-get-master-main-branch-from-gitpython
"""
try:
# replace "origin" with your remote name if differs
show_result = repo.git.remote("show", "origin")
# The show_result contains a wall of text in the language that
# is set by your locales. Now you can use regex to extract the
# default branch name, but if your language is different
# from english, you need to adjust this regex pattern.
matches = re.search(r"\s*HEAD branch:\s*(.*)", show_result)
if matches:
default_branch = matches.group(1)
return default_branch
except Exception:
pass
# fallback to main/master
if "main" in repo.heads:
return "main"
if "master" in repo.heads:
return "master"
raise ValueError("Could not find main branch")
# use poetry dependency string format
def _parse_dependency_string(package_string: str) -> DependencySource:
if package_string.startswith("git+"):
# remove git+
remaining = package_string[4:]
# split main string from params
gitstring, *params = remaining.split("#")
# parse params
params_dict = {}
for param in params:
if not param:
# ignore empty entries
continue
if "=" in param:
key, value = param.split("=")
if key in params_dict:
raise ValueError(
f"Duplicate parameter {key} in dependency string {package_string}"
)
params_dict[key] = value
else:
if "ref" in params_dict:
raise ValueError(
f"Duplicate parameter ref in dependency string {package_string}"
)
params_dict["ref"] = param
return DependencySource(
git=gitstring,
ref=params_dict.get("ref"),
subdirectory=params_dict.get("subdirectory"),
)
elif package_string.startswith("https://"):
raise NotImplementedError("url dependencies are not supported yet")
else:
# it's a default git repo dependency
gitstring = DEFAULT_GIT_REPO
subdirectory = str(Path(DEFAULT_GIT_SUBDIRECTORY) / package_string)
return DependencySource(git=gitstring, ref=None, subdirectory=subdirectory)
def _get_repo_path(dependency: DependencySource, repo_dir: Path) -> Path:
# only based on git for now
gitstring = dependency["git"]
hashed = hashlib.sha256(gitstring.encode("utf-8")).hexdigest()[:8]
removed_protocol = gitstring.split("://")[-1]
removed_basename = re.split(r"[/:]", removed_protocol, 1)[-1]
removed_extras = removed_basename.split("#")[0]
foldername = re.sub(r"[^a-zA-Z0-9_]", "_", removed_extras)
directory_name = f"{foldername}_{hashed}"
return repo_dir / directory_name
def update_repo(gitpath: str, repo_dir: Path) -> Path:
# see if path already saved
dependency = _parse_dependency_string(gitpath)
repo_path = _get_repo_path(dependency, repo_dir)
if not repo_path.exists():
repo = Repo.clone_from(dependency["git"], repo_path)
else:
repo = Repo(repo_path)
# pull it
ref = dependency.get("ref") if dependency.get("ref") else _get_main_branch(repo)
repo.git.checkout(ref)
repo.git.pull()
return (
repo_path
if dependency["subdirectory"] is None
else repo_path / dependency["subdirectory"]
)
def copy_repo(
source: Path,
destination: Path,
) -> None:
def ignore_func(_, files):
return [f for f in files if f == ".git"]
shutil.copytree(source, destination, ignore=ignore_func)

View File

@@ -0,0 +1,16 @@
from pathlib import Path
from typing import Set, Optional
def get_package_root(cwd: Optional[Path] = None) -> Path:
# traverse path for routes to host (any directory holding a pyproject.toml file)
package_root = Path.cwd() if cwd is None else cwd
visited: Set[Path] = set()
while package_root not in visited:
visited.add(package_root)
pyproject_path = package_root / "pyproject.toml"
if pyproject_path.exists():
return package_root
package_root = package_root.parent
raise FileNotFoundError("No pyproject.toml found")