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"]
elif dir_ == "libs/langchain_v1":
py_versions = ["3.10", "3.13"]
elif dir_ in {"libs/cli"}:
py_versions = ["3.10", "3.13"]
elif dir_ == ".":
# 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
cd .integration_test && \
python3 -m venv .venv && \
pip install --upgrade poetry && \
$(PYTHON) -m pip install --upgrade uv && \
$(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 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 && \
cd langchain-parrot-link && \
poetry install --with lint,typing,test && \
poetry run pip install -e ../../../standard-tests && \
unset UV_FROZEN && \
unset VIRTUAL_ENV && \
uv sync && \
uv add --editable ../../../standard-tests && \
make format lint tests && \
poetry install --with test_integration && \
poetry run pip install -e ../../../core && \
uv add --editable ../../../core && \
make integration_test

View File

@@ -1,6 +1,8 @@
"""LangChain CLI."""
from typing import Annotated, Optional
from __future__ import annotations
from typing import Annotated
import typer
@@ -61,11 +63,11 @@ def _main(
def serve(
*,
port: Annotated[
Optional[int],
int | None,
typer.Option(help="The port to run the server on"),
] = None,
host: Annotated[
Optional[str],
str | None,
typer.Option(help="The host to run the server on"),
] = 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
test tests:
poetry run pytest --disable-socket --allow-unix-socket $(TEST_FILE)
uv run pytest --disable-socket --allow-unix-socket $(TEST_FILE)
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_test integration_tests:
poetry run pytest $(TEST_FILE)
uv run pytest $(TEST_FILE)
######################
# LINTING AND FORMATTING
@@ -33,22 +33,22 @@ lint_tests: PYTHON_FILES=tests
lint_tests: MYPY_CACHE=.mypy_cache_test
lint lint_diff lint_package lint_tests:
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && poetry run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES) --diff
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && uv run mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
format format_diff:
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff format $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || poetry run ruff check --fix $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || uv run ruff format $(PYTHON_FILES)
[ "$(PYTHON_FILES)" = "" ] || uv run ruff check --fix $(PYTHON_FILES)
spell_check:
poetry run codespell --toml pyproject.toml
uv run codespell --toml pyproject.toml
spell_fix:
poetry run codespell --toml pyproject.toml -w
uv run codespell --toml pyproject.toml -w
check_imports: $(shell find __module_name__ -name '*.py')
poetry run python ./scripts/check_imports.py $^
uv run python ./scripts/check_imports.py $^
######################
# HELP

View File

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

View File

@@ -1,26 +1,38 @@
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"
requires = ["pdm-backend"]
build-backend = "pdm.backend"
[tool.poetry]
[project]
name = "__package_name__"
version = "0.1.0"
description = "An integration package connecting __ModuleName__ and LangChain"
authors = []
readme = "README.md"
repository = "https://github.com/langchain-ai/langchain"
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]
disallow_untyped_defs = "True"
[tool.poetry.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"
[tool.poetry.dependencies]
python = ">=3.9,<4.0"
langchain-core = "^0.3.15"
[tool.uv]
dev-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",
"codespell>=2.2.6",
"ruff>=0.5",
"mypy>=1.10",
]
[tool.ruff.lint]
select = ["E", "F", "I", "T201"]
@@ -37,38 +49,3 @@ markers = [
"compile: mark placeholder test used to compile integration tests without running them",
]
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(
"--pip/--no-pip",
help="Pip install the template(s) as editable dependencies",
is_flag=True,
),
] = None,
noninteractive: Annotated[
@@ -58,7 +57,6 @@ def new(
typer.Option(
"--non-interactive/--interactive",
help="Don't prompt for any input",
is_flag=True,
),
] = False,
) -> None:
@@ -154,7 +152,6 @@ def add(
typer.Option(
"--pip/--no-pip",
help="Pip install the template(s) as editable dependencies",
is_flag=True,
prompt="Would you like to `pip install -e` the template(s)?",
),
],
@@ -241,7 +238,7 @@ def add(
try:
add_dependencies_to_pyproject_toml(
project_root / "pyproject.toml",
zip(installed_destination_names, installed_destination_paths),
zip(installed_destination_names, installed_destination_paths, strict=False),
)
except Exception:
# Can fail if user modified/removed pyproject.toml
@@ -279,11 +276,11 @@ def add(
imports = [
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 = [
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 = (

View File

@@ -1,5 +1,6 @@
"""Develop integration packages for LangChain."""
import os
import re
import shutil
import subprocess
@@ -125,12 +126,28 @@ def new(
# replacements in files
replace_glob(destination_dir, "**/*", cast("dict[str, str]", replacements))
# poetry install
subprocess.run(
["poetry", "install", "--with", "lint,test,typing,test_integration"], # noqa: S607
cwd=destination_dir,
check=True,
)
# dependency install
try:
# Use --no-progress to avoid tty issues in CI/test environments
env = os.environ.copy()
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:
# confirm src and dst are the same length
if not src:
@@ -166,7 +183,7 @@ def new(
typer.echo(f"File {dst_path} exists.")
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)
replace_file(dst_path, cast("dict[str, str]", replacements))

View File

@@ -1,8 +1,10 @@
"""Events utilities."""
from __future__ import annotations
import http.client
import json
from typing import Any, Optional, TypedDict
from typing import Any, TypedDict
import typer
@@ -18,10 +20,10 @@ class EventDict(TypedDict):
"""
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.
Args:

View File

@@ -1,12 +1,14 @@
"""Git utilities."""
from __future__ import annotations
import hashlib
import logging
import re
import shutil
from collections.abc import Sequence
from pathlib import Path
from typing import Any, Optional, TypedDict
from typing import Any, TypedDict
from git import Repo
@@ -23,18 +25,18 @@ class DependencySource(TypedDict):
"""Dependency source information."""
git: str
ref: Optional[str]
subdirectory: Optional[str]
api_path: Optional[str]
ref: str | None
subdirectory: str | None
api_path: str | None
event_metadata: dict[str, Any]
# use poetry dependency string format
def parse_dependency_string(
dep: Optional[str],
repo: Optional[str],
branch: Optional[str],
api_path: Optional[str],
dep: str | None,
repo: str | None,
branch: str | None,
api_path: str | None,
) -> 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:
return [None] * num
if len(arg) == 1:
@@ -137,7 +139,7 @@ def _list_arg_to_length(arg: Optional[list[str]], num: int) -> Sequence[Optional
def parse_dependencies(
dependencies: Optional[list[str]],
dependencies: list[str] | None,
repo: list[str],
branch: list[str],
api_path: list[str],
@@ -180,17 +182,18 @@ def parse_dependencies(
inner_branches = _list_arg_to_length(branch, num_deps)
return list(
map(
map( # type: ignore[call-overload]
parse_dependency_string,
inner_deps,
inner_repos,
inner_branches,
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
ref_str = ref if ref is not None else ""
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
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.
Tries to pull if the repo already exists, otherwise clones it.

View File

@@ -1,11 +1,12 @@
"""GitHub utilities."""
from __future__ import annotations
import http.client
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.
Args:

View File

@@ -1,12 +1,14 @@
"""Packages utilities."""
from __future__ import annotations
from pathlib import Path
from typing import Any, Optional, TypedDict
from typing import Any, TypedDict, cast
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.
Args:
@@ -62,11 +64,12 @@ def get_langserve_export(filepath: Path) -> LangServeExport:
KeyError: If the `pyproject.toml` file is missing required fields.
"""
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:
module = data["tool"]["langserve"]["export_module"]
attr = data["tool"]["langserve"]["export_attr"]
package_name = data["tool"]["poetry"]["name"]
module = str(data["tool"]["langserve"]["export_module"])
attr = str(data["tool"]["langserve"]["export_attr"])
package_name = str(data["tool"]["poetry"]["name"])
except KeyError as e:
msg = "Invalid LangServe PyProject.toml"
raise KeyError(msg) from e

View File

@@ -5,9 +5,9 @@ build-backend = "pdm.backend"
[project]
authors = [{ name = "Erick Friis", email = "erick@langchain.dev" }]
license = { text = "MIT" }
requires-python = ">=3.9"
requires-python = ">=3.10"
dependencies = [
"typer<1.0.0,>=0.9.0",
"typer<1.0.0,>=0.17",
"gitpython<4,>=3",
"langserve[all]>=0.0.51",
"uvicorn<1.0,>=0.23",
@@ -80,6 +80,12 @@ pyupgrade.keep-runtime-typing = true
"tests/**" = [ "D1", "DOC", "S", "SLF",]
"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]
plugins = ["pydantic.mypy"]
strict = true

View File

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

View File

@@ -52,7 +52,7 @@ class Folder:
if len(self.files) != len(__value.files):
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:
return False

View File

@@ -14,7 +14,7 @@ pytest.importorskip("gritql")
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.name != expected_file.name:
return (

View File

@@ -1,4 +1,6 @@
from typing import Any, Optional
from __future__ import annotations
from typing import Any
import pytest
@@ -13,10 +15,10 @@ 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[str, Any]] = None,
git: str | None = None,
ref: str | None = None,
subdirectory: str | None = None,
event_metadata: dict[str, Any] | None = None,
) -> None:
if dep["git"] != 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