diff --git a/.github/workflows/check_core_versions.yml b/.github/workflows/check_core_versions.yml deleted file mode 100644 index 4cdbf2a0e1a..00000000000 --- a/.github/workflows/check_core_versions.yml +++ /dev/null @@ -1,67 +0,0 @@ -# Ensures version numbers in pyproject.toml and version.py stay in sync. -# -# (Prevents releases with mismatched version numbers) - -name: "🔍 Check Version Equality" - -on: - pull_request: - paths: - - "libs/core/pyproject.toml" - - "libs/core/langchain_core/version.py" - - "libs/partners/anthropic/pyproject.toml" - - "libs/partners/anthropic/langchain_anthropic/_version.py" - -permissions: - contents: read - -jobs: - check_version_equality: - runs-on: ubuntu-latest - - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 - - - name: "✅ Verify pyproject.toml & version.py Match" - run: | - # Check core versions - CORE_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/core/pyproject.toml) - CORE_VERSION_PY_VERSION=$(grep -Po '(?<=^VERSION = ")[^"]*' libs/core/langchain_core/version.py) - - # Compare core versions - if [ "$CORE_PYPROJECT_VERSION" != "$CORE_VERSION_PY_VERSION" ]; then - echo "langchain-core versions in pyproject.toml and version.py do not match!" - echo "pyproject.toml version: $CORE_PYPROJECT_VERSION" - echo "version.py version: $CORE_VERSION_PY_VERSION" - exit 1 - else - echo "Core versions match: $CORE_PYPROJECT_VERSION" - fi - - # Check langchain_v1 versions - LANGCHAIN_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/langchain_v1/pyproject.toml) - LANGCHAIN_INIT_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/langchain_v1/langchain/__init__.py) - - # Compare langchain_v1 versions - if [ "$LANGCHAIN_PYPROJECT_VERSION" != "$LANGCHAIN_INIT_PY_VERSION" ]; then - echo "langchain_v1 versions in pyproject.toml and __init__.py do not match!" - echo "pyproject.toml version: $LANGCHAIN_PYPROJECT_VERSION" - echo "version.py version: $LANGCHAIN_INIT_PY_VERSION" - exit 1 - else - echo "Langchain v1 versions match: $LANGCHAIN_PYPROJECT_VERSION" - fi - - # Check langchain-anthropic versions - ANTHROPIC_PYPROJECT_VERSION=$(grep -Po '(?<=^version = ")[^"]*' libs/partners/anthropic/pyproject.toml) - ANTHROPIC_VERSION_PY_VERSION=$(grep -Po '(?<=^__version__ = ")[^"]*' libs/partners/anthropic/langchain_anthropic/_version.py) - - # Compare langchain-anthropic versions - if [ "$ANTHROPIC_PYPROJECT_VERSION" != "$ANTHROPIC_VERSION_PY_VERSION" ]; then - echo "langchain-anthropic versions in pyproject.toml and _version.py do not match!" - echo "pyproject.toml version: $ANTHROPIC_PYPROJECT_VERSION" - echo "_version.py version: $ANTHROPIC_VERSION_PY_VERSION" - exit 1 - else - echo "Langchain-anthropic versions match: $ANTHROPIC_PYPROJECT_VERSION" - fi diff --git a/.github/workflows/check_versions.yml b/.github/workflows/check_versions.yml new file mode 100644 index 00000000000..391b47ac0c2 --- /dev/null +++ b/.github/workflows/check_versions.yml @@ -0,0 +1,54 @@ +# Ensures version numbers in pyproject.toml and _version.py stay in sync. +# +# (Prevents releases with mismatched version numbers) + +name: "Check Version Equality" + +on: + pull_request: + paths: + - "libs/core/pyproject.toml" + - "libs/core/langchain_core/version.py" + - "libs/langchain_v1/pyproject.toml" + - "libs/langchain_v1/langchain/__init__.py" + - "libs/partners/*/pyproject.toml" + - "libs/partners/**/_version.py" + +permissions: + contents: read + +jobs: + check_version_equality: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7 + with: + python-version: "3.12" + + - name: "Verify pyproject.toml & version files match" + run: | + FAILED=0 + + for dir in libs/core libs/langchain_v1 libs/partners/*; do + [ -f "$dir/Makefile" ] || continue + if grep -q '^check_version:' "$dir/Makefile"; then + echo "--- $dir ---" + make -C "$dir" check_version || FAILED=1 + elif find "$dir" -maxdepth 2 -name '_version.py' -not -path '*/tests/*' \ + | grep -q .; then + # A package ships a _version.py but has no way to verify it stays + # in sync with pyproject.toml. Don't let it pass unchecked. + echo "--- $dir ---" + echo "Error: $dir has a _version.py but no 'check_version' Makefile target" + FAILED=1 + fi + done + + if [ "$FAILED" -ne 0 ]; then + echo "" + echo "One or more version checks failed!" + exit 1 + fi diff --git a/libs/core/langchain_core/callbacks/base.py b/libs/core/langchain_core/callbacks/base.py index 6e4df3dd891..3573dff853d 100644 --- a/libs/core/langchain_core/callbacks/base.py +++ b/libs/core/langchain_core/callbacks/base.py @@ -1205,9 +1205,15 @@ class BaseCallbackManager(CallbackManagerMixin): metadata: The metadata to add. inherit: Whether to inherit the metadata. """ - self.metadata.update(metadata) + from langchain_core.runnables.config import ( # noqa: PLC0415 + _merge_metadata_dicts, + ) + + self.metadata = _merge_metadata_dicts(self.metadata, metadata) if inherit: - self.inheritable_metadata.update(metadata) + self.inheritable_metadata = _merge_metadata_dicts( + self.inheritable_metadata, metadata + ) def remove_metadata(self, keys: list[str]) -> None: """Remove metadata from the callback manager. diff --git a/libs/core/langchain_core/language_models/base.py b/libs/core/langchain_core/language_models/base.py index fb416887e74..2dc07612626 100644 --- a/libs/core/langchain_core/language_models/base.py +++ b/libs/core/langchain_core/language_models/base.py @@ -138,6 +138,30 @@ def _get_verbosity() -> bool: return get_verbose() +@cache +def _get_langchain_version() -> str | None: + """Return the installed `langchain` version, or `None` if not installed. + + Cached because `importlib.metadata.version` performs a filesystem lookup and + `model_post_init` runs on every `BaseLanguageModel` instantiation. `langchain` + is an optional sibling package, so its absence is expected and not an error. + """ + from importlib.metadata import PackageNotFoundError # noqa: PLC0415 + from importlib.metadata import version as pkg_version # noqa: PLC0415 + + try: + return pkg_version("langchain") + except PackageNotFoundError: + return None + + +# Warm the cache at import time, while we're guaranteed to be on the synchronous +# import path and outside any event loop. Otherwise the first model constructed +# inside async code would run the blocking `os.stat` (via `importlib.metadata`) +# on the event loop, tripping blocking-I/O detectors like blockbuster. +_get_langchain_version() + + class BaseLanguageModel( RunnableSerializable[LanguageModelInput, LanguageModelOutputVar], ABC ): @@ -179,6 +203,75 @@ class BaseLanguageModel( arbitrary_types_allowed=True, ) + def model_post_init(self, _context: Any, /) -> None: + """Pydantic V2 lifecycle hook called automatically after `__init__`. + + Seeds `metadata["versions"]` with the installed `langchain-core` + (and `langchain`, if installed) versions so that every LLM trace + carries the package versions that produced it. + + Partner packages should **not** override this method. Instead, they + should define a `@model_validator(mode="after")` that calls + `_add_version` to append their own version to the same dict. + + !!! warning "Validator naming" + + Each subclass's validator **must** have a unique name. Pydantic + replaces — rather than chains — same-named `model_validator` methods + in child classes. For example, a `BaseChatOpenAI` subclass should + use `_set__version`, not `_set_version`, to avoid silently + dropping the parent's entry. + + Args: + _context: Pydantic validation context (typically `None`). + """ + super().model_post_init(_context) + from langchain_core.version import VERSION # noqa: PLC0415 + + self._add_version("langchain-core", VERSION) + + langchain_version = _get_langchain_version() + if langchain_version is not None: + self._add_version("langchain", langchain_version) + + def _add_version(self, pkg: str, version: str) -> None: + """Record a package version in `metadata.versions` for tracing. + + Each layer in the class hierarchy (core -> langchain -> partner) + calls this so that the resulting metadata dict accumulates *all* + package versions involved in an invocation. + + Example resulting metadata: + + ```python + { + "versions": { + "langchain-core": "1.x.x", + "langchain": "1.x.x", + "langchain-openai": "1.x.x", + } + } + ``` + + Args: + pkg: Package name (e.g., `'langchain-openai'`). + version: Installed version string. + """ + if self.metadata is None: + self.metadata = {} + existing = self.metadata.get("versions") + if existing is not None and not isinstance(existing, Mapping): + warnings.warn( + f"metadata['versions'] expected a dict, got " + f"{type(existing).__name__}; overwriting with package version dict", + stacklevel=2, + ) + existing = None + self.metadata["versions"] = { + **(existing if isinstance(existing, Mapping) else {}), + pkg: version, + } + @field_validator("verbose", mode="before") def set_verbose(cls, verbose: bool | None) -> bool: # noqa: FBT001 """If verbose is `None`, set it. diff --git a/libs/core/langchain_core/runnables/config.py b/libs/core/langchain_core/runnables/config.py index d94d049fc52..0bb18f002f8 100644 --- a/libs/core/langchain_core/runnables/config.py +++ b/libs/core/langchain_core/runnables/config.py @@ -7,7 +7,15 @@ import asyncio # Cannot move uuid to TYPE_CHECKING as RunnableConfig is used in Pydantic models import uuid # noqa: TC003 import warnings -from collections.abc import Awaitable, Callable, Generator, Iterable, Iterator, Sequence +from collections.abc import ( + Awaitable, + Callable, + Generator, + Iterable, + Iterator, + Mapping, + Sequence, +) from concurrent.futures import Executor, Future, ThreadPoolExecutor from contextlib import contextmanager from contextvars import Context, ContextVar, Token, copy_context @@ -388,6 +396,50 @@ def patch_config( return config +def _merge_metadata_dicts( + base: Mapping[str, Any], incoming: Mapping[str, Any] +) -> dict[str, Any]: + """Merge two metadata dicts one level deep (not a recursive deep merge). + + If both sides have a `Mapping` value for the same key, the inner mappings + are merged (last-writer-wins within). Non-mapping values use + last-writer-wins at the top level. Only one level of depth is merged; values + nested more deeply are not recursively merged. + + Args: + base: The base metadata dict. + + Values here are kept unless overridden by `incoming`. + incoming: The metadata dict to merge on top. + + Its values take precedence on conflict. + + Returns: + A new merged dict. + + Inputs are not mutated. The returned dict performs shallow copies at + the top level and one level deep; mutable values nested beyond that + depth are shared references with the originals. + """ + merged = {**base} + for key, value in incoming.items(): + if ( + key in merged + and isinstance(merged[key], Mapping) + and isinstance(value, Mapping) + ): + merged[key] = {**merged[key], **value} + elif isinstance(value, Mapping): + merged[key] = {**value} + else: + merged[key] = value + # Ensure non-overlapping nested mappings are also copies, not shared refs. + for key in base: + if key not in incoming and isinstance(merged[key], Mapping): + merged[key] = {**merged[key]} + return merged + + def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig: """Merge multiple configs into one. @@ -403,10 +455,10 @@ def merge_configs(*configs: RunnableConfig | None) -> RunnableConfig: for config in (ensure_config(c) for c in configs if c is not None): for key in config: if key == "metadata": - base["metadata"] = { - **base.get("metadata", {}), - **(config.get("metadata") or {}), - } + base["metadata"] = _merge_metadata_dicts( + base.get("metadata", {}), + config.get("metadata") or {}, + ) elif key == "tags": base["tags"] = sorted( set(base.get("tags", []) + (config.get("tags") or [])), diff --git a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py index 68f401501a5..eb8b4a20aaa 100644 --- a/libs/core/tests/unit_tests/language_models/chat_models/test_base.py +++ b/libs/core/tests/unit_tests/language_models/chat_models/test_base.py @@ -4,6 +4,7 @@ import uuid import warnings from collections.abc import AsyncIterator, Iterator from contextlib import contextmanager +from importlib.metadata import PackageNotFoundError from typing import TYPE_CHECKING, Any, Literal, get_type_hints from unittest.mock import patch @@ -27,6 +28,7 @@ from langchain_core.language_models._utils import ( _filter_invocation_params_for_tracing, _normalize_messages, ) +from langchain_core.language_models.base import _get_langchain_version from langchain_core.language_models.chat_models import ( SimpleChatModel, _generate_response_from_error, @@ -52,6 +54,7 @@ from langchain_core.tracers.context import collect_runs from langchain_core.tracers.event_stream import _AstreamEventsCallbackHandler from langchain_core.tracers.langchain import LangChainTracer from langchain_core.tracers.schemas import Run +from langchain_core.version import VERSION from tests.unit_tests.fake.callbacks import ( BaseFakeCallbackHandler, FakeAsyncCallbackHandler, @@ -61,6 +64,7 @@ from tests.unit_tests.stubs import _any_id_ai_message, _any_id_ai_message_chunk if TYPE_CHECKING: from langchain_core.outputs.llm_result import LLMResult + from langchain_core.runnables.config import RunnableConfig def _content_blocks_equal_ignore_id( @@ -1305,6 +1309,187 @@ def test_get_ls_params() -> None: assert ls_params["ls_stop"] == ["stop"] +class _VersionedFakeModel(FakeListChatModel): + """Fake model that adds a version via `_add_version`.""" + + def model_post_init(self, _context: Any, /) -> None: + super().model_post_init(_context) + self._add_version("langchain-fake", "0.1.0") + + +def test_user_versions_metadata_survives_merge() -> None: + """User-provided versions metadata should be deep-merged with model versions. + + Regression test: the `add_metadata` deep-merge in `CallbackManager` + must preserve both user-provided and model-provided versions dicts. + """ + llm = _VersionedFakeModel(responses=["hello"]) + user_config: RunnableConfig = {"metadata": {"versions": {"my-app": "2.0"}}} + + with collect_runs() as cb: + llm.invoke([HumanMessage(content="hi")], config=user_config) + assert len(cb.traced_runs) == 1 + run_metadata = cb.traced_runs[0].extra["metadata"] + assert "my-app" in run_metadata["versions"] + assert run_metadata["versions"]["my-app"] == "2.0" + assert "langchain-fake" in run_metadata["versions"] + assert "langchain-core" in run_metadata["versions"] + + +async def test_user_versions_metadata_survives_merge_async() -> None: + """Async variant: user-provided versions metadata deep-merged with model's.""" + llm = _VersionedFakeModel(responses=["hello"]) + user_config: RunnableConfig = {"metadata": {"versions": {"my-app": "2.0"}}} + + with collect_runs() as cb: + await llm.ainvoke([HumanMessage(content="hi")], config=user_config) + assert len(cb.traced_runs) == 1 + run_metadata = cb.traced_runs[0].extra["metadata"] + assert "my-app" in run_metadata["versions"] + assert "langchain-fake" in run_metadata["versions"] + assert "langchain-core" in run_metadata["versions"] + + +def test_user_versions_metadata_survives_merge_stream() -> None: + """Stream variant: user-provided versions metadata deep-merged with model's.""" + llm = _VersionedFakeModel(responses=["hello"]) + user_config: RunnableConfig = {"metadata": {"versions": {"my-app": "2.0"}}} + + with collect_runs() as cb: + for _ in llm.stream([HumanMessage(content="hi")], config=user_config): + pass + assert len(cb.traced_runs) == 1 + run_metadata = cb.traced_runs[0].extra["metadata"] + assert "my-app" in run_metadata["versions"] + assert "langchain-fake" in run_metadata["versions"] + assert "langchain-core" in run_metadata["versions"] + + +async def test_user_versions_metadata_survives_merge_astream() -> None: + """Async stream variant: user-provided versions metadata deep-merged.""" + llm = _VersionedFakeModel(responses=["hello"]) + user_config: RunnableConfig = {"metadata": {"versions": {"my-app": "2.0"}}} + + with collect_runs() as cb: + async for _ in llm.astream([HumanMessage(content="hi")], config=user_config): + pass + assert len(cb.traced_runs) == 1 + run_metadata = cb.traced_runs[0].extra["metadata"] + assert "my-app" in run_metadata["versions"] + assert "langchain-fake" in run_metadata["versions"] + assert "langchain-core" in run_metadata["versions"] + + +def test_add_version_with_none_metadata() -> None: + """Model constructed with metadata=None should still get versions.""" + llm = FakeListChatModel(responses=["x"], metadata=None) + assert llm.metadata is not None + assert "langchain-core" in llm.metadata["versions"] + + +def test_add_version_with_non_dict_versions() -> None: + """Non-dict `versions` value is silently replaced with a warning.""" + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + llm = FakeListChatModel(responses=["x"], metadata={"versions": "garbage"}) + assert any("expected a dict" in str(warning.message) for warning in w) + assert llm.metadata is not None + assert isinstance(llm.metadata["versions"], dict) + assert "langchain-core" in llm.metadata["versions"] + + +def test_langchain_version_in_metadata(monkeypatch: pytest.MonkeyPatch) -> None: + """When `langchain` is installed, its version appears in metadata.""" + + def _fake_version(pkg: str) -> str: + if pkg == "langchain": + return "1.2.13" + raise PackageNotFoundError(pkg) + + monkeypatch.setattr("importlib.metadata.version", _fake_version) + _get_langchain_version.cache_clear() + try: + llm = FakeListChatModel(responses=["x"]) + assert llm.metadata is not None + assert llm.metadata["versions"]["langchain"] == "1.2.13" + assert "langchain-core" in llm.metadata["versions"] + finally: + _get_langchain_version.cache_clear() + + +def test_langchain_version_missing_when_not_installed( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """When `langchain` is not installed, metadata.versions has no entry. + + The lookup raises `PackageNotFoundError` (the real not-installed signal), which + `_get_langchain_version` must swallow without dropping the rest of the dict. + """ + + def _raise_not_found(pkg: str) -> str: + raise PackageNotFoundError(pkg) + + monkeypatch.setattr("importlib.metadata.version", _raise_not_found) + _get_langchain_version.cache_clear() + try: + llm = FakeListChatModel(responses=["x"]) + assert llm.metadata is not None + assert "langchain" not in llm.metadata["versions"] + assert "langchain-core" in llm.metadata["versions"] + finally: + _get_langchain_version.cache_clear() + + +def test_version_validator_coexists_with_core_seed() -> None: + """A `model_validator(mode="after")` calling `_add_version` keeps the core seed. + + Mirrors the real partner pattern (a validator, not a `model_post_init` + override) and confirms the validator-added entry and the core-seeded + `langchain-core` entry accumulate rather than clobber one another. + """ + + class _ValidatorVersionedModel(FakeListChatModel): + @model_validator(mode="after") + def _set_fake_version(self) -> Self: + self._add_version("langchain-fake", "0.1.0") + return self + + llm = _ValidatorVersionedModel(responses=["x"]) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert versions["langchain-core"] == VERSION + assert versions["langchain-fake"] == "0.1.0" + + +def test_subclass_unique_validator_names_accumulate() -> None: + """Parent and child uniquely-named validators must both contribute entries. + + Regression guard for the documented Pydantic footgun: same-named + `model_validator` methods *replace* rather than chain across a class + hierarchy. As long as each layer uses a unique validator name, a subclass + must not silently drop its parent's version entry. + """ + + class _ParentModel(FakeListChatModel): + @model_validator(mode="after") + def _set_parent_version(self) -> Self: + self._add_version("langchain-parent", "1.0.0") + return self + + class _ChildModel(_ParentModel): + @model_validator(mode="after") + def _set_child_version(self) -> Self: + self._add_version("langchain-child", "2.0.0") + return self + + llm = _ChildModel(responses=["x"]) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert versions["langchain-core"] == VERSION + assert versions["langchain-parent"] == "1.0.0" + assert versions["langchain-child"] == "2.0.0" + + def test_model_profiles() -> None: model = GenericFakeChatModel(messages=iter([])) assert model.profile is None @@ -1592,6 +1777,7 @@ def test_invocation_params_passed_to_tracer_metadata() -> None: "ls_temperature": 0.7, "stop": None, "temperature": 0.7, + "versions": {"langchain-core": VERSION}, }, "options": {"stop": None}, "runtime": run.extra["runtime"], diff --git a/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr b/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr index a5e3dd82318..b92bcbb420c 100644 --- a/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr +++ b/libs/core/tests/unit_tests/runnables/__snapshots__/test_fallbacks.ambr @@ -84,7 +84,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['foo'], i=1)", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo'], i=1)", "name": "FakeListLLM" } }, @@ -128,7 +128,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['bar'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['bar'])", "name": "FakeListLLM" } }, @@ -268,7 +268,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['foo'], i=1)", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo'], i=1)", "name": "FakeListLLM" }, "fallbacks": [ @@ -281,7 +281,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['bar'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['bar'])", "name": "FakeListLLM" } ], @@ -322,7 +322,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['foo'], i=1)", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo'], i=1)", "name": "FakeListLLM" }, "fallbacks": [ @@ -335,7 +335,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['baz'], i=1)", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['baz'], i=1)", "name": "FakeListLLM" }, { @@ -347,7 +347,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['bar'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['bar'])", "name": "FakeListLLM" } ], diff --git a/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr b/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr index 0f04a93366a..1e3cbb5d212 100644 --- a/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr +++ b/libs/core/tests/unit_tests/runnables/__snapshots__/test_runnable.ambr @@ -97,7 +97,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo, bar'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo, bar'])", "name": "FakeListChatModel" } ], @@ -227,7 +227,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['baz, qux'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['baz, qux'])", "name": "FakeListChatModel" } ], @@ -346,7 +346,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo, bar'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo, bar'])", "name": "FakeListChatModel" }, { @@ -457,7 +457,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['baz, qux'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['baz, qux'])", "name": "FakeListChatModel" } ], @@ -848,7 +848,7 @@ "fake", "FakeStreamingListLLM" ], - "repr": "FakeStreamingListLLM(responses=['first item, second item, third item'])", + "repr": "FakeStreamingListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['first item, second item, third item'])", "name": "FakeStreamingListLLM" }, { @@ -884,7 +884,7 @@ "fake", "FakeStreamingListLLM" ], - "repr": "FakeStreamingListLLM(responses=['this', 'is', 'a', 'test'])", + "repr": "FakeStreamingListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['this', 'is', 'a', 'test'])", "name": "FakeStreamingListLLM" } }, @@ -1009,7 +1009,7 @@ # name: test_prompt_with_chat_model ''' ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})]) - | FakeListChatModel(responses=['foo']) + | FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo']) ''' # --- # name: test_prompt_with_chat_model.1 @@ -1109,7 +1109,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo'])", "name": "FakeListChatModel" } }, @@ -1220,7 +1220,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo, bar'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo, bar'])", "name": "FakeListChatModel" } ], @@ -1249,7 +1249,7 @@ # name: test_prompt_with_chat_model_async ''' ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})]) - | FakeListChatModel(responses=['foo']) + | FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo']) ''' # --- # name: test_prompt_with_chat_model_async.1 @@ -1349,7 +1349,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo'])", "name": "FakeListChatModel" } }, @@ -1459,7 +1459,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['foo', 'bar'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo', 'bar'])", "name": "FakeListLLM" } }, @@ -1576,7 +1576,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['foo', 'bar'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo', 'bar'])", "name": "FakeListLLM" } ], @@ -1699,7 +1699,7 @@ "fake", "FakeStreamingListLLM" ], - "repr": "FakeStreamingListLLM(responses=['bear, dog, cat', 'tomato, lettuce, onion'])", + "repr": "FakeStreamingListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['bear, dog, cat', 'tomato, lettuce, onion'])", "name": "FakeStreamingListLLM" } ], @@ -1867,7 +1867,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['4'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['4'])", "name": "FakeListLLM" } }, @@ -1940,7 +1940,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=['2'])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['2'])", "name": "FakeListLLM" } }, @@ -13407,7 +13407,7 @@ just_to_test_lambda: RunnableLambda(...) } | ChatPromptTemplate(input_variables=['documents', 'question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['documents', 'question'], input_types={}, partial_variables={}, template='Context:\n{documents}\n\nQuestion:\n{question}'), additional_kwargs={})]) - | FakeListChatModel(responses=['foo, bar']) + | FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo, bar']) | CommaSeparatedListOutputParser() ''' # --- @@ -13610,7 +13610,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=['foo, bar'])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=['foo, bar'])", "name": "FakeListChatModel" } ], @@ -13636,8 +13636,8 @@ ChatPromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, messages=[SystemMessagePromptTemplate(prompt=PromptTemplate(input_variables=[], input_types={}, partial_variables={}, template='You are a nice assistant.'), additional_kwargs={}), HumanMessagePromptTemplate(prompt=PromptTemplate(input_variables=['question'], input_types={}, partial_variables={}, template='{question}'), additional_kwargs={})]) | RunnableLambda(...) | { - chat: FakeListChatModel(responses=["i'm a chatbot"]), - llm: FakeListLLM(responses=["i'm a textbot"]) + chat: FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=["i'm a chatbot"]), + llm: FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=["i'm a textbot"]) } ''' # --- @@ -13762,7 +13762,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=[\"i'm a chatbot\"])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=[\"i'm a chatbot\"])", "name": "FakeListChatModel" }, "llm": { @@ -13774,7 +13774,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=[\"i'm a textbot\"])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=[\"i'm a textbot\"])", "name": "FakeListLLM" } } @@ -13917,7 +13917,7 @@ "fake_chat_models", "FakeListChatModel" ], - "repr": "FakeListChatModel(responses=[\"i'm a chatbot\"])", + "repr": "FakeListChatModel(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=[\"i'm a chatbot\"])", "name": "FakeListChatModel" }, "kwargs": { @@ -13938,7 +13938,7 @@ "fake", "FakeListLLM" ], - "repr": "FakeListLLM(responses=[\"i'm a textbot\"])", + "repr": "FakeListLLM(metadata={'versions': {'langchain-core': '1.4.5'}}, responses=[\"i'm a textbot\"])", "name": "FakeListLLM" }, "passthrough": { diff --git a/libs/core/tests/unit_tests/runnables/test_config.py b/libs/core/tests/unit_tests/runnables/test_config.py index 5b639111d9e..00be7137c0a 100644 --- a/libs/core/tests/unit_tests/runnables/test_config.py +++ b/libs/core/tests/unit_tests/runnables/test_config.py @@ -17,6 +17,7 @@ from langchain_core.runnables import RunnableBinding, RunnablePassthrough from langchain_core.runnables.config import ( RunnableConfig, _get_langsmith_inheritable_metadata_from_config, + _merge_metadata_dicts, _set_config_context, ensure_config, merge_configs, @@ -322,3 +323,90 @@ async def test_run_in_executor() -> None: with pytest.raises(RuntimeError): await run_in_executor(None, raises_stop_iter) + + +class TestMergeMetadataDicts: + """Tests for _merge_metadata_dicts deep-merge behavior.""" + + def test_deep_merge_preserves_both_nested_dicts(self) -> None: + base = {"versions": {"langchain-core": "0.3.1"}, "user_id": "abc"} + incoming = {"versions": {"langchain-anthropic": "1.3.3"}, "run": "x"} + result = _merge_metadata_dicts(base, incoming) + assert result == { + "versions": { + "langchain-core": "0.3.1", + "langchain-anthropic": "1.3.3", + }, + "user_id": "abc", + "run": "x", + } + + def test_last_writer_wins_within_nested_dicts(self) -> None: + base = {"versions": {"pkg": "1.0"}} + incoming = {"versions": {"pkg": "2.0"}} + result = _merge_metadata_dicts(base, incoming) + assert result == {"versions": {"pkg": "2.0"}} + + def test_non_dict_overwrites_dict(self) -> None: + base = {"key": {"nested": "value"}} + incoming = {"key": "flat"} + result = _merge_metadata_dicts(base, incoming) + assert result == {"key": "flat"} + + def test_dict_overwrites_non_dict(self) -> None: + base = {"key": "flat"} + incoming = {"key": {"nested": "value"}} + result = _merge_metadata_dicts(base, incoming) + assert result == {"key": {"nested": "value"}} + + def test_no_mutation_of_inputs(self) -> None: + base = {"versions": {"a": "1"}} + incoming = {"versions": {"b": "2"}} + base_copy = {"versions": {"a": "1"}} + incoming_copy = {"versions": {"b": "2"}} + result = _merge_metadata_dicts(base, incoming) + assert base == base_copy + assert incoming == incoming_copy + # Returned nested dicts should not share identity with originals. + assert result["versions"] is not base["versions"] + assert result["versions"] is not incoming["versions"] + + def test_non_overlapping_nested_dict_is_copied(self) -> None: + base = {"versions": {"a": "1"}, "extras": {"x": "y"}} + incoming = {"versions": {"b": "2"}} + result = _merge_metadata_dicts(base, incoming) + # "extras" was not in incoming — result should still be a copy. + assert result["extras"] is not base["extras"] + assert result["extras"] == {"x": "y"} + + def test_both_empty(self) -> None: + assert _merge_metadata_dicts({}, {}) == {} + + def test_empty_base(self) -> None: + incoming = {"versions": {"pkg": "1.0"}} + result = _merge_metadata_dicts({}, incoming) + assert result == {"versions": {"pkg": "1.0"}} + assert result["versions"] is not incoming["versions"] + + result["versions"]["new"] = "2.0" + assert incoming == {"versions": {"pkg": "1.0"}} + + def test_empty_incoming(self) -> None: + result = _merge_metadata_dicts({"versions": {"pkg": "1.0"}}, {}) + assert result == {"versions": {"pkg": "1.0"}} + + def test_merge_configs_with_none_metadata(self) -> None: + merged = merge_configs( + cast("RunnableConfig", {"metadata": None}), + {"metadata": {"versions": {"a": "1"}}}, + ) + assert merged["metadata"] == {"versions": {"a": "1"}} + + def test_three_config_merge_accumulates(self) -> None: + c1: RunnableConfig = {"metadata": {"versions": {"a": "1"}}} + c2: RunnableConfig = {"metadata": {"versions": {"b": "2"}}} + c3: RunnableConfig = {"metadata": {"versions": {"c": "3"}}} + merged = merge_configs(c1, c2, c3) + assert merged["metadata"] == { + "versions": {"a": "1", "b": "2", "c": "3"}, + } diff --git a/libs/core/tests/unit_tests/runnables/test_runnable.py b/libs/core/tests/unit_tests/runnables/test_runnable.py index 25da4d3b158..f07a3aa3d54 100644 --- a/libs/core/tests/unit_tests/runnables/test_runnable.py +++ b/libs/core/tests/unit_tests/runnables/test_runnable.py @@ -91,6 +91,7 @@ from langchain_core.tracers import ( from langchain_core.tracers._compat import pydantic_copy from langchain_core.tracers.context import collect_runs from langchain_core.utils.pydantic import PYDANTIC_VERSION +from langchain_core.version import VERSION from tests.unit_tests.pydantic_utils import _normalize_schema, _schema from tests.unit_tests.stubs import AnyStr, _any_id_ai_message, _any_id_ai_message_chunk @@ -2147,7 +2148,11 @@ async def test_prompt_with_llm( "value": { "end_time": None, "final_output": None, - "metadata": {"ls_model_type": "llm", "ls_provider": "fakelist"}, + "metadata": { + "ls_model_type": "llm", + "ls_provider": "fakelist", + "versions": {"langchain-core": VERSION}, + }, "name": "FakeListLLM", "start_time": "2023-01-01T00:00:00.000+00:00", "streamed_output": [], @@ -2361,6 +2366,7 @@ async def test_prompt_with_llm_parser( "metadata": { "ls_model_type": "llm", "ls_provider": "fakestreaminglist", + "versions": {"langchain-core": VERSION}, }, "name": "FakeStreamingListLLM", "start_time": "2023-01-01T00:00:00.000+00:00", diff --git a/libs/partners/anthropic/langchain_anthropic/chat_models.py b/libs/partners/anthropic/langchain_anthropic/chat_models.py index f706374d7fb..c11143f2ac9 100644 --- a/libs/partners/anthropic/langchain_anthropic/chat_models.py +++ b/libs/partners/anthropic/langchain_anthropic/chat_models.py @@ -56,7 +56,7 @@ from langchain_core.utils.function_calling import ( from langchain_core.utils.pydantic import is_basemodel_subclass from langchain_core.utils.utils import _build_model_kwargs from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator -from typing_extensions import NotRequired, TypedDict +from typing_extensions import NotRequired, Self, TypedDict from langchain_anthropic import __version__ from langchain_anthropic._client_utils import ( @@ -1168,6 +1168,12 @@ class ChatAnthropic(BaseChatModel): all_required_field_names = get_pydantic_field_names(cls) return _build_model_kwargs(values, all_required_field_names) + @model_validator(mode="after") + def _set_anthropic_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-anthropic", __version__) + return self + def _resolve_model_profile(self) -> ModelProfile | None: profile = _get_default_model_profile(self.model) or None if profile is not None and self.betas and "context-1m-2025-08-07" in self.betas: diff --git a/libs/partners/anthropic/scripts/check_version.py b/libs/partners/anthropic/scripts/check_version.py index 163f31644da..fcfcf7dfe0b 100644 --- a/libs/partners/anthropic/scripts/check_version.py +++ b/libs/partners/anthropic/scripts/check_version.py @@ -2,7 +2,7 @@ This script validates that the version defined in pyproject.toml matches the `__version__` variable in `langchain_anthropic/_version.py`. Intended for use as a -pre-commit hook to prevent version mismatches. +CI check to prevent version mismatches. """ import re diff --git a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py index 0ff727f4ba1..175fc66f337 100644 --- a/libs/partners/anthropic/tests/unit_tests/test_chat_models.py +++ b/libs/partners/anthropic/tests/unit_tests/test_chat_models.py @@ -23,6 +23,7 @@ from pydantic import BaseModel, Field, SecretStr, ValidationError from pytest import CaptureFixture, MonkeyPatch from langchain_anthropic import ChatAnthropic +from langchain_anthropic._version import __version__ from langchain_anthropic.chat_models import ( _TOOL_CALL_ID_PATTERN, _create_usage_metadata, @@ -2131,6 +2132,8 @@ def test_anthropic_model_params() -> None: "ls_max_tokens": 64000, "ls_temperature": None, } + assert llm.metadata is not None + assert llm.metadata["versions"]["langchain-anthropic"] == __version__ ls_params = llm._get_ls_params(model=MODEL_NAME) assert ls_params.get("ls_model_name") == MODEL_NAME diff --git a/libs/partners/deepseek/Makefile b/libs/partners/deepseek/Makefile index a57d25ef235..61077b82e12 100644 --- a/libs/partners/deepseek/Makefile +++ b/libs/partners/deepseek/Makefile @@ -55,6 +55,9 @@ format format_diff: check_imports: $(shell find langchain_deepseek -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -62,6 +65,7 @@ check_imports: $(shell find langchain_deepseek -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/deepseek/langchain_deepseek/__init__.py b/libs/partners/deepseek/langchain_deepseek/__init__.py index 07fec728057..10ff61a5adf 100644 --- a/libs/partners/deepseek/langchain_deepseek/__init__.py +++ b/libs/partners/deepseek/langchain_deepseek/__init__.py @@ -1,16 +1,8 @@ """LangChain DeepSeek integration.""" -from importlib import metadata - +from langchain_deepseek._version import __version__ from langchain_deepseek.chat_models import ChatDeepSeek -try: - __version__ = metadata.version(__package__) -except metadata.PackageNotFoundError: - # Case where package metadata is not available. - __version__ = "" -del metadata # optional, avoids polluting the results of dir(__package__) - __all__ = [ "ChatDeepSeek", "__version__", diff --git a/libs/partners/deepseek/langchain_deepseek/_version.py b/libs/partners/deepseek/langchain_deepseek/_version.py new file mode 100644 index 00000000000..685ec565966 --- /dev/null +++ b/libs/partners/deepseek/langchain_deepseek/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-deepseek`.""" + +__version__ = "1.1.0" diff --git a/libs/partners/deepseek/langchain_deepseek/chat_models.py b/libs/partners/deepseek/langchain_deepseek/chat_models.py index a56df1474b4..91596fb9f06 100644 --- a/libs/partners/deepseek/langchain_deepseek/chat_models.py +++ b/libs/partners/deepseek/langchain_deepseek/chat_models.py @@ -27,6 +27,7 @@ from langchain_openai.chat_models.base import BaseChatOpenAI from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self +from langchain_deepseek._version import __version__ from langchain_deepseek.data._profiles import _PROFILES DEFAULT_API_BASE = "https://api.deepseek.com/v1" @@ -224,6 +225,16 @@ class ChatDeepSeek(BaseChatOpenAI): ls_params["ls_provider"] = "deepseek" return ls_params + @model_validator(mode="after") + def _set_deepseek_version(self) -> Self: + """Set package version in metadata. + + Named uniquely to avoid shadowing `BaseChatOpenAI._set_openai_chat_version`; + Pydantic replaces same-named validators rather than chaining them. + """ + self._add_version("langchain-deepseek", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate necessary environment vars and client params.""" diff --git a/libs/partners/deepseek/scripts/check_version.py b/libs/partners/deepseek/scripts/check_version.py new file mode 100644 index 00000000000..9575625f3b6 --- /dev/null +++ b/libs/partners/deepseek/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_deepseek/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_deepseek" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_deepseek/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_deepseek/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py index db45da40092..a17f60d67dc 100644 --- a/libs/partners/deepseek/tests/unit_tests/test_chat_models.py +++ b/libs/partners/deepseek/tests/unit_tests/test_chat_models.py @@ -437,3 +437,13 @@ def test_profile() -> None: model = ChatDeepSeek(model="deepseek-reasoner", api_key=SecretStr("test_key")) assert model.profile is not None assert model.profile["reasoning_output"] + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatDeepSeek(model=MODEL_NAME, api_key=SecretStr("test_key")) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-deepseek" in versions + assert "langchain-openai" in versions diff --git a/libs/partners/deepseek/tests/unit_tests/test_imports.py b/libs/partners/deepseek/tests/unit_tests/test_imports.py new file mode 100644 index 00000000000..a7c9b2f3fda --- /dev/null +++ b/libs/partners/deepseek/tests/unit_tests/test_imports.py @@ -0,0 +1,10 @@ +"""Test `langchain_deepseek` public API surface.""" + +from langchain_deepseek import __all__ + +EXPECTED_ALL = ["__version__", "ChatDeepSeek"] + + +def test_all_imports() -> None: + """Verify that `__all__` exports match the expected public API.""" + assert sorted(EXPECTED_ALL) == sorted(__all__) diff --git a/libs/partners/fireworks/Makefile b/libs/partners/fireworks/Makefile index 2c41629412e..12462f52d96 100644 --- a/libs/partners/fireworks/Makefile +++ b/libs/partners/fireworks/Makefile @@ -53,6 +53,9 @@ format format_diff: check_imports: $(shell find langchain_fireworks -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -60,6 +63,7 @@ check_imports: $(shell find langchain_fireworks -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/fireworks/langchain_fireworks/__init__.py b/libs/partners/fireworks/langchain_fireworks/__init__.py index 27fdaaee4c9..a8608eb0ca2 100644 --- a/libs/partners/fireworks/langchain_fireworks/__init__.py +++ b/libs/partners/fireworks/langchain_fireworks/__init__.py @@ -1,9 +1,9 @@ """Fireworks AI integration for LangChain.""" +from langchain_fireworks._version import __version__ from langchain_fireworks.chat_models import ChatFireworks from langchain_fireworks.embeddings import FireworksEmbeddings from langchain_fireworks.llms import Fireworks -from langchain_fireworks.version import __version__ __all__ = [ "ChatFireworks", diff --git a/libs/partners/fireworks/langchain_fireworks/_version.py b/libs/partners/fireworks/langchain_fireworks/_version.py new file mode 100644 index 00000000000..0b16a671ed0 --- /dev/null +++ b/libs/partners/fireworks/langchain_fireworks/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-fireworks`.""" + +__version__ = "1.4.2" diff --git a/libs/partners/fireworks/langchain_fireworks/chat_models.py b/libs/partners/fireworks/langchain_fireworks/chat_models.py index ee2fef10cc3..79440a6af78 100644 --- a/libs/partners/fireworks/langchain_fireworks/chat_models.py +++ b/libs/partners/fireworks/langchain_fireworks/chat_models.py @@ -100,6 +100,7 @@ from pydantic import ( from typing_extensions import Self from langchain_fireworks._compat import _convert_from_v1_to_chat_completions +from langchain_fireworks._version import __version__ from langchain_fireworks.data._profiles import _PROFILES logger = logging.getLogger(__name__) @@ -827,6 +828,12 @@ class ChatFireworks(BaseChatModel): all_required_field_names = get_pydantic_field_names(cls) return _build_model_kwargs(values, all_required_field_names) + @model_validator(mode="after") + def _set_fireworks_chat_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-fireworks", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" diff --git a/libs/partners/fireworks/langchain_fireworks/llms.py b/libs/partners/fireworks/langchain_fireworks/llms.py index 00e76ed6dee..02ca864c2b6 100644 --- a/libs/partners/fireworks/langchain_fireworks/llms.py +++ b/libs/partners/fireworks/langchain_fireworks/llms.py @@ -15,8 +15,9 @@ from langchain_core.language_models.llms import LLM from langchain_core.utils import get_pydantic_field_names from langchain_core.utils.utils import _build_model_kwargs, secret_from_env from pydantic import ConfigDict, Field, SecretStr, model_validator +from typing_extensions import Self -from langchain_fireworks.version import __version__ +from langchain_fireworks._version import __version__ logger = logging.getLogger(__name__) @@ -98,6 +99,12 @@ class Fireworks(LLM): all_required_field_names = get_pydantic_field_names(cls) return _build_model_kwargs(values, all_required_field_names) + @model_validator(mode="after") + def _set_fireworks_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-fireworks", __version__) + return self + @property def _llm_type(self) -> str: """Return type of model.""" diff --git a/libs/partners/fireworks/langchain_fireworks/version.py b/libs/partners/fireworks/langchain_fireworks/version.py index 8d54cafc7e3..5dd40a8cde3 100644 --- a/libs/partners/fireworks/langchain_fireworks/version.py +++ b/libs/partners/fireworks/langchain_fireworks/version.py @@ -1,9 +1,3 @@ -"""Main entrypoint into package.""" +"""Backwards-compatible re-export from `langchain_fireworks._version`.""" -from importlib import metadata - -try: - __version__ = metadata.version(__package__) -except metadata.PackageNotFoundError: - # Case where package metadata is not available. - __version__ = "" +from langchain_fireworks._version import __version__ # noqa: F401 diff --git a/libs/partners/fireworks/scripts/check_version.py b/libs/partners/fireworks/scripts/check_version.py new file mode 100644 index 00000000000..0876e59c4c6 --- /dev/null +++ b/libs/partners/fireworks/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_fireworks/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_fireworks" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_fireworks/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_fireworks/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py index 0db2b230d98..80574e67ddf 100644 --- a/libs/partners/fireworks/tests/unit_tests/test_chat_models.py +++ b/libs/partners/fireworks/tests/unit_tests/test_chat_models.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os from typing import Any from unittest.mock import MagicMock @@ -116,6 +117,16 @@ def test_convert_dict_to_message_without_reasoning_content() -> None: assert "reasoning_content" not in message.additional_kwargs +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + os.environ.setdefault("FIREWORKS_API_KEY", "fake-key") + llm = ChatFireworks(model="accounts/fireworks/models/llama-v3-70b-instruct") + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-fireworks" in versions + + def test_format_message_content_passthrough_string() -> None: """Plain string content is returned unchanged.""" assert _format_message_content("hello") == "hello" diff --git a/libs/partners/groq/Makefile b/libs/partners/groq/Makefile index c4df8ea33f8..43d05e3628b 100644 --- a/libs/partners/groq/Makefile +++ b/libs/partners/groq/Makefile @@ -54,6 +54,9 @@ format format_diff: check_imports: $(shell find langchain_groq -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -61,6 +64,7 @@ check_imports: $(shell find langchain_groq -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/groq/langchain_groq/__init__.py b/libs/partners/groq/langchain_groq/__init__.py index 47336d474b2..d4d01fc4c0f 100644 --- a/libs/partners/groq/langchain_groq/__init__.py +++ b/libs/partners/groq/langchain_groq/__init__.py @@ -1,6 +1,9 @@ """Groq integration for LangChain.""" +from langchain_groq._version import __version__ from langchain_groq.chat_models import ChatGroq -from langchain_groq.version import __version__ -__all__ = ["ChatGroq", "__version__"] +__all__ = [ + "ChatGroq", + "__version__", +] diff --git a/libs/partners/groq/langchain_groq/_version.py b/libs/partners/groq/langchain_groq/_version.py new file mode 100644 index 00000000000..6680704dda6 --- /dev/null +++ b/libs/partners/groq/langchain_groq/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-groq`.""" + +__version__ = "1.1.3" diff --git a/libs/partners/groq/langchain_groq/chat_models.py b/libs/partners/groq/langchain_groq/chat_models.py index 0ab2fa8a139..9cfaa31042a 100644 --- a/libs/partners/groq/langchain_groq/chat_models.py +++ b/libs/partners/groq/langchain_groq/chat_models.py @@ -71,8 +71,8 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self from langchain_groq._compat import _convert_from_v1_to_groq +from langchain_groq._version import __version__ from langchain_groq.data._profiles import _PROFILES -from langchain_groq.version import __version__ _MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES) _STRICT_STRUCTURED_OUTPUT_MODELS = frozenset( @@ -543,6 +543,12 @@ class ChatGroq(BaseChatModel): raise ImportError(msg) from exc return self + @model_validator(mode="after") + def _set_groq_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-groq", __version__) + return self + def _resolve_model_profile(self) -> ModelProfile | None: return _get_default_model_profile(self.model_name) or None diff --git a/libs/partners/groq/langchain_groq/version.py b/libs/partners/groq/langchain_groq/version.py index 8d54cafc7e3..3d228d77bad 100644 --- a/libs/partners/groq/langchain_groq/version.py +++ b/libs/partners/groq/langchain_groq/version.py @@ -1,9 +1,3 @@ -"""Main entrypoint into package.""" +"""Backwards-compatible re-export from `langchain_groq._version`.""" -from importlib import metadata - -try: - __version__ = metadata.version(__package__) -except metadata.PackageNotFoundError: - # Case where package metadata is not available. - __version__ = "" +from langchain_groq._version import __version__ # noqa: F401 diff --git a/libs/partners/groq/scripts/check_version.py b/libs/partners/groq/scripts/check_version.py new file mode 100644 index 00000000000..0bb1e325352 --- /dev/null +++ b/libs/partners/groq/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_groq/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_groq" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_groq/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_groq/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/groq/tests/unit_tests/test_chat_models.py b/libs/partners/groq/tests/unit_tests/test_chat_models.py index 33f4448bd5e..38fcf8dd60e 100644 --- a/libs/partners/groq/tests/unit_tests/test_chat_models.py +++ b/libs/partners/groq/tests/unit_tests/test_chat_models.py @@ -1095,3 +1095,12 @@ def test_format_message_content_mixed() -> None: {"type": "image_url", "image_url": {"url": "data:image/png;base64,"}}, ] assert expected == _format_message_content(content) + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatGroq(model="foo") # type: ignore[call-arg] + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-groq" in versions diff --git a/libs/partners/huggingface/Makefile b/libs/partners/huggingface/Makefile index 648e2460e56..c2b03cf4d22 100644 --- a/libs/partners/huggingface/Makefile +++ b/libs/partners/huggingface/Makefile @@ -1,4 +1,4 @@ -.PHONY: all format lint type test tests integration_tests help extended_tests +.PHONY: all format lint type test tests integration_tests help extended_tests check_version # Default target executed when no arguments are given to make. all: help @@ -55,13 +55,17 @@ format format_diff: check_imports: $(shell find langchain_huggingface -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### help: @echo '----' - @echo 'check_imports - check imports' + @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/huggingface/langchain_huggingface/__init__.py b/libs/partners/huggingface/langchain_huggingface/__init__.py index a64efaa4812..df7dbecaff9 100644 --- a/libs/partners/huggingface/langchain_huggingface/__init__.py +++ b/libs/partners/huggingface/langchain_huggingface/__init__.py @@ -1,5 +1,6 @@ """Hugging Face integration for LangChain.""" +from langchain_huggingface._version import __version__ from langchain_huggingface.chat_models import ( ChatHuggingFace, # type: ignore[import-not-found] ) @@ -18,4 +19,5 @@ __all__ = [ "HuggingFaceEndpoint", "HuggingFaceEndpointEmbeddings", "HuggingFacePipeline", + "__version__", ] diff --git a/libs/partners/huggingface/langchain_huggingface/_version.py b/libs/partners/huggingface/langchain_huggingface/_version.py new file mode 100644 index 00000000000..a9914fed922 --- /dev/null +++ b/libs/partners/huggingface/langchain_huggingface/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-huggingface`.""" + +__version__ = "1.2.2" diff --git a/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py b/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py index 52e9df5d343..22fc0a004fd 100644 --- a/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py +++ b/libs/partners/huggingface/langchain_huggingface/chat_models/huggingface.py @@ -69,6 +69,7 @@ from langchain_core.utils.pydantic import is_basemodel_subclass from pydantic import BaseModel, Field, model_validator from typing_extensions import Self +from langchain_huggingface._version import __version__ from langchain_huggingface.data._profiles import _PROFILES from langchain_huggingface.llms.huggingface_endpoint import HuggingFaceEndpoint from langchain_huggingface.llms.huggingface_pipeline import HuggingFacePipeline @@ -580,6 +581,12 @@ class ChatHuggingFace(BaseChatModel): ): self.model_kwargs = self.llm.model_kwargs.copy() + @model_validator(mode="after") + def _set_huggingface_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-huggingface", __version__) + return self + @model_validator(mode="after") def validate_llm(self) -> Self: if ( diff --git a/libs/partners/huggingface/langchain_huggingface/llms/huggingface_endpoint.py b/libs/partners/huggingface/langchain_huggingface/llms/huggingface_endpoint.py index a3a15b1c675..92c14bb18df 100644 --- a/libs/partners/huggingface/langchain_huggingface/llms/huggingface_endpoint.py +++ b/libs/partners/huggingface/langchain_huggingface/llms/huggingface_endpoint.py @@ -17,6 +17,8 @@ from langchain_core.utils import from_env, get_pydantic_field_names from pydantic import ConfigDict, Field, model_validator from typing_extensions import Self +from langchain_huggingface._version import __version__ + logger = logging.getLogger(__name__) @@ -249,6 +251,12 @@ class HuggingFaceEndpoint(LLM): raise ValueError(msg) return values + @model_validator(mode="after") + def _set_huggingface_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-huggingface", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that package is installed and that the API token is valid.""" diff --git a/libs/partners/huggingface/langchain_huggingface/llms/huggingface_pipeline.py b/libs/partners/huggingface/langchain_huggingface/llms/huggingface_pipeline.py index ba646f1309f..c7109976138 100644 --- a/libs/partners/huggingface/langchain_huggingface/llms/huggingface_pipeline.py +++ b/libs/partners/huggingface/langchain_huggingface/llms/huggingface_pipeline.py @@ -9,7 +9,9 @@ from langchain_core.callbacks import CallbackManagerForLLMRun from langchain_core.language_models.llms import BaseLLM from langchain_core.outputs import Generation, GenerationChunk, LLMResult from pydantic import ConfigDict, model_validator +from typing_extensions import Self +from langchain_huggingface._version import __version__ from langchain_huggingface.utils.import_utils import ( IMPORT_ERROR, is_ipex_available, @@ -91,6 +93,12 @@ class HuggingFacePipeline(BaseLLM): extra="forbid", ) + @model_validator(mode="after") + def _set_huggingface_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-huggingface", __version__) + return self + @model_validator(mode="before") @classmethod def pre_init_validator(cls, values: dict[str, Any]) -> dict[str, Any]: diff --git a/libs/partners/huggingface/scripts/check_version.py b/libs/partners/huggingface/scripts/check_version.py new file mode 100644 index 00000000000..8c598b7e943 --- /dev/null +++ b/libs/partners/huggingface/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_huggingface/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_huggingface" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_huggingface/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_huggingface/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/huggingface/tests/unit_tests/test_chat_models.py b/libs/partners/huggingface/tests/unit_tests/test_chat_models.py index c6de805e987..47ff8e3846c 100644 --- a/libs/partners/huggingface/tests/unit_tests/test_chat_models.py +++ b/libs/partners/huggingface/tests/unit_tests/test_chat_models.py @@ -329,6 +329,17 @@ def test_inheritance_with_empty_llm() -> None: assert chat.temperature is None +def test_metadata_versions(chat_hugging_face: Any) -> None: + """Test that metadata reports the correct version info.""" + from langchain_huggingface._version import __version__ + + assert chat_hugging_face.metadata is not None + versions = chat_hugging_face.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-huggingface" in versions + assert versions["langchain-huggingface"] == __version__ + + def test_profile() -> None: empty_llm = Mock(spec=HuggingFaceEndpoint) empty_llm.repo_id = "test/model" diff --git a/libs/partners/mistralai/Makefile b/libs/partners/mistralai/Makefile index 97d56a39d58..d1dd17af905 100644 --- a/libs/partners/mistralai/Makefile +++ b/libs/partners/mistralai/Makefile @@ -56,6 +56,9 @@ format format_diff: check_imports: $(shell find langchain_mistralai -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -63,6 +66,7 @@ check_imports: $(shell find langchain_mistralai -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/mistralai/langchain_mistralai/__init__.py b/libs/partners/mistralai/langchain_mistralai/__init__.py index 11fa44c371a..16e77b85c8c 100644 --- a/libs/partners/mistralai/langchain_mistralai/__init__.py +++ b/libs/partners/mistralai/langchain_mistralai/__init__.py @@ -1,6 +1,11 @@ """Mistral AI integration for LangChain.""" +from langchain_mistralai._version import __version__ from langchain_mistralai.chat_models import ChatMistralAI from langchain_mistralai.embeddings import MistralAIEmbeddings -__all__ = ["ChatMistralAI", "MistralAIEmbeddings"] +__all__ = [ + "ChatMistralAI", + "MistralAIEmbeddings", + "__version__", +] diff --git a/libs/partners/mistralai/langchain_mistralai/_version.py b/libs/partners/mistralai/langchain_mistralai/_version.py new file mode 100644 index 00000000000..b450251c056 --- /dev/null +++ b/libs/partners/mistralai/langchain_mistralai/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-mistralai`.""" + +__version__ = "1.1.5" diff --git a/libs/partners/mistralai/langchain_mistralai/chat_models.py b/libs/partners/mistralai/langchain_mistralai/chat_models.py index 615616d3c9b..be86042beb4 100644 --- a/libs/partners/mistralai/langchain_mistralai/chat_models.py +++ b/libs/partners/mistralai/langchain_mistralai/chat_models.py @@ -78,6 +78,7 @@ from pydantic import ( from typing_extensions import Self from langchain_mistralai._compat import _convert_from_v1_to_mistral +from langchain_mistralai._version import __version__ from langchain_mistralai.data._profiles import _PROFILES if TYPE_CHECKING: @@ -661,6 +662,12 @@ class ChatMistralAI(BaseChatModel): overall_token_usage[k] = v return {"token_usage": overall_token_usage, "model_name": self.model} + @model_validator(mode="after") + def _set_mistralai_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-mistralai", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate api key, python package exists, temperature, and top_p.""" diff --git a/libs/partners/mistralai/scripts/check_version.py b/libs/partners/mistralai/scripts/check_version.py new file mode 100644 index 00000000000..fed73474db9 --- /dev/null +++ b/libs/partners/mistralai/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_mistralai/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_mistralai" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_mistralai/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_mistralai/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/mistralai/tests/unit_tests/test_chat_models.py b/libs/partners/mistralai/tests/unit_tests/test_chat_models.py index 2ebc4f6b2ba..c68eb8411fd 100644 --- a/libs/partners/mistralai/tests/unit_tests/test_chat_models.py +++ b/libs/partners/mistralai/tests/unit_tests/test_chat_models.py @@ -653,3 +653,12 @@ def test_no_duplicate_tool_calls_when_multiple_tools() -> None: def test_profile() -> None: model = ChatMistralAI(model="mistral-large-latest") # type: ignore[call-arg] assert model.profile + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatMistralAI(model="foo") # type: ignore[call-arg] + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-mistralai" in versions diff --git a/libs/partners/mistralai/tests/unit_tests/test_imports.py b/libs/partners/mistralai/tests/unit_tests/test_imports.py index 01c220d64c9..63f4ec7f058 100644 --- a/libs/partners/mistralai/tests/unit_tests/test_imports.py +++ b/libs/partners/mistralai/tests/unit_tests/test_imports.py @@ -1,6 +1,6 @@ from langchain_mistralai import __all__ -EXPECTED_ALL = ["ChatMistralAI", "MistralAIEmbeddings"] +EXPECTED_ALL = ["__version__", "ChatMistralAI", "MistralAIEmbeddings"] def test_all_imports() -> None: diff --git a/libs/partners/ollama/Makefile b/libs/partners/ollama/Makefile index 4fa1cec6fcf..6d4031f506b 100644 --- a/libs/partners/ollama/Makefile +++ b/libs/partners/ollama/Makefile @@ -60,6 +60,9 @@ format format_diff: check_imports: $(shell find langchain_ollama -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -67,6 +70,7 @@ check_imports: $(shell find langchain_ollama -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/ollama/langchain_ollama/__init__.py b/libs/partners/ollama/langchain_ollama/__init__.py index 84a267d23c0..00cd8b9cf1b 100644 --- a/libs/partners/ollama/langchain_ollama/__init__.py +++ b/libs/partners/ollama/langchain_ollama/__init__.py @@ -2,39 +2,13 @@ Provides infrastructure for interacting with the [Ollama](https://ollama.com/) service. - -!!! note - **Newly added in 0.3.4:** `validate_model_on_init` param on all models. - This parameter allows you to validate the model exists in Ollama locally on - initialization. If set to `True`, it will raise an error if the model does not - exist locally. This is useful for ensuring that the model is available before - attempting to use it, especially in environments where models may not be - pre-downloaded. - """ -from importlib import metadata -from importlib.metadata import PackageNotFoundError -from typing import NoReturn - +from langchain_ollama._version import __version__ from langchain_ollama.chat_models import ChatOllama from langchain_ollama.embeddings import OllamaEmbeddings from langchain_ollama.llms import OllamaLLM - -def _raise_package_not_found_error() -> NoReturn: - raise PackageNotFoundError - - -try: - if __package__ is None: - _raise_package_not_found_error() - __version__ = metadata.version(__package__) -except metadata.PackageNotFoundError: - # Case where package metadata is not available. - __version__ = "" -del metadata # optional, avoids polluting the results of dir(__package__) - __all__ = [ "ChatOllama", "OllamaEmbeddings", diff --git a/libs/partners/ollama/langchain_ollama/_version.py b/libs/partners/ollama/langchain_ollama/_version.py new file mode 100644 index 00000000000..c50c0e0dfdc --- /dev/null +++ b/libs/partners/ollama/langchain_ollama/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-ollama`.""" + +__version__ = "1.1.0" diff --git a/libs/partners/ollama/langchain_ollama/chat_models.py b/libs/partners/ollama/langchain_ollama/chat_models.py index 24c09277e89..bdc7c67fd20 100644 --- a/libs/partners/ollama/langchain_ollama/chat_models.py +++ b/libs/partners/ollama/langchain_ollama/chat_models.py @@ -95,6 +95,7 @@ from langchain_ollama._utils import ( parse_url_with_auth, validate_model, ) +from langchain_ollama._version import __version__ log = logging.getLogger(__name__) @@ -921,6 +922,12 @@ class ChatOllama(BaseChatModel): ) return schema + @model_validator(mode="after") + def _set_ollama_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-ollama", __version__) + return self + @model_validator(mode="after") def _set_clients(self) -> Self: """Set clients to use for ollama.""" diff --git a/libs/partners/ollama/langchain_ollama/llms.py b/libs/partners/ollama/langchain_ollama/llms.py index 7fad56ab063..c2745bd7429 100644 --- a/libs/partners/ollama/langchain_ollama/llms.py +++ b/libs/partners/ollama/langchain_ollama/llms.py @@ -20,6 +20,7 @@ from langchain_ollama._utils import ( parse_url_with_auth, validate_model, ) +from langchain_ollama._version import __version__ class OllamaLLM(BaseLLM): @@ -319,6 +320,12 @@ class OllamaLLM(BaseLLM): params["ls_max_tokens"] = max_tokens return params + @model_validator(mode="after") + def _set_ollama_llm_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-ollama", __version__) + return self + @model_validator(mode="after") def _set_clients(self) -> Self: """Set clients to use for ollama.""" diff --git a/libs/partners/ollama/scripts/check_version.py b/libs/partners/ollama/scripts/check_version.py new file mode 100644 index 00000000000..12aece8ff2c --- /dev/null +++ b/libs/partners/ollama/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_ollama/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_ollama" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_ollama/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_ollama/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/ollama/tests/unit_tests/test_chat_models.py b/libs/partners/ollama/tests/unit_tests/test_chat_models.py index ca690ac5d45..d499a785225 100644 --- a/libs/partners/ollama/tests/unit_tests/test_chat_models.py +++ b/libs/partners/ollama/tests/unit_tests/test_chat_models.py @@ -814,6 +814,15 @@ def test_chat_ollama_ignores_strict_arg() -> None: assert "strict" not in call_kwargs +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatOllama(model=MODEL_NAME) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-ollama" in versions + + def test_chat_ollama_supports_response_format_json_schema() -> None: """Test that ChatOllama correctly maps json_schema response_format to format.""" with patch("langchain_ollama.chat_models.Client") as mock_client_class: diff --git a/libs/partners/ollama/tests/unit_tests/test_llms.py b/libs/partners/ollama/tests/unit_tests/test_llms.py index d561d3e0da0..7a63bf6504a 100644 --- a/libs/partners/ollama/tests/unit_tests/test_llms.py +++ b/libs/partners/ollama/tests/unit_tests/test_llms.py @@ -90,3 +90,12 @@ async def test_acreate_generate_stream_raises_when_client_none() -> None: with pytest.raises(RuntimeError, match="async client is not initialized"): async for _ in llm._acreate_generate_stream("Hello"): pass + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = OllamaLLM(model=MODEL_NAME) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-ollama" in versions diff --git a/libs/partners/openai/Makefile b/libs/partners/openai/Makefile index c4ffe78aa26..6516bc88137 100644 --- a/libs/partners/openai/Makefile +++ b/libs/partners/openai/Makefile @@ -72,6 +72,9 @@ format format_diff: check_imports: $(shell find langchain_openai -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -79,6 +82,7 @@ check_imports: $(shell find langchain_openai -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/openai/langchain_openai/__init__.py b/libs/partners/openai/langchain_openai/__init__.py index e55c6042561..c70be734874 100644 --- a/libs/partners/openai/langchain_openai/__init__.py +++ b/libs/partners/openai/langchain_openai/__init__.py @@ -1,5 +1,6 @@ """Module for OpenAI integrations.""" +from langchain_openai._version import __version__ from langchain_openai.chat_models import AzureChatOpenAI, ChatOpenAI from langchain_openai.chat_models._client_utils import StreamChunkTimeoutError from langchain_openai.embeddings import AzureOpenAIEmbeddings, OpenAIEmbeddings @@ -14,5 +15,6 @@ __all__ = [ "OpenAI", "OpenAIEmbeddings", "StreamChunkTimeoutError", + "__version__", "custom_tool", ] diff --git a/libs/partners/openai/langchain_openai/_version.py b/libs/partners/openai/langchain_openai/_version.py new file mode 100644 index 00000000000..f99b37933c0 --- /dev/null +++ b/libs/partners/openai/langchain_openai/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-openai`.""" + +__version__ = "1.3.0" diff --git a/libs/partners/openai/langchain_openai/chat_models/base.py b/libs/partners/openai/langchain_openai/chat_models/base.py index 41bb467f6de..35c21dda9a4 100644 --- a/libs/partners/openai/langchain_openai/chat_models/base.py +++ b/libs/partners/openai/langchain_openai/chat_models/base.py @@ -130,6 +130,7 @@ from pydantic import ( from pydantic.v1 import BaseModel as BaseModelV1 from typing_extensions import Self +from langchain_openai._version import __version__ from langchain_openai.chat_models._client_utils import ( _astream_with_chunk_timeout, _build_proxied_async_httpx_client, @@ -1136,6 +1137,20 @@ class BaseChatOpenAI(BaseChatModel): return values + @model_validator(mode="after") + def _set_openai_chat_version(self) -> Self: + """Set package version in metadata. + + Note: Subclasses that inherit from `BaseChatOpenAI` (e.g. + `ChatDeepSeek`, `ChatXAI`) must use a **unique** validator name + (e.g. `_set_deepseek_version`) instead of overriding this one. Pydantic + replaces same-named `model_validator` methods rather than chaining them, + so reusing `_set_openai_chat_version` would silently drop the parent's + `langchain-openai` version entry. + """ + self._add_version("langchain-openai", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" diff --git a/libs/partners/openai/langchain_openai/llms/azure.py b/libs/partners/openai/langchain_openai/llms/azure.py index 723bd7e346c..3a67dc3b74c 100644 --- a/libs/partners/openai/langchain_openai/llms/azure.py +++ b/libs/partners/openai/langchain_openai/llms/azure.py @@ -12,6 +12,7 @@ from langchain_core.utils import from_env, secret_from_env from pydantic import Field, SecretStr, model_validator from typing_extensions import Self +from langchain_openai._version import __version__ from langchain_openai.llms.base import BaseOpenAI logger = logging.getLogger(__name__) @@ -117,6 +118,12 @@ class AzureOpenAI(BaseOpenAI): """Return whether this model can be serialized by LangChain.""" return True + @model_validator(mode="after") + def _set_azure_openai_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-openai", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" diff --git a/libs/partners/openai/langchain_openai/llms/base.py b/libs/partners/openai/langchain_openai/llms/base.py index 79032b57e29..6926301bc71 100644 --- a/libs/partners/openai/langchain_openai/llms/base.py +++ b/libs/partners/openai/langchain_openai/llms/base.py @@ -21,6 +21,7 @@ from langchain_core.utils.utils import _build_model_kwargs, from_env, secret_fro from pydantic import ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self +from langchain_openai._version import __version__ from langchain_openai.data._profiles import _PROFILES logger = logging.getLogger(__name__) @@ -310,6 +311,12 @@ class BaseOpenAI(BaseLLM): all_required_field_names = get_pydantic_field_names(cls) return _build_model_kwargs(values, all_required_field_names) + @model_validator(mode="after") + def _set_openai_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-openai", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" diff --git a/libs/partners/openai/scripts/check_version.py b/libs/partners/openai/scripts/check_version.py new file mode 100644 index 00000000000..50f615d0a95 --- /dev/null +++ b/libs/partners/openai/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_openai/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_openai" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_openai/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_openai/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_azure.py b/libs/partners/openai/tests/unit_tests/chat_models/test_azure.py index b7e9812ea2d..9e07518d133 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_azure.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_azure.py @@ -269,3 +269,16 @@ def test_max_tokens_converted_to_max_completion_tokens() -> None: assert "max_completion_tokens" in payload assert payload["max_completion_tokens"] == 1000 assert "max_tokens" not in payload + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = AzureChatOpenAI( # type: ignore[call-arg] + azure_deployment="35-turbo-dev", + openai_api_version="2023-05-15", + azure_endpoint="my-base-url", + ) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-openai" in versions diff --git a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py index f9ab9169058..89808b63aa9 100644 --- a/libs/partners/openai/tests/unit_tests/chat_models/test_base.py +++ b/libs/partners/openai/tests/unit_tests/chat_models/test_base.py @@ -4077,6 +4077,15 @@ def test_context_overflow_error_backwards_compatibility() -> None: assert isinstance(exc_info.value, ContextOverflowError) +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatOpenAI() + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-openai" in versions + + def test_get_request_payload_responses_api_input_file_blocks_passthrough() -> None: llm = ChatOpenAI(model="gpt-5", use_responses_api=True) payload = llm._get_request_payload( diff --git a/libs/partners/openai/tests/unit_tests/test_check_version.py b/libs/partners/openai/tests/unit_tests/test_check_version.py new file mode 100644 index 00000000000..f8cab7d0ee1 --- /dev/null +++ b/libs/partners/openai/tests/unit_tests/test_check_version.py @@ -0,0 +1,62 @@ +"""Unit tests for `scripts/check_version.py`. + +The version-consistency check is duplicated verbatim across partner packages, so +exercising the parsing helpers here covers the shared logic. The script is loaded +by path because `scripts/` is not an importable package. +""" + +import importlib.util +from pathlib import Path +from types import ModuleType + +import pytest + +_SCRIPT_PATH = Path(__file__).parents[2] / "scripts" / "check_version.py" + + +def _load_script() -> ModuleType: + spec = importlib.util.spec_from_file_location("_check_version", _SCRIPT_PATH) + assert spec is not None + assert spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +check_version = _load_script() + + +def test_get_pyproject_version_parses_version(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\nversion = "1.2.3"\n', encoding="utf-8") + assert check_version.get_pyproject_version(pyproject) == "1.2.3" + + +def test_get_pyproject_version_missing_returns_none(tmp_path: Path) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "x"\n', encoding="utf-8") + assert check_version.get_pyproject_version(pyproject) is None + + +def test_get_version_py_version_parses_version(tmp_path: Path) -> None: + version_py = tmp_path / "_version.py" + version_py.write_text('__version__ = "4.5.6"\n', encoding="utf-8") + assert check_version.get_version_py_version(version_py) == "4.5.6" + + +def test_get_version_py_version_missing_returns_none(tmp_path: Path) -> None: + version_py = tmp_path / "_version.py" + version_py.write_text('"""No version here."""\n', encoding="utf-8") + assert check_version.get_version_py_version(version_py) is None + + +def test_main_passes_for_real_package() -> None: + """The shipped `pyproject.toml` and `_version.py` must already agree.""" + assert check_version.main() == 0 + + +def test_main_reports_mismatch(monkeypatch: pytest.MonkeyPatch) -> None: + """A version mismatch must fail loudly with a non-zero exit code.""" + monkeypatch.setattr(check_version, "get_pyproject_version", lambda _: "1.0.0") + monkeypatch.setattr(check_version, "get_version_py_version", lambda _: "2.0.0") + assert check_version.main() == 1 diff --git a/libs/partners/openai/tests/unit_tests/test_imports.py b/libs/partners/openai/tests/unit_tests/test_imports.py index 2d3255366ba..59994381974 100644 --- a/libs/partners/openai/tests/unit_tests/test_imports.py +++ b/libs/partners/openai/tests/unit_tests/test_imports.py @@ -1,6 +1,7 @@ from langchain_openai import __all__ EXPECTED_ALL = [ + "__version__", "OpenAI", "ChatOpenAI", "OpenAIEmbeddings", diff --git a/libs/partners/openrouter/Makefile b/libs/partners/openrouter/Makefile index 3259904a978..4dacb32a9d3 100644 --- a/libs/partners/openrouter/Makefile +++ b/libs/partners/openrouter/Makefile @@ -55,6 +55,9 @@ format format_diff: check_imports: $(shell find langchain_openrouter -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -62,6 +65,7 @@ check_imports: $(shell find langchain_openrouter -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/openrouter/langchain_openrouter/__init__.py b/libs/partners/openrouter/langchain_openrouter/__init__.py index ca2c47a43d7..9da7a35f813 100644 --- a/libs/partners/openrouter/langchain_openrouter/__init__.py +++ b/libs/partners/openrouter/langchain_openrouter/__init__.py @@ -1,7 +1,9 @@ """LangChain OpenRouter integration.""" +from langchain_openrouter._version import __version__ from langchain_openrouter.chat_models import ChatOpenRouter __all__ = [ "ChatOpenRouter", + "__version__", ] diff --git a/libs/partners/openrouter/langchain_openrouter/_version.py b/libs/partners/openrouter/langchain_openrouter/_version.py new file mode 100644 index 00000000000..887d17d3212 --- /dev/null +++ b/libs/partners/openrouter/langchain_openrouter/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-openrouter`.""" + +__version__ = "0.2.3" diff --git a/libs/partners/openrouter/langchain_openrouter/chat_models.py b/libs/partners/openrouter/langchain_openrouter/chat_models.py index b0ef381fbb4..fcc98284852 100644 --- a/libs/partners/openrouter/langchain_openrouter/chat_models.py +++ b/libs/partners/openrouter/langchain_openrouter/chat_models.py @@ -69,6 +69,7 @@ from langchain_core.utils.pydantic import is_basemodel_subclass from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self +from langchain_openrouter._version import __version__ from langchain_openrouter.data._profiles import _PROFILES _MODEL_PROFILES = cast("ModelProfileRegistry", _PROFILES) @@ -410,6 +411,12 @@ class ChatOpenRouter(BaseChatModel): ) return openrouter.OpenRouter(**client_kwargs) + @model_validator(mode="after") + def _set_openrouter_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-openrouter", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate configuration and build the SDK client.""" diff --git a/libs/partners/openrouter/scripts/check_version.py b/libs/partners/openrouter/scripts/check_version.py new file mode 100644 index 00000000000..88c28a17086 --- /dev/null +++ b/libs/partners/openrouter/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_openrouter/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_openrouter" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_openrouter/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_openrouter/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py index 1c6bd7fb744..c4965014e10 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_chat_models.py +++ b/libs/partners/openrouter/tests/unit_tests/test_chat_models.py @@ -274,6 +274,14 @@ class TestChatOpenRouterInstantiation: ls_params = model._get_ls_params() assert ls_params["ls_stop"] == ["END", "STOP"] + def test_metadata_versions(self) -> None: + """Test that metadata reports the correct version info.""" + model = _make_model() + assert model.metadata is not None + versions = model.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-openrouter" in versions + def test_client_created(self) -> None: """Test that OpenRouter SDK client is created.""" model = _make_model() diff --git a/libs/partners/openrouter/tests/unit_tests/test_imports.py b/libs/partners/openrouter/tests/unit_tests/test_imports.py index d46ab65340d..554989ddba4 100644 --- a/libs/partners/openrouter/tests/unit_tests/test_imports.py +++ b/libs/partners/openrouter/tests/unit_tests/test_imports.py @@ -3,6 +3,7 @@ from langchain_openrouter import __all__ EXPECTED_ALL = [ + "__version__", "ChatOpenRouter", ] diff --git a/libs/partners/perplexity/Makefile b/libs/partners/perplexity/Makefile index f0dcbbc9cae..16aec97148b 100644 --- a/libs/partners/perplexity/Makefile +++ b/libs/partners/perplexity/Makefile @@ -1,4 +1,4 @@ -.PHONY: all format lint type test tests integration_tests help extended_tests +.PHONY: all format lint type test tests integration_tests help extended_tests check_version # Default target executed when no arguments are given to make. all: help @@ -54,13 +54,17 @@ format format_diff: check_imports: $(shell find langchain_perplexity -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### help: @echo '----' - @echo 'check_imports - check imports' + @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/perplexity/langchain_perplexity/__init__.py b/libs/partners/perplexity/langchain_perplexity/__init__.py index 22447cfde40..1347c7e6455 100644 --- a/libs/partners/perplexity/langchain_perplexity/__init__.py +++ b/libs/partners/perplexity/langchain_perplexity/__init__.py @@ -1,5 +1,6 @@ """Perplexity AI integration for LangChain.""" +from langchain_perplexity._version import __version__ from langchain_perplexity.chat_models import ChatPerplexity from langchain_perplexity.embeddings import PerplexityEmbeddings from langchain_perplexity.output_parsers import ( @@ -17,6 +18,7 @@ from langchain_perplexity.types import ( ) __all__ = [ + "__version__", "ChatPerplexity", "PerplexityEmbeddings", "PerplexitySearchRetriever", diff --git a/libs/partners/perplexity/langchain_perplexity/_version.py b/libs/partners/perplexity/langchain_perplexity/_version.py new file mode 100644 index 00000000000..8f43ed3b4cb --- /dev/null +++ b/libs/partners/perplexity/langchain_perplexity/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-perplexity`.""" + +__version__ = "1.4.0" diff --git a/libs/partners/perplexity/langchain_perplexity/chat_models.py b/libs/partners/perplexity/langchain_perplexity/chat_models.py index 53f73a81ec0..b45853d5a76 100644 --- a/libs/partners/perplexity/langchain_perplexity/chat_models.py +++ b/libs/partners/perplexity/langchain_perplexity/chat_models.py @@ -56,6 +56,7 @@ from perplexity import AsyncPerplexity, Perplexity from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self +from langchain_perplexity._version import __version__ from langchain_perplexity.data._profiles import _PROFILES from langchain_perplexity.output_parsers import ( ReasoningJsonOutputParser, @@ -839,6 +840,12 @@ class ChatPerplexity(BaseChatModel): values["model_kwargs"] = extra return values + @model_validator(mode="after") + def _set_perplexity_version(self) -> Self: + """Set package version in metadata.""" + self._add_version("langchain-perplexity", __version__) + return self + @model_validator(mode="after") def validate_environment(self) -> Self: """Validate that api key and python package exists in environment.""" diff --git a/libs/partners/perplexity/scripts/check_version.py b/libs/partners/perplexity/scripts/check_version.py new file mode 100644 index 00000000000..cd8e0d7bc0b --- /dev/null +++ b/libs/partners/perplexity/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_perplexity/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_perplexity" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_perplexity/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_perplexity/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py index 63068341210..5706d15e7f7 100644 --- a/libs/partners/perplexity/tests/unit_tests/test_chat_models.py +++ b/libs/partners/perplexity/tests/unit_tests/test_chat_models.py @@ -223,6 +223,18 @@ def test_perplexity_invoke_includes_num_search_queries(mocker: MockerFixture) -> patcher.assert_called_once() +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + from langchain_perplexity._version import __version__ + + llm = ChatPerplexity(model="test") + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-perplexity" in versions + assert versions["langchain-perplexity"] == __version__ + + def test_profile() -> None: model = ChatPerplexity(model="sonar") assert model.profile diff --git a/libs/partners/perplexity/tests/unit_tests/test_imports.py b/libs/partners/perplexity/tests/unit_tests/test_imports.py index 57168f50e48..bd99d4bd6eb 100644 --- a/libs/partners/perplexity/tests/unit_tests/test_imports.py +++ b/libs/partners/perplexity/tests/unit_tests/test_imports.py @@ -1,6 +1,7 @@ from langchain_perplexity import __all__ EXPECTED_ALL = [ + "__version__", "ChatPerplexity", "PerplexityEmbeddings", "PerplexitySearchRetriever", diff --git a/libs/partners/xai/Makefile b/libs/partners/xai/Makefile index 3642ccafa6d..d56d79d00eb 100644 --- a/libs/partners/xai/Makefile +++ b/libs/partners/xai/Makefile @@ -53,6 +53,9 @@ format format_diff: check_imports: $(shell find langchain_xai -name '*.py') $(UV_RUN_LINT) python ./scripts/check_imports.py $^ +check_version: + uv run python ./scripts/check_version.py + ###################### # HELP ###################### @@ -60,6 +63,7 @@ check_imports: $(shell find langchain_xai -name '*.py') help: @echo '----' @echo 'check_imports - check imports' + @echo 'check_version - validate version consistency' @echo 'format - run code formatters' @echo 'lint - run linters' @echo 'type - run type checking' diff --git a/libs/partners/xai/langchain_xai/__init__.py b/libs/partners/xai/langchain_xai/__init__.py index 3ba202d04d8..15f54b2bdc0 100644 --- a/libs/partners/xai/langchain_xai/__init__.py +++ b/libs/partners/xai/langchain_xai/__init__.py @@ -1,5 +1,9 @@ """LangChain integration with xAI.""" +from langchain_xai._version import __version__ from langchain_xai.chat_models import ChatXAI -__all__ = ["ChatXAI"] +__all__ = [ + "ChatXAI", + "__version__", +] diff --git a/libs/partners/xai/langchain_xai/_version.py b/libs/partners/xai/langchain_xai/_version.py new file mode 100644 index 00000000000..949ac42abbe --- /dev/null +++ b/libs/partners/xai/langchain_xai/_version.py @@ -0,0 +1,3 @@ +"""Version information for `langchain-xai`.""" + +__version__ = "1.2.2" diff --git a/libs/partners/xai/langchain_xai/chat_models.py b/libs/partners/xai/langchain_xai/chat_models.py index 359667376e4..aa78391768f 100644 --- a/libs/partners/xai/langchain_xai/chat_models.py +++ b/libs/partners/xai/langchain_xai/chat_models.py @@ -12,6 +12,7 @@ from langchain_openai.chat_models.base import BaseChatOpenAI from pydantic import BaseModel, ConfigDict, Field, SecretStr, model_validator from typing_extensions import Self +from langchain_xai._version import __version__ from langchain_xai.data._profiles import _PROFILES if TYPE_CHECKING: @@ -468,6 +469,16 @@ class ChatXAI(BaseChatOpenAI): # type: ignore[override] params["ls_provider"] = "xai" return params + @model_validator(mode="after") + def _set_xai_version(self) -> Self: + """Set package version in metadata. + + Named uniquely to avoid shadowing `BaseChatOpenAI._set_openai_chat_version`; + Pydantic replaces same-named validators rather than chaining them. + """ + self._add_version("langchain-xai", __version__) + return self + @model_validator(mode="after") def _warn_search_parameters_deprecated(self) -> Self: """Emit deprecation warning if search_parameters (Live Search) is used.""" diff --git a/libs/partners/xai/scripts/check_version.py b/libs/partners/xai/scripts/check_version.py new file mode 100644 index 00000000000..c17ebc2bcf8 --- /dev/null +++ b/libs/partners/xai/scripts/check_version.py @@ -0,0 +1,65 @@ +"""Check version consistency between `pyproject.toml` and `_version.py`. + +This script validates that the version defined in pyproject.toml matches the +`__version__` variable in `langchain_xai/_version.py`. Intended for use as a +CI check to prevent version mismatches. +""" + +import re +import sys +from pathlib import Path + + +def get_pyproject_version(pyproject_path: Path) -> str | None: + """Extract version from `pyproject.toml`.""" + content = pyproject_path.read_text(encoding="utf-8") + match = re.search(r'^version\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def get_version_py_version(version_path: Path) -> str | None: + """Extract `__version__` from `_version.py`.""" + content = version_path.read_text(encoding="utf-8") + match = re.search(r'^__version__\s*=\s*"([^"]+)"', content, re.MULTILINE) + return match.group(1) if match else None + + +def main() -> int: + """Validate version consistency.""" + script_dir = Path(__file__).parent + package_dir = script_dir.parent + + pyproject_path = package_dir / "pyproject.toml" + version_path = package_dir / "langchain_xai" / "_version.py" + + if not pyproject_path.exists(): + print(f"Error: {pyproject_path} not found") # noqa: T201 + return 1 + + if not version_path.exists(): + print(f"Error: {version_path} not found") # noqa: T201 + return 1 + + pyproject_version = get_pyproject_version(pyproject_path) + version_py_version = get_version_py_version(version_path) + + if pyproject_version is None: + print("Error: Could not find version in pyproject.toml") # noqa: T201 + return 1 + + if version_py_version is None: + print("Error: Could not find __version__ in langchain_xai/_version.py") # noqa: T201 + return 1 + + if pyproject_version != version_py_version: + print("Error: Version mismatch detected!") # noqa: T201 + print(f" pyproject.toml: {pyproject_version}") # noqa: T201 + print(f" langchain_xai/_version.py: {version_py_version}") # noqa: T201 + return 1 + + print(f"Version check passed: {pyproject_version}") # noqa: T201 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/libs/partners/xai/tests/unit_tests/test_chat_models.py b/libs/partners/xai/tests/unit_tests/test_chat_models.py index 1bbdc9d6596..e0074b17e76 100644 --- a/libs/partners/xai/tests/unit_tests/test_chat_models.py +++ b/libs/partners/xai/tests/unit_tests/test_chat_models.py @@ -161,3 +161,13 @@ def test_stream_usage_metadata() -> None: model = ChatXAI(model=MODEL_NAME, stream_usage=False) assert model.stream_usage is False + + +def test_metadata_versions() -> None: + """Test that metadata reports the correct version info.""" + llm = ChatXAI(model=MODEL_NAME) + assert llm.metadata is not None + versions = llm.metadata["versions"] + assert "langchain-core" in versions + assert "langchain-xai" in versions + assert "langchain-openai" in versions diff --git a/libs/partners/xai/tests/unit_tests/test_imports.py b/libs/partners/xai/tests/unit_tests/test_imports.py index e1a75c65920..1d739d4c846 100644 --- a/libs/partners/xai/tests/unit_tests/test_imports.py +++ b/libs/partners/xai/tests/unit_tests/test_imports.py @@ -1,6 +1,6 @@ from langchain_xai import __all__ -EXPECTED_ALL = ["ChatXAI"] +EXPECTED_ALL = ["__version__", "ChatXAI"] def test_all_imports() -> None: