From 8dba54fa0d96787fc5fa81dfedd89bf2003f1792 Mon Sep 17 00:00:00 2001 From: William Fu-Hinthorn <13333726+hinthornw@users.noreply.github.com> Date: Wed, 20 Nov 2024 17:34:16 -0800 Subject: [PATCH] Add windows CLI testing --- .github/workflows/_integration_test.yml | 62 +++++++++- libs/cli/langgraph_cli/config.py | 150 +++++++++++++----------- 2 files changed, 140 insertions(+), 72 deletions(-) diff --git a/.github/workflows/_integration_test.yml b/.github/workflows/_integration_test.yml index c2d7cb895..e8eb71248 100644 --- a/.github/workflows/_integration_test.yml +++ b/.github/workflows/_integration_test.yml @@ -14,7 +14,7 @@ jobs: python-version: - "3.10" - "3.11" - name: "CLI integration test" + name: "CLI integration test (Linux)" defaults: run: working-directory: libs/cli @@ -71,4 +71,62 @@ jobs: working-directory: libs/cli/js-examples run: | langgraph build -t langgraph-test-e - \ No newline at end of file + + windows-build: + runs-on: windows-latest + name: "CLI integration test (Windows)" + defaults: + run: + working-directory: libs/cli + shell: bash + steps: + - uses: actions/checkout@v4 + - name: Get changed files + id: changed-files + uses: Ana06/get-changed-files@v2.3.0 + with: + filter: "libs/cli/**" + - name: Set up Python 3.11 + Poetry ${{ env.POETRY_VERSION }} + if: steps.changed-files.outputs.all + uses: "./.github/actions/poetry_setup" + with: + python-version: "3.11" + poetry-version: ${{ env.POETRY_VERSION }} + cache-key: integration-test-cli-windows + - name: Setup env + if: steps.changed-files.outputs.all + working-directory: libs/cli/examples + run: cp .env.example .env + - name: Install cli globally + if: steps.changed-files.outputs.all + run: pip install -e . + - name: Build and test service A + if: steps.changed-files.outputs.all + working-directory: libs/cli/examples + run: | + langgraph build -t langgraph-test-a --base-image "langchain/langgraph-trial" + cp .env.example .envg + timeout 60 python ../../../.github/scripts/run_langgraph_cli_test.py -c langgraph.json -t langgraph-test-a + - name: Build and test service B + if: steps.changed-files.outputs.all + working-directory: libs/cli/examples/graphs + run: | + langgraph build -t langgraph-test-b --base-image "langchain/langgraph-trial" + timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-b + - name: Build and test service C + if: steps.changed-files.outputs.all + working-directory: libs/cli/examples/graphs_reqs_a + run: | + langgraph build -t langgraph-test-c --base-image "langchain/langgraph-trial" + timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-c + - name: Build and test service D + if: steps.changed-files.outputs.all + working-directory: libs/cli/examples/graphs_reqs_b + run: | + langgraph build -t langgraph-test-d --base-image "langchain/langgraph-trial" + timeout 60 python ../../../../.github/scripts/run_langgraph_cli_test.py -t langgraph-test-d + - name: Build JS service + if: steps.changed-files.outputs.all + working-directory: libs/cli/js-examples + run: | + langgraph build -t langgraph-test-e \ No newline at end of file diff --git a/libs/cli/langgraph_cli/config.py b/libs/cli/langgraph_cli/config.py index 695b51b63..1edd0761e 100644 --- a/libs/cli/langgraph_cli/config.py +++ b/libs/cli/langgraph_cli/config.py @@ -1,5 +1,4 @@ import json -import os import pathlib import textwrap from typing import NamedTuple, Optional, TypedDict, Union @@ -192,66 +191,76 @@ def check_reserved(name: str, ref: str): resolved = config_path.parent / local_dep # validate local dependency - if not resolved.exists(): - raise FileNotFoundError(f"Could not find local dependency: {resolved}") - elif not resolved.is_dir(): - raise NotADirectoryError( - f"Local dependency must be a directory: {resolved}" - ) - elif not resolved.is_relative_to(config_path.parent): - raise ValueError( - f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'" - ) + try: + if not resolved.exists(): + raise FileNotFoundError(f"Could not find local dependency: {resolved}") + elif not resolved.is_dir(): + raise NotADirectoryError( + f"Local dependency must be a directory: {resolved}" + ) + elif not resolved.is_relative_to(config_path.parent): + raise ValueError( + f"Local dependency '{resolved}' must be a subdirectory of '{config_path.parent}'" + ) - # if it's installable, add it to local_pkgs - # otherwise, add it to faux_pkgs, and create a pyproject.toml - files = os.listdir(resolved) - if "pyproject.toml" in files: - real_pkgs[resolved] = local_dep - if local_dep == ".": - working_dir = f"/deps/{resolved.name}" - elif "setup.py" in files: - real_pkgs[resolved] = local_dep - if local_dep == ".": - working_dir = f"/deps/{resolved.name}" - else: - if any(file == "__init__.py" for file in files): - # flat layout - if "-" in resolved.name: - raise ValueError( - f"Package name '{resolved.name}' contains a hyphen. " - "Rename the directory to use it as flat-layout package." - ) - check_reserved(resolved.name, local_dep) - container_path = f"/deps/__outer_{resolved.name}/{resolved.name}" + # if it's installable, add it to local_pkgs + # otherwise, add it to faux_pkgs, and create a pyproject.toml + try: + files = list(resolved.iterdir()) + file_names = [f.name for f in files] + except (PermissionError, OSError) as e: + raise click.UsageError( + f"Cannot access directory {resolved}: {str(e)}" + ) from None + + if "pyproject.toml" in file_names: + real_pkgs[resolved] = local_dep + if local_dep == ".": + working_dir = f"/deps/{resolved.name}" + elif "setup.py" in file_names: + real_pkgs[resolved] = local_dep + if local_dep == ".": + working_dir = f"/deps/{resolved.name}" else: - # src layout - container_path = f"/deps/__outer_{resolved.name}/src" - for file in files: - rfile = resolved / file - if ( - rfile.is_dir() - and file != "__pycache__" - and not file.startswith(".") - ): - try: - for subfile in os.listdir(rfile): - if subfile.endswith(".py"): - check_reserved(file, local_dep) - break - except PermissionError: - pass - faux_pkgs[resolved] = (local_dep, container_path) - if local_dep == ".": - working_dir = container_path - if "requirements.txt" in files: - rfile = resolved / "requirements.txt" - pip_reqs.append( - ( - pathlib.PurePosixPath(rfile.relative_to(config_path.parent)), - f"{container_path}/requirements.txt", + if any(file == "__init__.py" for file in file_names): + # flat layout + if "-" in resolved.name: + raise ValueError( + f"Package name '{resolved.name}' contains a hyphen. " + "Rename the directory to use it as flat-layout package." + ) + check_reserved(resolved.name, local_dep) + container_path = f"/deps/__outer_{resolved.name}/{resolved.name}" + else: + # src layout + container_path = f"/deps/__outer_{resolved.name}/src" + for file in files: + if ( + file.is_dir() + and file.name != "__pycache__" + and not file.name.startswith(".") + ): + try: + subfiles = list(file.iterdir()) + if any(f.name.endswith(".py") for f in subfiles): + check_reserved(file.name, local_dep) + except (PermissionError, OSError): + continue + faux_pkgs[resolved] = (local_dep, container_path) + if local_dep == ".": + working_dir = container_path + if "requirements.txt" in file_names: + rfile = resolved / "requirements.txt" + pip_reqs.append( + ( + pathlib.PurePosixPath( + rfile.relative_to(config_path.parent) + ), + f"{container_path}/requirements.txt", + ) ) - ) + except (PermissionError, OSError) as e: + raise click.UsageError(f"Cannot access path {resolved}: {str(e)}") from e return LocalDeps(pip_reqs, real_pkgs, faux_pkgs, working_dir) @@ -313,17 +322,17 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag pip_pkgs_str = f"RUN {pip_install} {' '.join(pypi_deps)}" if pypi_deps else "" if local_deps.pip_reqs: - pip_reqs_str = os.linesep.join( + pip_reqs_str = "\n".join( f"ADD {reqpath} {destpath}" for reqpath, destpath in local_deps.pip_reqs ) - pip_reqs_str += f'{os.linesep}RUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}' - + pip_reqs_str += ( + f'\nRUN {pip_install} {" ".join("-r " + r for _,r in local_deps.pip_reqs)}' + ) else: pip_reqs_str = "" - # https://setuptools.pypa.io/en/latest/userguide/datafiles.html#package-data # https://til.simonwillison.net/python/pyproject - faux_pkgs_str = f"{os.linesep}{os.linesep}".join( + faux_pkgs_str = "\n\n".join( f"""ADD {relpath} {destpath} RUN set -ex && \\ for line in '[project]' \\ @@ -335,12 +344,12 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag done""" for fullpath, (relpath, destpath) in local_deps.faux_pkgs.items() ) - local_pkgs_str = os.linesep.join( + local_pkgs_str = "\n".join( f"ADD {relpath} /deps/{fullpath.name}" for fullpath, relpath in local_deps.real_pkgs.items() ) - installs = f"{os.linesep}{os.linesep}".join( + installs = "\n\n".join( filter( None, [ @@ -352,10 +361,11 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag ], ) ) - + _workdir = f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else "" + dockerfile_lines = "\n".join(config["dockerfile_lines"]) return f"""FROM {base_image}:{config['python_version']} -{os.linesep.join(config["dockerfile_lines"])} +{dockerfile_lines} {installs} @@ -363,7 +373,7 @@ def python_config_to_docker(config_path: pathlib.Path, config: Config, base_imag ENV LANGSERVE_GRAPHS='{json.dumps(config["graphs"])}' -{f"WORKDIR {local_deps.working_dir}" if local_deps.working_dir else ""}""" +{_workdir}""" def node_config_to_docker(config_path: pathlib.Path, config: Config, base_image: str): @@ -390,10 +400,10 @@ def test_file(file_name): install_cmd = "npm ci" else: install_cmd = "npm i" - + dockerfile_lines = "\n".join(config["dockerfile_lines"]) return f"""FROM {base_image}:{config['node_version']} -{os.linesep.join(config["dockerfile_lines"])} +{dockerfile_lines} ADD . {faux_path}