refactor(cli): drop Python 3.9 (#32964)

This commit is contained in:
Mason Daugherty
2025-09-15 19:22:53 -04:00
committed by GitHub
parent 369858de19
commit 244c699551
18 changed files with 634 additions and 730 deletions

View File

@@ -134,6 +134,8 @@ def _get_configs_for_single_dir(job: str, dir_: str) -> List[Dict[str, str]]:
py_versions = ["3.9", "3.13"] py_versions = ["3.9", "3.13"]
elif dir_ == "libs/langchain_v1": elif dir_ == "libs/langchain_v1":
py_versions = ["3.10", "3.13"] py_versions = ["3.10", "3.13"]
elif dir_ in {"libs/cli"}:
py_versions = ["3.10", "3.13"]
elif dir_ == ".": elif dir_ == ".":
# unable to install with 3.13 because tokenizers doesn't support 3.13 yet # unable to install with 3.13 because tokenizers doesn't support 3.13 yet

View File

@@ -38,15 +38,16 @@ _e2e_test:
mkdir .integration_test mkdir .integration_test
cd .integration_test && \ cd .integration_test && \
python3 -m venv .venv && \ python3 -m venv .venv && \
pip install --upgrade poetry && \ $(PYTHON) -m pip install --upgrade uv && \
$(PYTHON) -m pip install -e .. && \ $(PYTHON) -m pip install -e .. && \
$(PYTHON) -m langchain_cli.cli integration new --name parrot-link --name-class ParrotLink && \ $(PYTHON) -m langchain_cli.cli integration new --name parrot-link --name-class ParrotLink && \
$(PYTHON) -m langchain_cli.cli integration new --name parrot-link --name-class ParrotLinkB --src=integration_template/chat_models.py --dst=langchain-parrot-link/langchain_parrot_link/chat_models_b.py && \ $(PYTHON) -m langchain_cli.cli integration new --name parrot-link --name-class ParrotLinkB --src=integration_template/chat_models.py --dst=langchain-parrot-link/langchain_parrot_link/chat_models_b.py && \
$(PYTHON) -m langchain_cli.cli integration create-doc --name parrot-link --name-class ParrotLinkB --component-type ChatModel --destination-dir langchain-parrot-link/docs && \ $(PYTHON) -m langchain_cli.cli integration create-doc --name parrot-link --name-class ParrotLinkB --component-type ChatModel --destination-dir langchain-parrot-link/docs && \
cd langchain-parrot-link && \ cd langchain-parrot-link && \
poetry install --with lint,typing,test && \ unset UV_FROZEN && \
poetry run pip install -e ../../../standard-tests && \ unset VIRTUAL_ENV && \
uv sync && \
uv add --editable ../../../standard-tests && \
make format lint tests && \ make format lint tests && \
poetry install --with test_integration && \ uv add --editable ../../../core && \
poetry run pip install -e ../../../core && \
make integration_test make integration_test

View File

@@ -1,6 +1,8 @@
"""LangChain CLI.""" """LangChain CLI."""
from typing import Annotated, Optional from __future__ import annotations
from typing import Annotated
import typer import typer
@@ -61,11 +63,11 @@ def _main(
def serve( def serve(
*, *,
port: Annotated[ port: Annotated[
Optional[int], int | None,
typer.Option(help="The port to run the server on"), typer.Option(help="The port to run the server on"),
] = None, ] = None,
host: Annotated[ host: Annotated[
Optional[str], str | None,
typer.Option(help="The host to run the server on"), typer.Option(help="The host to run the server on"),
] = None, ] = None,
) -> None: ) -> None:

View File

@@ -10,14 +10,14 @@ integration_test integration_tests: TEST_FILE = tests/integration_tests/
# unit tests are run with the --disable-socket flag to prevent network calls # unit tests are run with the --disable-socket flag to prevent network calls
test tests: test tests:
poetry run pytest --disable-socket --allow-unix-socket $(TEST_FILE) uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE)
test_watch: test_watch:
poetry run ptw --snapshot-update --now . -- -vv $(TEST_FILE) uv run ptw --snapshot-update --now . -- -vv $(TEST_FILE)
# integration tests are run without the --disable-socket flag to allow network calls # integration tests are run without the --disable-socket flag to allow network calls
integration_test integration_tests: integration_test integration_tests:
poetry run pytest $(TEST_FILE) uv run pytest $(TEST_FILE)
###################### ######################
# LINTING AND FORMATTING # LINTING AND FORMATTING
@@ -33,22 +33,22 @@ lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests: lint lint_diff lint_package lint_tests:
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check $(PYTHON_FILES) [ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE) [ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff: format format_diff:
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) [ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check --fix $(PYTHON_FILES) [ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES)
spell_check: spell_check:
poetry run codespell --toml pyproject.toml uv run codespell --toml pyproject.toml
spell_fix: spell_fix:
poetry run codespell --toml pyproject.toml -w uv run codespell --toml pyproject.toml -w
check_imports: $(shell find __module_name__ -name '*.py') check_imports: $(shell find __module_name__ -name '*.py')
poetry run python ./scripts/check_imports.py $^ uv run python ./scripts/check_imports.py $^
###################### ######################
# HELP # HELP

View File

@@ -89,7 +89,7 @@ class Chat__ModuleName__(BaseChatModel):
.. code-block:: python .. code-block:: python
for chunk in llm.stream(messages): for chunk in llm.stream(messages):
print(chunk.text(), end="") print(chunk.text, end="")
.. code-block:: python .. code-block:: python

View File

@@ -1,26 +1,38 @@
[build-system] [build-system]
requires = ["poetry-core>=1.0.0"] requires = ["pdm-backend"]
build-backend = "poetry.core.masonry.api" build-backend = "pdm.backend"
[tool.poetry] [project]
name = "__package_name__" name = "__package_name__"
version = "0.1.0" version = "0.1.0"
description = "An integration package connecting __ModuleName__ and LangChain" description = "An integration package connecting __ModuleName__ and LangChain"
authors = [] authors = []
readme = "README.md" readme = "README.md"
repository = "https://github.com/langchain-ai/langchain"
license = "MIT" license = "MIT"
requires-python = ">=3.10"
dependencies = [
"langchain-core>=0.3.15",
]
[project.urls]
"Source Code" = "https://github.com/langchain-ai/langchain/tree/master/libs/partners/__package_name_short__"
"Release Notes" = "https://github.com/langchain-ai/langchain/releases?q=tag%3A%22__package_name_short__%3D%3D0%22&expanded=true"
"Repository" = "https://github.com/langchain-ai/langchain"
[tool.mypy] [tool.mypy]
disallow_untyped_defs = "True" disallow_untyped_defs = "True"
[tool.poetry.urls] [tool.uv]
"Source Code" = "https://github.com/langchain-ai/langchain/tree/master/libs/partners/__package_name_short__" dev-dependencies = [
"Release Notes" = "https://github.com/langchain-ai/langchain/releases?q=tag%3A%22__package_name_short__%3D%3D0%22&expanded=true" "pytest>=7.4.3",
"pytest-asyncio>=0.23.2",
[tool.poetry.dependencies] "pytest-socket>=0.7.0",
python = ">=3.9,<4.0" "pytest-watcher>=0.3.4",
langchain-core = "^0.3.15" "langchain-tests>=0.3.5",
"codespell>=2.2.6",
"ruff>=0.5",
"mypy>=1.10",
]
[tool.ruff.lint] [tool.ruff.lint]
select = ["E", "F", "I", "T201"] select = ["E", "F", "I", "T201"]
@@ -37,38 +49,3 @@ markers = [
"compile: mark placeholder test used to compile integration tests without running them", "compile: mark placeholder test used to compile integration tests without running them",
] ]
asyncio_mode = "auto" asyncio_mode = "auto"
[tool.poetry.group.test]
optional = true
[tool.poetry.group.codespell]
optional = true
[tool.poetry.group.test_integration]
optional = true
[tool.poetry.group.lint]
optional = true
[tool.poetry.group.dev]
optional = true
[tool.poetry.group.dev.dependencies]
[tool.poetry.group.test.dependencies]
pytest = "^7.4.3"
pytest-asyncio = "^0.23.2"
pytest-socket = "^0.7.0"
pytest-watcher = "^0.3.4"
langchain-tests = "^0.3.5"
[tool.poetry.group.codespell.dependencies]
codespell = "^2.2.6"
[tool.poetry.group.test_integration.dependencies]
[tool.poetry.group.lint.dependencies]
ruff = "^0.5"
[tool.poetry.group.typing.dependencies]
mypy = "^1.10"

View File

@@ -50,7 +50,6 @@ def new(
typer.Option( typer.Option(
"--pip/--no-pip", "--pip/--no-pip",
help="Pip install the template(s) as editable dependencies", help="Pip install the template(s) as editable dependencies",
is_flag=True,
), ),
] = None, ] = None,
noninteractive: Annotated[ noninteractive: Annotated[
@@ -58,7 +57,6 @@ def new(
typer.Option( typer.Option(
"--non-interactive/--interactive", "--non-interactive/--interactive",
help="Don't prompt for any input", help="Don't prompt for any input",
is_flag=True,
), ),
] = False, ] = False,
) -> None: ) -> None:
@@ -154,7 +152,6 @@ def add(
typer.Option( typer.Option(
"--pip/--no-pip", "--pip/--no-pip",
help="Pip install the template(s) as editable dependencies", help="Pip install the template(s) as editable dependencies",
is_flag=True,
prompt="Would you like to `pip install -e` the template(s)?", prompt="Would you like to `pip install -e` the template(s)?",
), ),
], ],
@@ -241,7 +238,7 @@ def add(
try: try:
add_dependencies_to_pyproject_toml( add_dependencies_to_pyproject_toml(
project_root / "pyproject.toml", project_root / "pyproject.toml",
zip(installed_destination_names, installed_destination_paths), zip(installed_destination_names, installed_destination_paths, strict=False),
) )
except Exception: except Exception:
# Can fail if user modified/removed pyproject.toml # Can fail if user modified/removed pyproject.toml
@@ -279,11 +276,11 @@ def add(
imports = [ imports = [
f"from {e['module']} import {e['attr']} as {name}" f"from {e['module']} import {e['attr']} as {name}"
for e, name in zip(installed_exports, chain_names) for e, name in zip(installed_exports, chain_names, strict=False)
] ]
routes = [ routes = [
f'add_routes(app, {name}, path="{path}")' f'add_routes(app, {name}, path="{path}")'
for name, path in zip(chain_names, api_paths) for name, path in zip(chain_names, api_paths, strict=False)
] ]
t = ( t = (

View File

@@ -1,5 +1,6 @@
"""Develop integration packages for LangChain.""" """Develop integration packages for LangChain."""
import os
import re import re
import shutil import shutil
import subprocess import subprocess
@@ -125,12 +126,28 @@ def new(
# replacements in files # replacements in files
replace_glob(destination_dir, "**/*", cast("dict[str, str]", replacements)) replace_glob(destination_dir, "**/*", cast("dict[str, str]", replacements))
# poetry install # dependency install
subprocess.run( try:
["poetry", "install", "--with", "lint,test,typing,test_integration"], # noqa: S607 # Use --no-progress to avoid tty issues in CI/test environments
cwd=destination_dir, env = os.environ.copy()
check=True, env.pop("UV_FROZEN", None)
) env.pop("VIRTUAL_ENV", None)
subprocess.run(
["uv", "sync", "--dev", "--no-progress"], # noqa: S607
cwd=destination_dir,
check=True,
env=env,
)
except FileNotFoundError:
typer.echo(
"uv is not installed. Skipping dependency installation; run "
"`uv sync --dev` manually if needed.",
)
except subprocess.CalledProcessError:
typer.echo(
"Failed to install dependencies. You may need to run "
"`uv sync --dev` manually in the package directory.",
)
else: else:
# confirm src and dst are the same length # confirm src and dst are the same length
if not src: if not src:
@@ -166,7 +183,7 @@ def new(
typer.echo(f"File {dst_path} exists.") typer.echo(f"File {dst_path} exists.")
raise typer.Exit(code=1) raise typer.Exit(code=1)
for src_path, dst_path in zip(src_paths, dst_paths): for src_path, dst_path in zip(src_paths, dst_paths, strict=False):
shutil.copy(src_path, dst_path) shutil.copy(src_path, dst_path)
replace_file(dst_path, cast("dict[str, str]", replacements)) replace_file(dst_path, cast("dict[str, str]", replacements))

View File

@@ -1,8 +1,10 @@
"""Events utilities.""" """Events utilities."""
from __future__ import annotations
import http.client import http.client
import json import json
from typing import Any, Optional, TypedDict from typing import Any, TypedDict
import typer import typer
@@ -18,10 +20,10 @@ class EventDict(TypedDict):
""" """
event: str event: str
properties: Optional[dict[str, Any]] properties: dict[str, Any] | None
def create_events(events: list[EventDict]) -> Optional[dict[str, Any]]: def create_events(events: list[EventDict]) -> dict[str, Any] | None:
"""Create events. """Create events.
Args: Args:

View File

@@ -1,12 +1,14 @@
"""Git utilities.""" """Git utilities."""
from __future__ import annotations
import hashlib import hashlib
import logging import logging
import re import re
import shutil import shutil
from collections.abc import Sequence from collections.abc import Sequence
from pathlib import Path from pathlib import Path
from typing import Any, Optional, TypedDict from typing import Any, TypedDict
from git import Repo from git import Repo
@@ -23,18 +25,18 @@ class DependencySource(TypedDict):
"""Dependency source information.""" """Dependency source information."""
git: str git: str
ref: Optional[str] ref: str | None
subdirectory: Optional[str] subdirectory: str | None
api_path: Optional[str] api_path: str | None
event_metadata: dict[str, Any] event_metadata: dict[str, Any]
# use poetry dependency string format # use poetry dependency string format
def parse_dependency_string( def parse_dependency_string(
dep: Optional[str], dep: str | None,
repo: Optional[str], repo: str | None,
branch: Optional[str], branch: str | None,
api_path: Optional[str], api_path: str | None,
) -> DependencySource: ) -> DependencySource:
"""Parse a dependency string into a DependencySource. """Parse a dependency string into a DependencySource.
@@ -125,7 +127,7 @@ def parse_dependency_string(
) )
def _list_arg_to_length(arg: Optional[list[str]], num: int) -> Sequence[Optional[str]]: def _list_arg_to_length(arg: list[str] | None, num: int) -> Sequence[str | None]:
if not arg: if not arg:
return [None] * num return [None] * num
if len(arg) == 1: if len(arg) == 1:
@@ -137,7 +139,7 @@ def _list_arg_to_length(arg: Optional[list[str]], num: int) -> Sequence[Optional
def parse_dependencies( def parse_dependencies(
dependencies: Optional[list[str]], dependencies: list[str] | None,
repo: list[str], repo: list[str],
branch: list[str], branch: list[str],
api_path: list[str], api_path: list[str],
@@ -180,17 +182,18 @@ def parse_dependencies(
inner_branches = _list_arg_to_length(branch, num_deps) inner_branches = _list_arg_to_length(branch, num_deps)
return list( return list(
map( map( # type: ignore[call-overload]
parse_dependency_string, parse_dependency_string,
inner_deps, inner_deps,
inner_repos, inner_repos,
inner_branches, inner_branches,
inner_api_paths, inner_api_paths,
strict=False,
) )
) )
def _get_repo_path(gitstring: str, ref: Optional[str], repo_dir: Path) -> Path: def _get_repo_path(gitstring: str, ref: str | None, repo_dir: Path) -> Path:
# only based on git for now # only based on git for now
ref_str = ref if ref is not None else "" ref_str = ref if ref is not None else ""
hashed = hashlib.sha256((f"{gitstring}:{ref_str}").encode()).hexdigest()[:8] hashed = hashlib.sha256((f"{gitstring}:{ref_str}").encode()).hexdigest()[:8]
@@ -204,7 +207,7 @@ def _get_repo_path(gitstring: str, ref: Optional[str], repo_dir: Path) -> Path:
return repo_dir / directory_name return repo_dir / directory_name
def update_repo(gitstring: str, ref: Optional[str], repo_dir: Path) -> Path: def update_repo(gitstring: str, ref: str | None, repo_dir: Path) -> Path:
"""Update a git repository to the specified ref. """Update a git repository to the specified ref.
Tries to pull if the repo already exists, otherwise clones it. Tries to pull if the repo already exists, otherwise clones it.

View File

@@ -1,11 +1,12 @@
"""GitHub utilities.""" """GitHub utilities."""
from __future__ import annotations
import http.client import http.client
import json import json
from typing import Optional
def list_packages(*, contains: Optional[str] = None) -> list[str]: def list_packages(*, contains: str | None = None) -> list[str]:
"""List all packages in the langchain repository templates directory. """List all packages in the langchain repository templates directory.
Args: Args:

View File

@@ -1,12 +1,14 @@
"""Packages utilities.""" """Packages utilities."""
from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Optional, TypedDict from typing import Any, TypedDict, cast
from tomlkit import load from tomlkit import load
def get_package_root(cwd: Optional[Path] = None) -> Path: def get_package_root(cwd: Path | None = None) -> Path:
"""Get package root directory. """Get package root directory.
Args: Args:
@@ -62,11 +64,12 @@ def get_langserve_export(filepath: Path) -> LangServeExport:
KeyError: If the `pyproject.toml` file is missing required fields. KeyError: If the `pyproject.toml` file is missing required fields.
""" """
with filepath.open() as f: with filepath.open() as f:
data: dict[str, Any] = load(f) # tomlkit types aren't amazing - treat as Dict instead
data = cast("dict[str, Any]", load(f))
try: try:
module = data["tool"]["langserve"]["export_module"] module = str(data["tool"]["langserve"]["export_module"])
attr = data["tool"]["langserve"]["export_attr"] attr = str(data["tool"]["langserve"]["export_attr"])
package_name = data["tool"]["poetry"]["name"] package_name = str(data["tool"]["poetry"]["name"])
except KeyError as e: except KeyError as e:
msg = "Invalid LangServe PyProject.toml" msg = "Invalid LangServe PyProject.toml"
raise KeyError(msg) from e raise KeyError(msg) from e

View File

@@ -5,9 +5,9 @@ build-backend = "pdm.backend"
[project] [project]
authors = [{ name = "Erick Friis", email = "erick@langchain.dev" }] authors = [{ name = "Erick Friis", email = "erick@langchain.dev" }]
license = { text = "MIT" } license = { text = "MIT" }
requires-python = ">=3.9" requires-python = ">=3.10"
dependencies = [ dependencies = [
"typer<1.0.0,>=0.9.0", "typer<1.0.0,>=0.17",
"gitpython<4,>=3", "gitpython<4,>=3",
"langserve[all]>=0.0.51", "langserve[all]>=0.0.51",
"uvicorn<1.0,>=0.23", "uvicorn<1.0,>=0.23",
@@ -80,6 +80,12 @@ pyupgrade.keep-runtime-typing = true
"tests/**" = [ "D1", "DOC", "S", "SLF",] "tests/**" = [ "D1", "DOC", "S", "SLF",]
"scripts/**" = [ "INP", "S",] "scripts/**" = [ "INP", "S",]
[tool.pytest.ini_options]
addopts = "--strict-markers --strict-config --durations=5"
markers = [
"compile: mark placeholder test used to compile integration tests without running them",
]
[tool.mypy] [tool.mypy]
plugins = ["pydantic.mypy"] plugins = ["pydantic.mypy"]
strict = true strict = true

View File

@@ -1,9 +1,10 @@
"""Script to generate migrations for the migration script.""" """Script to generate migrations for the migration script."""
from __future__ import annotations
import json import json
import pkgutil import pkgutil
from pathlib import Path from pathlib import Path
from typing import Optional
import click import click
@@ -52,7 +53,7 @@ def cli() -> None:
def generic( def generic(
pkg1: str, pkg1: str,
pkg2: str, pkg2: str,
output: Optional[str], output: str | None,
filter_by_all: bool, # noqa: FBT001 filter_by_all: bool, # noqa: FBT001
format_: str, format_: str,
) -> None: ) -> None:
@@ -76,7 +77,7 @@ def generic(
Path(output).write_text(dumped, encoding="utf-8") Path(output).write_text(dumped, encoding="utf-8")
def handle_partner(pkg: str, output: Optional[str] = None) -> None: def handle_partner(pkg: str, output: str | None = None) -> None:
"""Handle partner package migrations.""" """Handle partner package migrations."""
migrations = get_migrations_for_partner_package(pkg) migrations = get_migrations_for_partner_package(pkg)
# Run with python 3.9+ # Run with python 3.9+

View File

@@ -52,7 +52,7 @@ class Folder:
if len(self.files) != len(__value.files): if len(self.files) != len(__value.files):
return False return False
for self_file, other_file in zip(self.files, __value.files): for self_file, other_file in zip(self.files, __value.files, strict=False):
if self_file != other_file: if self_file != other_file:
return False return False

View File

@@ -14,7 +14,7 @@ pytest.importorskip("gritql")
def find_issue(current: Folder, expected: Folder) -> str: def find_issue(current: Folder, expected: Folder) -> str:
for current_file, expected_file in zip(current.files, expected.files): for current_file, expected_file in zip(current.files, expected.files, strict=False):
if current_file != expected_file: if current_file != expected_file:
if current_file.name != expected_file.name: if current_file.name != expected_file.name:
return ( return (

View File

@@ -1,4 +1,6 @@
from typing import Any, Optional from __future__ import annotations
from typing import Any
import pytest import pytest
@@ -13,10 +15,10 @@ from langchain_cli.utils.git import DependencySource, parse_dependency_string
def _assert_dependency_equals( def _assert_dependency_equals(
dep: DependencySource, dep: DependencySource,
*, *,
git: Optional[str] = None, git: str | None = None,
ref: Optional[str] = None, ref: str | None = None,
subdirectory: Optional[str] = None, subdirectory: str | None = None,
event_metadata: Optional[dict[str, Any]] = None, event_metadata: dict[str, Any] | None = None,
) -> None: ) -> None:
if dep["git"] != git: if dep["git"] != git:
msg = f"Expected git to be {git} but got {dep['git']}" msg = f"Expected git to be {git} but got {dep['git']}"

1120
libs/cli/uv.lock generated

File diff suppressed because it is too large Load Diff