chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
# Dev container
|
||||
|
||||
This project includes a [dev container](https://containers.dev/), which lets you use a container as a full-featured dev environment.
|
||||
|
||||
You can use the dev container configuration in this folder to build and run the app without needing to install any of its tools locally! You can use it in [GitHub Codespaces](https://github.com/features/codespaces) or the [VS Code Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers).
|
||||
|
||||
## GitHub Codespaces
|
||||
|
||||
[](https://codespaces.new/langchain-ai/langchain)
|
||||
|
||||
You may use the button above, or follow these steps to open this repo in a Codespace:
|
||||
|
||||
1. Click the **Code** drop-down menu at the top of <https://github.com/langchain-ai/langchain>.
|
||||
1. Click on the **Codespaces** tab.
|
||||
1. Click **Create codespace on master**.
|
||||
|
||||
For more info, check out the [GitHub documentation](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/creating-a-codespace#creating-a-codespace).
|
||||
|
||||
## VS Code Dev Containers
|
||||
|
||||
[](https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/langchain-ai/langchain)
|
||||
|
||||
> [!NOTE]
|
||||
> If you click the link above you will open the main repo (`langchain-ai/langchain`) and *not* your local cloned repo. This is fine if you only want to run and test the library, but if you want to contribute you can use the link below and replace with your username and cloned repo name:
|
||||
|
||||
```txt
|
||||
https://vscode.dev/redirect?url=vscode://ms-vscode-remote.remote-containers/cloneInVolume?url=https://github.com/<YOUR_USERNAME>/<YOUR_CLONED_REPO_NAME>
|
||||
```
|
||||
|
||||
Then you will have a local cloned repo where you can contribute and then create pull requests.
|
||||
|
||||
If you already have VS Code and Docker installed, you can use the button above to get started. This will use VSCode to automatically install the Dev Containers extension if needed, clone the source code into a container volume, and spin up a dev container for use.
|
||||
|
||||
Alternatively you can also follow these steps to open this repo in a container using the VS Code Dev Containers extension:
|
||||
|
||||
1. If this is your first time using a development container, please ensure your system meets the pre-reqs (i.e. have Docker installed) in the [getting started steps](https://aka.ms/vscode-remote/containers/getting-started).
|
||||
|
||||
2. Open a locally cloned copy of the code:
|
||||
|
||||
- Fork and Clone this repository to your local filesystem.
|
||||
- Press <kbd>F1</kbd> and select the **Dev Containers: Open Folder in Container...** command.
|
||||
- Select the cloned copy of this folder, wait for the container to start, and try things out!
|
||||
|
||||
You can learn more in the [Dev Containers documentation](https://code.visualstudio.com/docs/devcontainers/containers).
|
||||
|
||||
## Tips and tricks
|
||||
|
||||
- If you are working with the same repository folder in a container and Windows, you'll want consistent line endings (otherwise you may see hundreds of changes in the SCM view). The `.gitattributes` file in the root of this repo will disable line ending conversion and should prevent this. See [tips and tricks](https://code.visualstudio.com/docs/devcontainers/tips-and-tricks#_resolving-git-line-ending-issues-in-containers-resulting-in-many-modified-files) for more info.
|
||||
- If you'd like to review the contents of the image used in this dev container, you can check it out in the [devcontainers/images](https://github.com/devcontainers/images/tree/main/src/python) repo.
|
||||
@@ -0,0 +1,58 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
|
||||
// README at: https://github.com/devcontainers/templates/tree/main/src/docker-existing-docker-compose
|
||||
{
|
||||
// Name for the dev container
|
||||
"name": "langchain",
|
||||
// Point to a Docker Compose file
|
||||
"dockerComposeFile": "./docker-compose.yaml",
|
||||
// Required when using Docker Compose. The name of the service to connect to once running
|
||||
"service": "langchain",
|
||||
// The optional 'workspaceFolder' property is the path VS Code should open by default when
|
||||
// connected. This is typically a file mount in .devcontainer/docker-compose.yml
|
||||
"workspaceFolder": "/workspaces/langchain",
|
||||
"mounts": [
|
||||
"source=langchain-workspaces,target=/workspaces/langchain,type=volume"
|
||||
],
|
||||
// Prevent the container from shutting down
|
||||
"overrideCommand": true,
|
||||
// Features to add to the dev container. More info: https://containers.dev/features
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/git:1": {},
|
||||
"ghcr.io/devcontainers/features/github-cli:1": {}
|
||||
},
|
||||
"containerEnv": {
|
||||
"UV_LINK_MODE": "copy"
|
||||
},
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
// Run commands after the container is created
|
||||
"postCreateCommand": "cd libs/langchain_v1 && uv sync && echo 'LangChain (Python) dev environment ready!'",
|
||||
// Configure tool-specific properties.
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
"ms-python.python",
|
||||
"ms-python.debugpy",
|
||||
"ms-python.mypy-type-checker",
|
||||
"ms-python.isort",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"ms-toolsai.jupyter",
|
||||
"GitHub.copilot",
|
||||
"GitHub.copilot-chat"
|
||||
],
|
||||
"settings": {
|
||||
"python.defaultInterpreterPath": "libs/langchain_v1/.venv/bin/python",
|
||||
"python.formatting.provider": "none",
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
|
||||
// "remoteUser": "root"
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
version: '3'
|
||||
services:
|
||||
langchain:
|
||||
build:
|
||||
dockerfile: libs/langchain/dev.Dockerfile
|
||||
context: ..
|
||||
|
||||
networks:
|
||||
- langchain-network
|
||||
|
||||
networks:
|
||||
langchain-network:
|
||||
driver: bridge
|
||||
@@ -0,0 +1,34 @@
|
||||
# Git
|
||||
.git
|
||||
.github
|
||||
|
||||
# Python
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
.venv
|
||||
.mypy_cache
|
||||
.pytest_cache
|
||||
.ruff_cache
|
||||
*.egg-info
|
||||
.tox
|
||||
|
||||
# IDE
|
||||
.idea
|
||||
.vscode
|
||||
|
||||
# Worktree
|
||||
worktree
|
||||
|
||||
# Test artifacts
|
||||
.coverage
|
||||
htmlcov
|
||||
coverage.xml
|
||||
|
||||
# Build artifacts
|
||||
dist
|
||||
build
|
||||
|
||||
# Misc
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,52 @@
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
# All files
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
# Python files
|
||||
[*.py]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
max_line_length = 88
|
||||
|
||||
# JSON files
|
||||
[*.json]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# YAML files
|
||||
[*.{yml,yaml}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Markdown files
|
||||
[*.md]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
# Configuration files
|
||||
[*.{toml,ini,cfg}]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
# Shell scripts
|
||||
[*.sh]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
# Makefile
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
indent_size = 4
|
||||
|
||||
# Jupyter notebooks
|
||||
[*.ipynb]
|
||||
# Jupyter may include trailing whitespace in cell
|
||||
# outputs that's semantically meaningful
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,3 @@
|
||||
* text=auto eol=lf
|
||||
*.{cmd,[cC][mM][dD]} text eol=crlf
|
||||
*.{bat,[bB][aA][tT]} text eol=crlf
|
||||
@@ -0,0 +1,3 @@
|
||||
/.github/ @ccurme @eyurtsev @mdrxy
|
||||
/libs/core/ @eyurtsev
|
||||
/libs/partners/ @ccurme @mdrxy
|
||||
@@ -0,0 +1,153 @@
|
||||
name: "\U0001F41B Bug Report"
|
||||
description: Report a bug in LangChain. To report a security issue, please instead use the security option (below). For questions, please use the LangChain forum (below).
|
||||
labels: ["bug"]
|
||||
type: bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
|
||||
|
||||
Thank you for taking the time to file a bug report.
|
||||
|
||||
For usage questions, feature requests and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
|
||||
|
||||
Check these before submitting to see if your issue has already been reported, fixed or if there's another way to solve your problem:
|
||||
|
||||
* [Documentation](https://docs.langchain.com/oss/python/langchain/overview),
|
||||
* [API Reference Documentation](https://reference.langchain.com/python/),
|
||||
* [LangChain ChatBot](https://chat.langchain.com/)
|
||||
* [GitHub search](https://github.com/langchain-ai/langchain),
|
||||
* [LangChain Forum](https://forum.langchain.com/),
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Submission checklist
|
||||
description: Please confirm and check all the following options.
|
||||
options:
|
||||
- label: This is a bug, not a usage question.
|
||||
required: true
|
||||
- label: I added a clear and descriptive title that summarizes this issue.
|
||||
required: true
|
||||
- label: I used the GitHub search to find a similar question and didn't find it.
|
||||
required: true
|
||||
- label: I am sure that this is a bug in LangChain rather than my code.
|
||||
required: true
|
||||
- label: The bug is not resolved by updating to the latest stable version of LangChain (or the specific integration package).
|
||||
required: true
|
||||
- label: This is not related to the langchain-community package.
|
||||
required: true
|
||||
- label: I posted a self-contained, minimal, reproducible example. A maintainer can copy it and run it AS IS.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: package
|
||||
attributes:
|
||||
label: Package (Required)
|
||||
description: |
|
||||
Which `langchain` package(s) is this bug related to? Select at least one.
|
||||
|
||||
Note that if the package you are reporting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in [`langchain-ai/langchain-google`](https://github.com/langchain-ai/langchain-google/)).
|
||||
|
||||
Please report issues for other packages to their respective repositories.
|
||||
options:
|
||||
- label: langchain
|
||||
- label: langchain-openai
|
||||
- label: langchain-anthropic
|
||||
- label: langchain-classic
|
||||
- label: langchain-core
|
||||
- label: langchain-model-profiles
|
||||
- label: langchain-tests
|
||||
- label: langchain-text-splitters
|
||||
- label: langchain-chroma
|
||||
- label: langchain-deepseek
|
||||
- label: langchain-exa
|
||||
- label: langchain-fireworks
|
||||
- label: langchain-groq
|
||||
- label: langchain-huggingface
|
||||
- label: langchain-mistralai
|
||||
- label: langchain-nomic
|
||||
- label: langchain-ollama
|
||||
- label: langchain-openrouter
|
||||
- label: langchain-perplexity
|
||||
- label: langchain-qdrant
|
||||
- label: langchain-xai
|
||||
- label: Other / not sure / general
|
||||
- type: textarea
|
||||
id: related
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Related Issues / PRs
|
||||
description: |
|
||||
If this bug is related to any existing issues or pull requests, please link them here.
|
||||
placeholder: |
|
||||
* e.g. #123, #456
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Reproduction Steps / Example Code (Python)
|
||||
description: |
|
||||
Please add a self-contained, [minimal, reproducible, example](https://stackoverflow.com/help/minimal-reproducible-example) with your use case.
|
||||
|
||||
If a maintainer can copy it, run it, and see it right away, there's a much higher chance that you'll be able to get help.
|
||||
|
||||
**Important!**
|
||||
|
||||
* Avoid screenshots, as they are hard to read and (more importantly) don't allow others to copy-and-paste your code.
|
||||
* Reduce your code to the minimum required to reproduce the issue if possible.
|
||||
|
||||
(This will be automatically formatted into code, so no need for backticks.)
|
||||
render: python
|
||||
placeholder: |
|
||||
from langchain_core.runnables import RunnableLambda
|
||||
|
||||
def bad_code(inputs) -> int:
|
||||
raise NotImplementedError('For demo purpose')
|
||||
|
||||
chain = RunnableLambda(bad_code)
|
||||
chain.invoke('Hello!')
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Error Message and Stack Trace (if applicable)
|
||||
description: |
|
||||
If you are reporting an error, please copy and paste the full error message and
|
||||
stack trace.
|
||||
(This will be automatically formatted into code, so no need for backticks.)
|
||||
render: shell
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: Description
|
||||
description: |
|
||||
What is the problem, question, or error?
|
||||
|
||||
Write a short description telling what you are doing, what you expect to happen, and what is currently happening.
|
||||
placeholder: |
|
||||
* I'm trying to use the `langchain` library to do X.
|
||||
* I expect to see Y.
|
||||
* Instead, it does Z.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: system-info
|
||||
attributes:
|
||||
label: System Info
|
||||
description: |
|
||||
Please share your system info with us.
|
||||
|
||||
Run the following command in your terminal and paste the output here:
|
||||
|
||||
`python -m langchain_core.sys_info`
|
||||
|
||||
or if you have an existing python interpreter running:
|
||||
|
||||
```python
|
||||
from langchain_core import sys_info
|
||||
sys_info.print_sys_info()
|
||||
```
|
||||
placeholder: |
|
||||
python -m langchain_core.sys_info
|
||||
validations:
|
||||
required: true
|
||||
@@ -0,0 +1,15 @@
|
||||
blank_issues_enabled: false
|
||||
version: 2.1
|
||||
contact_links:
|
||||
- name: 💬 LangChain Forum
|
||||
url: https://forum.langchain.com/
|
||||
about: General community discussions and support
|
||||
- name: 📚 LangChain Documentation
|
||||
url: https://docs.langchain.com/oss/python/langchain/overview
|
||||
about: View the official LangChain documentation
|
||||
- name: 📚 API Reference Documentation
|
||||
url: https://reference.langchain.com/python/
|
||||
about: View the official LangChain API reference documentation
|
||||
- name: 📚 Documentation issue
|
||||
url: https://github.com/langchain-ai/docs/issues/new?template=01-langchain.yml
|
||||
about: Report an issue related to the LangChain documentation
|
||||
@@ -0,0 +1,155 @@
|
||||
name: "✨ Feature Request"
|
||||
description: Request a new feature or enhancement for LangChain. For questions, please use the LangChain forum (below).
|
||||
labels: ["feature request"]
|
||||
type: feature
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
|
||||
|
||||
Thank you for taking the time to request a new feature.
|
||||
|
||||
Use this to request NEW FEATURES or ENHANCEMENTS in LangChain. For bug reports, please use the bug report template. For usage questions and general design questions, please use the [LangChain Forum](https://forum.langchain.com/).
|
||||
|
||||
Relevant links to check before filing a feature request to see if your request has already been made or
|
||||
if there's another way to achieve what you want:
|
||||
|
||||
* [Documentation](https://docs.langchain.com/oss/python/langchain/overview),
|
||||
* [API Reference Documentation](https://reference.langchain.com/python/),
|
||||
* [LangChain ChatBot](https://chat.langchain.com/)
|
||||
* [GitHub search](https://github.com/langchain-ai/langchain),
|
||||
* [LangChain Forum](https://forum.langchain.com/),
|
||||
|
||||
**Note:** Do not begin work on a PR unless explicitly assigned to this issue by a maintainer.
|
||||
- type: checkboxes
|
||||
id: checks
|
||||
attributes:
|
||||
label: Submission checklist
|
||||
description: Please confirm and check all the following options.
|
||||
options:
|
||||
- label: This is a feature request, not a bug report or usage question.
|
||||
required: true
|
||||
- label: I added a clear and descriptive title that summarizes the feature request.
|
||||
required: true
|
||||
- label: I used the GitHub search to find a similar feature request and didn't find it.
|
||||
required: true
|
||||
- label: I checked the LangChain documentation and API reference to see if this feature already exists.
|
||||
required: true
|
||||
- label: This is not related to the langchain-community package.
|
||||
required: true
|
||||
- type: checkboxes
|
||||
id: package
|
||||
attributes:
|
||||
label: Package (Required)
|
||||
description: |
|
||||
Which `langchain` package(s) is this request related to? Select at least one.
|
||||
|
||||
Note that if the package you are requesting for is not listed here, it is not in this repository (e.g. `langchain-google-genai` is in `langchain-ai/langchain`).
|
||||
|
||||
Please submit feature requests for other packages to their respective repositories.
|
||||
options:
|
||||
- label: langchain
|
||||
- label: langchain-openai
|
||||
- label: langchain-anthropic
|
||||
- label: langchain-classic
|
||||
- label: langchain-core
|
||||
- label: langchain-model-profiles
|
||||
- label: langchain-tests
|
||||
- label: langchain-text-splitters
|
||||
- label: langchain-chroma
|
||||
- label: langchain-deepseek
|
||||
- label: langchain-exa
|
||||
- label: langchain-fireworks
|
||||
- label: langchain-groq
|
||||
- label: langchain-huggingface
|
||||
- label: langchain-mistralai
|
||||
- label: langchain-nomic
|
||||
- label: langchain-ollama
|
||||
- label: langchain-openrouter
|
||||
- label: langchain-perplexity
|
||||
- label: langchain-qdrant
|
||||
- label: langchain-xai
|
||||
- label: Other / not sure / general
|
||||
- type: textarea
|
||||
id: feature-description
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: |
|
||||
Please provide a clear and concise description of the feature you would like to see added to LangChain.
|
||||
|
||||
What specific functionality are you requesting? Be as detailed as possible.
|
||||
placeholder: |
|
||||
I would like LangChain to support...
|
||||
|
||||
This feature would allow users to...
|
||||
- type: textarea
|
||||
id: use-case
|
||||
validations:
|
||||
required: true
|
||||
attributes:
|
||||
label: Use Case
|
||||
description: |
|
||||
Describe the specific use case or problem this feature would solve.
|
||||
|
||||
Why do you need this feature? What problem does it solve for you or other users?
|
||||
placeholder: |
|
||||
I'm trying to build an application that...
|
||||
|
||||
Currently, I have to work around this by...
|
||||
|
||||
This feature would help me/users to...
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Proposed Solution
|
||||
description: |
|
||||
If you have ideas about how this feature could be implemented, please describe them here.
|
||||
|
||||
This is optional but can be helpful for maintainers to understand your vision.
|
||||
placeholder: |
|
||||
I think this could be implemented by...
|
||||
|
||||
The API could look like...
|
||||
|
||||
```python
|
||||
# Example of how the feature might work
|
||||
```
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Alternatives Considered
|
||||
description: |
|
||||
Have you considered any alternative solutions or workarounds?
|
||||
|
||||
What other approaches have you tried or considered?
|
||||
placeholder: |
|
||||
I've tried using...
|
||||
|
||||
Alternative approaches I considered:
|
||||
1. ...
|
||||
2. ...
|
||||
|
||||
But these don't work because...
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
validations:
|
||||
required: false
|
||||
attributes:
|
||||
label: Additional Context
|
||||
description: |
|
||||
Add any other context, screenshots, examples, or references that would help explain your feature request.
|
||||
placeholder: |
|
||||
Related issues: #...
|
||||
|
||||
Similar features in other libraries:
|
||||
- ...
|
||||
|
||||
Additional context or examples:
|
||||
- ...
|
||||
@@ -0,0 +1,49 @@
|
||||
name: 🔒 Privileged
|
||||
description: You are a LangChain maintainer, or was asked directly by a maintainer to create an issue here. If not, check the other options.
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
If you are not a LangChain maintainer, employee, or were not asked directly by a maintainer to create an issue, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead.
|
||||
- type: checkboxes
|
||||
id: privileged
|
||||
attributes:
|
||||
label: Privileged issue
|
||||
description: Confirm that you are allowed to create an issue here.
|
||||
options:
|
||||
- label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create an issue here.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: Issue Content
|
||||
description: Add the content of the issue here.
|
||||
- type: checkboxes
|
||||
id: package
|
||||
attributes:
|
||||
label: Package (Required)
|
||||
description: |
|
||||
Please select package(s) that this issue is related to.
|
||||
options:
|
||||
- label: langchain
|
||||
- label: langchain-openai
|
||||
- label: langchain-anthropic
|
||||
- label: langchain-classic
|
||||
- label: langchain-core
|
||||
- label: langchain-model-profiles
|
||||
- label: langchain-tests
|
||||
- label: langchain-text-splitters
|
||||
- label: langchain-chroma
|
||||
- label: langchain-deepseek
|
||||
- label: langchain-exa
|
||||
- label: langchain-fireworks
|
||||
- label: langchain-groq
|
||||
- label: langchain-huggingface
|
||||
- label: langchain-mistralai
|
||||
- label: langchain-nomic
|
||||
- label: langchain-ollama
|
||||
- label: langchain-openrouter
|
||||
- label: langchain-perplexity
|
||||
- label: langchain-qdrant
|
||||
- label: langchain-xai
|
||||
- label: Other / not sure / general
|
||||
@@ -0,0 +1,120 @@
|
||||
name: "📋 Task"
|
||||
description: Create a task for project management and tracking by LangChain maintainers. If you are not a maintainer, please use other templates or the forum.
|
||||
labels: ["task"]
|
||||
type: task
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thanks for creating a task to help organize LangChain development.
|
||||
|
||||
This template is for **maintainer tasks** such as project management, development planning, refactoring, documentation updates, and other organizational work.
|
||||
|
||||
If you are not a LangChain maintainer or were not asked directly by a maintainer to create a task, then please start the conversation on the [LangChain Forum](https://forum.langchain.com/) instead or use the appropriate bug report or feature request templates on the previous page.
|
||||
- type: checkboxes
|
||||
id: maintainer
|
||||
attributes:
|
||||
label: Maintainer task
|
||||
description: Confirm that you are allowed to create a task here.
|
||||
options:
|
||||
- label: I am a LangChain maintainer, or was asked directly by a LangChain maintainer to create a task here.
|
||||
required: true
|
||||
- type: textarea
|
||||
id: task-description
|
||||
attributes:
|
||||
label: Task Description
|
||||
description: |
|
||||
Provide a clear and detailed description of the task.
|
||||
|
||||
What needs to be done? Be specific about the scope and requirements.
|
||||
placeholder: |
|
||||
This task involves...
|
||||
|
||||
The goal is to...
|
||||
|
||||
Specific requirements:
|
||||
- ...
|
||||
- ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: acceptance-criteria
|
||||
attributes:
|
||||
label: Acceptance Criteria
|
||||
description: |
|
||||
Define the criteria that must be met for this task to be considered complete.
|
||||
|
||||
What are the specific deliverables or outcomes expected?
|
||||
placeholder: |
|
||||
This task will be complete when:
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
- [ ] ...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context and Background
|
||||
description: |
|
||||
Provide any relevant context, background information, or links to related issues/PRs.
|
||||
|
||||
Why is this task needed? What problem does it solve?
|
||||
placeholder: |
|
||||
Background:
|
||||
- ...
|
||||
|
||||
Related issues/PRs:
|
||||
- #...
|
||||
|
||||
Additional context:
|
||||
- ...
|
||||
validations:
|
||||
required: false
|
||||
- type: textarea
|
||||
id: dependencies
|
||||
attributes:
|
||||
label: Dependencies
|
||||
description: |
|
||||
List any dependencies or blockers for this task.
|
||||
|
||||
Are there other tasks, issues, or external factors that need to be completed first?
|
||||
placeholder: |
|
||||
This task depends on:
|
||||
- [ ] Issue #...
|
||||
- [ ] PR #...
|
||||
- [ ] External dependency: ...
|
||||
|
||||
Blocked by:
|
||||
- ...
|
||||
validations:
|
||||
required: false
|
||||
- type: checkboxes
|
||||
id: package
|
||||
attributes:
|
||||
label: Package (Required)
|
||||
description: |
|
||||
Please select package(s) that this task is related to.
|
||||
options:
|
||||
- label: langchain
|
||||
- label: langchain-openai
|
||||
- label: langchain-anthropic
|
||||
- label: langchain-classic
|
||||
- label: langchain-core
|
||||
- label: langchain-model-profiles
|
||||
- label: langchain-tests
|
||||
- label: langchain-text-splitters
|
||||
- label: langchain-chroma
|
||||
- label: langchain-deepseek
|
||||
- label: langchain-exa
|
||||
- label: langchain-fireworks
|
||||
- label: langchain-groq
|
||||
- label: langchain-huggingface
|
||||
- label: langchain-mistralai
|
||||
- label: langchain-nomic
|
||||
- label: langchain-ollama
|
||||
- label: langchain-openrouter
|
||||
- label: langchain-perplexity
|
||||
- label: langchain-qdrant
|
||||
- label: langchain-xai
|
||||
- label: Other / not sure / general
|
||||
@@ -0,0 +1,48 @@
|
||||
Fixes #
|
||||
|
||||
---
|
||||
|
||||
<!-- Keep the `Fixes #xx` keyword at the very top and update the issue number — this auto-closes the issue on merge. Replace this comment with a 1-2 sentence description of your change. No `# Summary` header; the description is the summary. -->
|
||||
|
||||
Read the full contributing guidelines: https://docs.langchain.com/oss/python/contributing/overview
|
||||
|
||||
> **All contributions must be in English.** See the [language policy](https://docs.langchain.com/oss/python/contributing/overview#language-policy).
|
||||
|
||||
If you paste a large clearly AI generated description here your PR may be IGNORED or CLOSED!
|
||||
|
||||
Thank you for contributing to LangChain! Follow these steps to have your pull request considered as ready for review.
|
||||
|
||||
1. PR title: Should follow the format: TYPE(SCOPE): DESCRIPTION
|
||||
|
||||
- Examples:
|
||||
- fix(anthropic): resolve flag parsing error
|
||||
- feat(core): add multi-tenant support
|
||||
- test(openai): update API usage tests
|
||||
- Allowed TYPE and SCOPE values: https://github.com/langchain-ai/langchain/blob/master/.github/workflows/pr_lint.yml#L15-L33
|
||||
|
||||
2. PR description:
|
||||
|
||||
- Write 1-2 sentences that make the change easy to understand: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary.
|
||||
- The `Fixes #xx` line at the top is **required** for external contributions — update the issue number and keep the keyword. This links your PR to the approved issue and auto-closes it on merge.
|
||||
- If there are any breaking changes, please clearly describe them.
|
||||
- If this PR depends on another PR being merged first, please include "Depends on #PR_NUMBER" in the description.
|
||||
|
||||
## Release note
|
||||
<!-- Required for net new features or behavior-changing bugfixes. State the user-visible change in release-note-ready language. Omit this section for chores, refactors, or test-only changes. -->
|
||||
|
||||
3. Run `make format`, `make lint` and `make test` from the root of the package(s) you've modified.
|
||||
|
||||
- We will not consider a PR unless these three are passing in CI.
|
||||
|
||||
4. How did you verify your code works?
|
||||
|
||||
Additional guidelines:
|
||||
|
||||
- All external PRs must link to an issue or discussion where a solution has been approved by a maintainer, and you must be assigned to that issue. PRs without prior approval will be closed.
|
||||
- PRs should not touch more than one package unless absolutely necessary.
|
||||
- Do not update the `uv.lock` files or add dependencies to `pyproject.toml` files (even optional ones) unless you have explicit permission to do so by a maintainer.
|
||||
|
||||
## Social handles (optional)
|
||||
<!-- If you'd like a shoutout on release, add your socials below -->
|
||||
Twitter: @
|
||||
LinkedIn: https://linkedin.com/in/
|
||||
@@ -0,0 +1,39 @@
|
||||
# Helper to set up Python and uv with caching
|
||||
|
||||
name: uv-install
|
||||
description: Set up Python and uv with caching
|
||||
|
||||
inputs:
|
||||
python-version:
|
||||
description: Python version, supporting MAJOR.MINOR only
|
||||
required: true
|
||||
enable-cache:
|
||||
description: Enable caching for uv dependencies
|
||||
required: false
|
||||
default: "true"
|
||||
cache-suffix:
|
||||
description: Custom cache key suffix for cache invalidation
|
||||
required: false
|
||||
default: ""
|
||||
working-directory:
|
||||
description: Working directory for cache glob scoping
|
||||
required: false
|
||||
default: "**"
|
||||
|
||||
env:
|
||||
UV_VERSION: "0.11.26"
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Install uv and set the python version
|
||||
uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
|
||||
with:
|
||||
version: ${{ env.UV_VERSION }}
|
||||
python-version: ${{ inputs.python-version }}
|
||||
enable-cache: ${{ inputs.enable-cache }}
|
||||
cache-dependency-glob: |
|
||||
${{ inputs.working-directory }}/pyproject.toml
|
||||
${{ inputs.working-directory }}/uv.lock
|
||||
${{ inputs.working-directory }}/requirements*.txt
|
||||
cache-suffix: ${{ inputs.cache-suffix }}
|
||||
@@ -0,0 +1,119 @@
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
# and
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directories:
|
||||
- "/libs/core/"
|
||||
- "/libs/langchain/"
|
||||
- "/libs/langchain_v1/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
versioning-strategy: increase
|
||||
ignore:
|
||||
- dependency-name: "langchain-core"
|
||||
- dependency-name: "langchain"
|
||||
- dependency-name: "langchain-classic"
|
||||
- dependency-name: "langchain-text-splitters"
|
||||
- dependency-name: "langchain-tests"
|
||||
- dependency-name: "langchain-model-profiles"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directories:
|
||||
- "/libs/partners/anthropic/"
|
||||
- "/libs/partners/chroma/"
|
||||
- "/libs/partners/deepseek/"
|
||||
- "/libs/partners/exa/"
|
||||
- "/libs/partners/fireworks/"
|
||||
- "/libs/partners/groq/"
|
||||
- "/libs/partners/huggingface/"
|
||||
- "/libs/partners/mistralai/"
|
||||
- "/libs/partners/nomic/"
|
||||
- "/libs/partners/ollama/"
|
||||
- "/libs/partners/openai/"
|
||||
- "/libs/partners/openrouter/"
|
||||
- "/libs/partners/perplexity/"
|
||||
- "/libs/partners/qdrant/"
|
||||
- "/libs/partners/xai/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
versioning-strategy: increase
|
||||
ignore:
|
||||
- dependency-name: "langchain-core"
|
||||
- dependency-name: "langchain"
|
||||
- dependency-name: "langchain-classic"
|
||||
- dependency-name: "langchain-text-splitters"
|
||||
- dependency-name: "langchain-tests"
|
||||
- dependency-name: "langchain-model-profiles"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
|
||||
- package-ecosystem: "uv"
|
||||
directories:
|
||||
- "/libs/text-splitters/"
|
||||
- "/libs/standard-tests/"
|
||||
- "/libs/model-profiles/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
versioning-strategy: increase
|
||||
ignore:
|
||||
- dependency-name: "langchain-core"
|
||||
- dependency-name: "langchain"
|
||||
- dependency-name: "langchain-classic"
|
||||
- dependency-name: "langchain-text-splitters"
|
||||
- dependency-name: "langchain-tests"
|
||||
- dependency-name: "langchain-model-profiles"
|
||||
groups:
|
||||
minor-and-patch:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
major:
|
||||
patterns:
|
||||
- "*"
|
||||
update-types:
|
||||
- "major"
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="20" fill="#161F34"/>
|
||||
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
|
||||
<path d="M45.7427 45.7412L36.8098 54.6742C32.106 59.3779 32.106 67.0042 36.8098 71.708C41.5135 76.4118 49.1398 76.4118 53.8436 71.708L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg width="472" height="100" viewBox="0 0 472 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="100" height="100" rx="20" fill="#161F34"/>
|
||||
<path d="M54.2612 54.2583L63.1942 45.3253C67.8979 40.6215 67.8979 32.9952 63.1942 28.2914C58.4904 23.5877 50.8641 23.5877 46.1603 28.2914L37.2273 37.2244" stroke="#7FC8FF" stroke-width="12.0389"/>
|
||||
<path d="M45.7427 45.7411L36.8098 54.6741C32.106 59.3779 32.106 67.0042 36.8098 71.7079C41.5135 76.4117 49.1398 76.4117 53.8436 71.7079L62.7766 62.775" stroke="#7FC8FF" stroke-width="12.0389"/>
|
||||
<path d="M142.427 70.248V65.748H153.227V32.748H142.427V28.248H158.147V65.748H168.947V70.248H142.427ZM189.174 70.608C182.454 70.608 177.894 67.248 177.894 61.668C177.894 55.548 182.154 52.128 190.194 52.128H199.194V50.028C199.194 46.068 196.374 43.668 191.574 43.668C187.254 43.668 184.374 45.708 183.774 48.828H178.854C179.574 42.828 184.434 39.288 191.814 39.288C199.614 39.288 204.114 43.188 204.114 50.328V63.708C204.114 65.328 204.714 65.748 206.094 65.748H207.654V70.248H204.954C200.874 70.248 199.494 68.508 199.434 65.508C197.514 68.268 194.454 70.608 189.174 70.608ZM189.534 66.408C195.654 66.408 199.194 62.868 199.194 57.768V56.268H189.714C185.334 56.268 182.874 57.888 182.874 61.368C182.874 64.368 185.454 66.408 189.534 66.408ZM216.601 70.248V39.648H220.861L221.521 43.788C223.321 41.448 226.321 39.288 231.121 39.288C237.601 39.288 243.001 42.948 243.001 52.848V70.248H238.081V53.148C238.081 47.028 235.201 43.788 230.281 43.788C224.941 43.788 221.521 47.928 221.521 53.988V70.248H216.601ZM266.348 82.608C258.548 82.608 253.088 78.948 252.308 72.228H257.348C258.188 76.068 261.608 78.228 266.708 78.228C273.128 78.228 276.608 75.228 276.608 68.568V64.968C274.568 68.448 271.268 70.608 266.108 70.608C257.648 70.608 251.408 64.908 251.408 54.948C251.408 45.588 257.648 39.288 266.108 39.288C271.268 39.288 274.688 41.508 276.608 44.928L277.268 39.648H281.528V68.748C281.528 77.568 276.848 82.608 266.348 82.608ZM266.588 66.228C272.588 66.228 276.668 61.608 276.668 55.068C276.668 48.348 272.588 43.668 266.588 43.668C260.528 43.668 256.448 48.288 256.448 54.948C256.448 61.608 260.528 66.228 266.588 66.228ZM304.875 70.608C295.935 70.608 290.055 64.548 290.055 55.008C290.055 45.648 296.115 39.288 304.995 39.288C312.495 39.288 317.235 43.488 318.495 50.208H313.335C312.435 46.128 309.435 43.668 304.935 43.668C299.055 43.668 295.095 48.348 295.095 55.008C295.095 61.668 299.055 66.228 304.935 66.228C309.315 66.228 312.315 63.708 313.275 59.808H318.495C317.295 66.408 312.315 70.608 304.875 70.608ZM328.042 70.248V28.248H332.962V43.788C335.242 40.968 338.782 39.288 342.742 39.288C350.422 39.288 354.802 44.388 354.802 53.208V70.248H349.882V53.508C349.882 47.268 347.002 43.788 341.902 43.788C336.442 43.788 332.962 48.108 332.962 54.948V70.248H328.042ZM375.209 70.608C368.489 70.608 363.929 67.248 363.929 61.668C363.929 55.548 368.189 52.128 376.229 52.128H385.229V50.028C385.229 46.068 382.409 43.668 377.609 43.668C373.289 43.668 370.409 45.708 369.809 48.828H364.889C365.609 42.828 370.469 39.288 377.849 39.288C385.649 39.288 390.149 43.188 390.149 50.328V63.708C390.149 65.328 390.749 65.748 392.129 65.748H393.689V70.248H390.989C386.909 70.248 385.529 68.508 385.469 65.508C383.549 68.268 380.489 70.608 375.209 70.608ZM375.569 66.408C381.689 66.408 385.229 62.868 385.229 57.768V56.268H375.749C371.369 56.268 368.909 57.888 368.909 61.368C368.909 64.368 371.489 66.408 375.569 66.408ZM403.476 70.248V65.748H414.276V44.148H403.476V39.648H419.196V65.748H429.996V70.248H403.476ZM416.796 34.248C414.576 34.248 412.836 32.568 412.836 30.288C412.836 28.068 414.576 26.388 416.796 26.388C419.016 26.388 420.756 28.068 420.756 30.288C420.756 32.568 419.016 34.248 416.796 34.248ZM439.843 70.248V39.648H444.103L444.763 43.788C446.563 41.448 449.563 39.288 454.363 39.288C460.843 39.288 466.243 42.948 466.243 52.848V70.248H461.323V53.148C461.323 47.028 458.443 43.788 453.523 43.788C448.183 43.788 444.763 47.928 444.763 53.988V70.248H439.843Z" fill="#161F34"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.9 KiB |
@@ -0,0 +1,398 @@
|
||||
"""Analyze git diffs to determine which directories need to be tested.
|
||||
|
||||
Intelligently determines which LangChain packages and directories need to be tested,
|
||||
linted, or built based on the changes. Handles dependency relationships between
|
||||
packages, maps file changes to appropriate CI job configurations, and outputs JSON
|
||||
configurations for GitHub Actions.
|
||||
|
||||
- Maps changed files to affected package directories (libs/core, libs/partners/*, etc.)
|
||||
- Builds dependency graph to include dependent packages when core components change
|
||||
- Generates test matrix configurations with appropriate Python versions
|
||||
- Handles special cases for Pydantic version testing and performance benchmarks
|
||||
|
||||
Used as part of the check_diffs workflow.
|
||||
"""
|
||||
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Set
|
||||
|
||||
import tomllib
|
||||
from get_min_versions import get_min_version_from_toml
|
||||
from packaging.requirements import Requirement
|
||||
|
||||
LANGCHAIN_DIRS = [
|
||||
"libs/core",
|
||||
"libs/text-splitters",
|
||||
"libs/langchain",
|
||||
"libs/langchain_v1",
|
||||
"libs/model-profiles",
|
||||
]
|
||||
|
||||
# Packages with VCR cassette-backed integration tests.
|
||||
# These get a playback-only CI check to catch stale cassettes.
|
||||
VCR_PACKAGES = {
|
||||
"libs/partners/openai",
|
||||
}
|
||||
|
||||
# When set to True, we are ignoring core dependents
|
||||
# in order to be able to get CI to pass for each individual
|
||||
# package that depends on core
|
||||
# e.g. if you touch core, we don't then add textsplitters/etc to CI
|
||||
IGNORE_CORE_DEPENDENTS = False
|
||||
|
||||
# Ignored partners are removed from dependents but still run if directly edited
|
||||
IGNORED_PARTNERS = [
|
||||
# remove huggingface from dependents because of CI instability
|
||||
# specifically in huggingface jobs
|
||||
"huggingface",
|
||||
]
|
||||
|
||||
|
||||
def all_package_dirs() -> Set[str]:
|
||||
return {
|
||||
"/".join(path.split("/")[:-1]).lstrip("./")
|
||||
for path in glob.glob("./libs/**/pyproject.toml", recursive=True)
|
||||
if "libs/standard-tests" not in path
|
||||
}
|
||||
|
||||
|
||||
def dependents_graph() -> dict:
|
||||
"""Construct a mapping of package -> dependents
|
||||
|
||||
Done such that we can run tests on all dependents of a package when a change is made.
|
||||
"""
|
||||
dependents = defaultdict(set)
|
||||
|
||||
for path in glob.glob("./libs/**/pyproject.toml", recursive=True):
|
||||
if "template" in path:
|
||||
continue
|
||||
|
||||
# load regular and test deps from pyproject.toml
|
||||
with open(path, "rb") as f:
|
||||
pyproject = tomllib.load(f)
|
||||
|
||||
pkg_dir = "libs" + "/".join(path.split("libs")[1].split("/")[:-1])
|
||||
for dep in [
|
||||
*pyproject["project"]["dependencies"],
|
||||
*pyproject["dependency-groups"]["test"],
|
||||
]:
|
||||
requirement = Requirement(dep)
|
||||
package_name = requirement.name
|
||||
if "langchain" in dep:
|
||||
dependents[package_name].add(pkg_dir)
|
||||
continue
|
||||
|
||||
# load extended deps from extended_testing_deps.txt
|
||||
package_path = Path(path).parent
|
||||
extended_requirement_path = package_path / "extended_testing_deps.txt"
|
||||
if extended_requirement_path.exists():
|
||||
with open(extended_requirement_path, "r") as f:
|
||||
extended_deps = f.read().splitlines()
|
||||
for depline in extended_deps:
|
||||
if depline.startswith("-e "):
|
||||
# editable dependency
|
||||
assert depline.startswith("-e ../partners/"), (
|
||||
"Extended test deps should only editable install partner packages"
|
||||
)
|
||||
partner = depline.split("partners/")[1]
|
||||
dep = f"langchain-{partner}"
|
||||
else:
|
||||
dep = depline.split("==")[0]
|
||||
|
||||
if "langchain" in dep:
|
||||
dependents[dep].add(pkg_dir)
|
||||
|
||||
for k in dependents:
|
||||
for partner in IGNORED_PARTNERS:
|
||||
if f"libs/partners/{partner}" in dependents[k]:
|
||||
dependents[k].remove(f"libs/partners/{partner}")
|
||||
return dependents
|
||||
|
||||
|
||||
def add_dependents(dirs_to_eval: Set[str], dependents: dict) -> List[str]:
|
||||
updated = set()
|
||||
for dir_ in dirs_to_eval:
|
||||
# handle core manually because it has so many dependents
|
||||
if "core" in dir_:
|
||||
updated.add(dir_)
|
||||
continue
|
||||
pkg = "langchain-" + dir_.split("/")[-1]
|
||||
updated.update(dependents[pkg])
|
||||
updated.add(dir_)
|
||||
return list(updated)
|
||||
|
||||
|
||||
def _get_configs_for_single_dir(job: str, dir_: str) -> List[Dict[str, str]]:
|
||||
if job == "test-pydantic":
|
||||
return _get_pydantic_test_configs(dir_)
|
||||
|
||||
if job == "codspeed":
|
||||
# CPU simulation (<1% variance, Valgrind-based) is the default.
|
||||
# Partners with heavy SDK inits use walltime instead to keep CI fast.
|
||||
CODSPEED_WALLTIME_DIRS = {
|
||||
"libs/core",
|
||||
"libs/partners/fireworks", # ~328s under simulation
|
||||
"libs/partners/openai", # 6 benchmarks, ~6 min under simulation
|
||||
}
|
||||
mode = "walltime" if dir_ in CODSPEED_WALLTIME_DIRS else "simulation"
|
||||
return [
|
||||
{
|
||||
"working-directory": dir_,
|
||||
"python-version": "3.13",
|
||||
"codspeed-mode": mode,
|
||||
}
|
||||
]
|
||||
if dir_ == "libs/core":
|
||||
py_versions = ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
else:
|
||||
py_versions = ["3.10", "3.14"]
|
||||
|
||||
return [{"working-directory": dir_, "python-version": py_v} for py_v in py_versions]
|
||||
|
||||
|
||||
def _get_pydantic_test_configs(
|
||||
dir_: str, *, python_version: str = "3.12"
|
||||
) -> List[Dict[str, str]]:
|
||||
with open("./libs/core/uv.lock", "rb") as f:
|
||||
core_uv_lock_data = tomllib.load(f)
|
||||
for package in core_uv_lock_data["package"]:
|
||||
if package["name"] == "pydantic":
|
||||
core_max_pydantic_minor = package["version"].split(".")[1]
|
||||
break
|
||||
|
||||
with open(f"./{dir_}/uv.lock", "rb") as f:
|
||||
dir_uv_lock_data = tomllib.load(f)
|
||||
|
||||
for package in dir_uv_lock_data["package"]:
|
||||
if package["name"] == "pydantic":
|
||||
dir_max_pydantic_minor = package["version"].split(".")[1]
|
||||
break
|
||||
|
||||
core_min_pydantic_version = get_min_version_from_toml(
|
||||
"./libs/core/pyproject.toml", "release", python_version, include=["pydantic"]
|
||||
)["pydantic"]
|
||||
core_min_pydantic_minor = (
|
||||
core_min_pydantic_version.split(".")[1]
|
||||
if "." in core_min_pydantic_version
|
||||
else "0"
|
||||
)
|
||||
dir_min_pydantic_version = get_min_version_from_toml(
|
||||
f"./{dir_}/pyproject.toml", "release", python_version, include=["pydantic"]
|
||||
).get("pydantic", "0.0.0")
|
||||
dir_min_pydantic_minor = (
|
||||
dir_min_pydantic_version.split(".")[1]
|
||||
if "." in dir_min_pydantic_version
|
||||
else "0"
|
||||
)
|
||||
|
||||
max_pydantic_minor = min(
|
||||
int(dir_max_pydantic_minor),
|
||||
int(core_max_pydantic_minor),
|
||||
)
|
||||
min_pydantic_minor = max(
|
||||
int(dir_min_pydantic_minor),
|
||||
int(core_min_pydantic_minor),
|
||||
)
|
||||
|
||||
configs = [
|
||||
{
|
||||
"working-directory": dir_,
|
||||
"pydantic-version": f"2.{v}.0",
|
||||
"python-version": python_version,
|
||||
}
|
||||
for v in range(min_pydantic_minor, max_pydantic_minor + 1)
|
||||
]
|
||||
return configs
|
||||
|
||||
|
||||
def _get_configs_for_multi_dirs(
|
||||
job: str, dirs_to_run: Dict[str, Set[str]], dependents: dict
|
||||
) -> List[Dict[str, str]]:
|
||||
if job == "lint":
|
||||
dirs = add_dependents(
|
||||
dirs_to_run["lint"] | dirs_to_run["test"] | dirs_to_run["extended-test"],
|
||||
dependents,
|
||||
)
|
||||
elif job in ["test", "compile-integration-tests", "dependencies", "test-pydantic"]:
|
||||
dirs = add_dependents(
|
||||
dirs_to_run["test"] | dirs_to_run["extended-test"], dependents
|
||||
)
|
||||
elif job == "extended-tests":
|
||||
dirs = list(dirs_to_run["extended-test"])
|
||||
elif job == "codspeed":
|
||||
dirs = list(dirs_to_run["codspeed"])
|
||||
elif job == "vcr-tests":
|
||||
# Only run VCR tests for packages that have cassettes and are affected
|
||||
all_affected = set(
|
||||
add_dependents(
|
||||
dirs_to_run["test"] | dirs_to_run["extended-test"], dependents
|
||||
)
|
||||
)
|
||||
dirs = [d for d in VCR_PACKAGES if d in all_affected]
|
||||
else:
|
||||
raise ValueError(f"Unknown job: {job}")
|
||||
|
||||
return [
|
||||
config for dir_ in dirs for config in _get_configs_for_single_dir(job, dir_)
|
||||
]
|
||||
|
||||
|
||||
def _get_changed_files(args: list[str]) -> list[str]:
|
||||
"""Parse changed files from command-line arguments.
|
||||
|
||||
Args:
|
||||
args: Either a legacy list of filename arguments or a single JSON array
|
||||
produced by `Ana06/get-changed-files` with `format: json`.
|
||||
|
||||
Returns:
|
||||
List of changed files.
|
||||
|
||||
Raises:
|
||||
ValueError: If a single argument looks like JSON but is not a string array.
|
||||
"""
|
||||
if len(args) != 1:
|
||||
return args
|
||||
|
||||
value = args[0].strip()
|
||||
if not value.startswith("[") or not value.endswith("]"):
|
||||
return args
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
msg = "Expected changed files JSON to be a list of strings."
|
||||
raise ValueError(msg) from e
|
||||
|
||||
if not isinstance(parsed, list) or not all(
|
||||
isinstance(file, str) for file in parsed
|
||||
):
|
||||
msg = "Expected changed files JSON to be a list of strings."
|
||||
raise ValueError(msg)
|
||||
return parsed
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
files = _get_changed_files(sys.argv[1:])
|
||||
|
||||
dirs_to_run: Dict[str, set] = {
|
||||
"lint": set(),
|
||||
"test": set(),
|
||||
"extended-test": set(),
|
||||
"codspeed": set(),
|
||||
}
|
||||
docs_edited = False
|
||||
|
||||
if len(files) >= 300:
|
||||
# max diff length is 300 files - there are likely files missing
|
||||
dirs_to_run["lint"] = all_package_dirs()
|
||||
dirs_to_run["test"] = all_package_dirs()
|
||||
dirs_to_run["extended-test"] = set(LANGCHAIN_DIRS)
|
||||
|
||||
for file in files:
|
||||
if any(
|
||||
file.startswith(dir_)
|
||||
for dir_ in (
|
||||
".github/workflows",
|
||||
".github/tools",
|
||||
".github/actions",
|
||||
".github/scripts/check_diff.py",
|
||||
)
|
||||
):
|
||||
# Infrastructure changes (workflows, actions, CI scripts) trigger tests on
|
||||
# all core packages as a safety measure. This ensures that changes to CI/CD
|
||||
# infrastructure don't inadvertently break package testing, even if the change
|
||||
# appears unrelated (e.g., documentation build workflows). This is intentionally
|
||||
# conservative to catch unexpected side effects from workflow modifications.
|
||||
#
|
||||
# Example: A PR modifying .github/workflows/api_doc_build.yml will trigger
|
||||
# lint/test jobs for libs/core, libs/text-splitters, libs/langchain, and
|
||||
# libs/langchain_v1, even though the workflow may only affect documentation.
|
||||
dirs_to_run["extended-test"].update(LANGCHAIN_DIRS)
|
||||
|
||||
if file.startswith("libs/core"):
|
||||
dirs_to_run["codspeed"].add("libs/core")
|
||||
if file.startswith("libs/langchain_v1"):
|
||||
dirs_to_run["codspeed"].add("libs/langchain_v1")
|
||||
if any(file.startswith(dir_) for dir_ in LANGCHAIN_DIRS):
|
||||
# add that dir and all dirs after in LANGCHAIN_DIRS
|
||||
# for extended testing
|
||||
|
||||
found = False
|
||||
for dir_ in LANGCHAIN_DIRS:
|
||||
if dir_ == "libs/core" and IGNORE_CORE_DEPENDENTS:
|
||||
dirs_to_run["extended-test"].add(dir_)
|
||||
continue
|
||||
if file.startswith(dir_):
|
||||
found = True
|
||||
if found:
|
||||
dirs_to_run["extended-test"].add(dir_)
|
||||
elif file.startswith("libs/standard-tests"):
|
||||
# TODO: update to include all packages that rely on standard-tests (all partner packages)
|
||||
# Note: won't run on external repo partners
|
||||
dirs_to_run["lint"].add("libs/standard-tests")
|
||||
dirs_to_run["test"].add("libs/standard-tests")
|
||||
dirs_to_run["test"].add("libs/partners/mistralai")
|
||||
dirs_to_run["test"].add("libs/partners/openai")
|
||||
dirs_to_run["test"].add("libs/partners/anthropic")
|
||||
dirs_to_run["test"].add("libs/partners/fireworks")
|
||||
dirs_to_run["test"].add("libs/partners/groq")
|
||||
|
||||
elif file.startswith("libs/partners"):
|
||||
partner_dir = file.split("/")[2]
|
||||
if os.path.isdir(f"libs/partners/{partner_dir}") and [
|
||||
filename
|
||||
for filename in os.listdir(f"libs/partners/{partner_dir}")
|
||||
if not filename.startswith(".")
|
||||
] != ["README.md"]:
|
||||
dirs_to_run["test"].add(f"libs/partners/{partner_dir}")
|
||||
# Only add to codspeed if the partner has benchmarks and is not ignored
|
||||
if (
|
||||
partner_dir not in IGNORED_PARTNERS
|
||||
and os.path.isdir(
|
||||
f"libs/partners/{partner_dir}/tests/benchmarks"
|
||||
)
|
||||
):
|
||||
dirs_to_run["codspeed"].add(f"libs/partners/{partner_dir}")
|
||||
# Skip if the directory was deleted or is just a tombstone readme
|
||||
elif file.startswith("libs/"):
|
||||
# Check if this is a root-level file in libs/ (e.g., libs/README.md)
|
||||
file_parts = file.split("/")
|
||||
if len(file_parts) == 2:
|
||||
# Root-level file in libs/, skip it (no tests needed)
|
||||
continue
|
||||
raise ValueError(
|
||||
f"Unknown lib: {file}. check_diff.py likely needs "
|
||||
"an update for this new library!"
|
||||
)
|
||||
elif file in [
|
||||
"pyproject.toml",
|
||||
"uv.lock",
|
||||
]: # root uv files
|
||||
docs_edited = True
|
||||
|
||||
dependents = dependents_graph()
|
||||
|
||||
# we now have dirs_by_job
|
||||
# todo: clean this up
|
||||
map_job_to_configs = {
|
||||
job: _get_configs_for_multi_dirs(job, dirs_to_run, dependents)
|
||||
for job in [
|
||||
"lint",
|
||||
"test",
|
||||
"extended-tests",
|
||||
"compile-integration-tests",
|
||||
"dependencies",
|
||||
"test-pydantic",
|
||||
"codspeed",
|
||||
"vcr-tests",
|
||||
]
|
||||
}
|
||||
|
||||
for key, value in map_job_to_configs.items():
|
||||
json_output = json.dumps(value)
|
||||
print(f"{key}={json_output}")
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Check that optional extras stay in sync with required dependencies.
|
||||
|
||||
When a package appears in both [project.dependencies] and
|
||||
[project.optional-dependencies], we ensure their version constraints match.
|
||||
This prevents silent version drift (e.g. bumping a required dep but
|
||||
forgetting the corresponding extra).
|
||||
"""
|
||||
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from re import compile as re_compile
|
||||
|
||||
# Matches the package name at the start of a PEP 508 dependency string.
|
||||
# Stops at the first non-name character; downstream code is responsible for
|
||||
# stripping extras (`[...]`) and env markers (`; ...`) from the remainder.
|
||||
_NAME_RE = re_compile(r"^([A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?)")
|
||||
|
||||
|
||||
def _normalize(name: str) -> str:
|
||||
"""Normalize a package name for equality comparison.
|
||||
|
||||
Lowercases and maps `-` and `.` to `_`. Looser than PEP 503
|
||||
(which uses `-` and collapses runs), but sufficient for matching the
|
||||
same package across two PEP 508 strings.
|
||||
|
||||
Returns:
|
||||
Lowercased, underscore-normalized package name.
|
||||
"""
|
||||
return name.lower().replace("-", "_").replace(".", "_")
|
||||
|
||||
|
||||
def _parse_dep(dep: str) -> tuple[str, str]:
|
||||
"""Return `(normalized_name, version_spec)` from a PEP 508 string.
|
||||
|
||||
Strips extras (`pkg[async]`), environment markers (`; python_version ...`),
|
||||
URL specifiers (`pkg @ git+...`), and whitespace so the returned
|
||||
`version_spec` is directly comparable between a required and optional dep.
|
||||
|
||||
Returns:
|
||||
Tuple of normalized package name and bare version specifier.
|
||||
|
||||
Raises:
|
||||
ValueError: If the dependency string cannot be parsed.
|
||||
"""
|
||||
match = _NAME_RE.match(dep)
|
||||
if not match:
|
||||
msg = f"Cannot parse dependency: {dep!r}"
|
||||
raise ValueError(msg)
|
||||
name = match.group(1)
|
||||
rest = dep[match.end() :].strip()
|
||||
|
||||
if rest.startswith("["):
|
||||
close = rest.find("]")
|
||||
if close == -1:
|
||||
msg = f"Unclosed extras bracket in dependency: {dep!r}"
|
||||
raise ValueError(msg)
|
||||
rest = rest[close + 1 :].strip()
|
||||
|
||||
if ";" in rest:
|
||||
rest = rest.split(";", 1)[0].strip()
|
||||
|
||||
# URL specifiers have no comparable version; treat as unconstrained.
|
||||
if rest.startswith("@"):
|
||||
rest = ""
|
||||
|
||||
rest = " ".join(rest.split())
|
||||
return _normalize(name), rest
|
||||
|
||||
|
||||
def main(pyproject_path: Path) -> int:
|
||||
"""Check extras sync and return `0` on pass, `1` on mismatch or parse error."""
|
||||
with pyproject_path.open("rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
required: dict[str, str] = {}
|
||||
for dep in data.get("project", {}).get("dependencies", []):
|
||||
try:
|
||||
name, spec = _parse_dep(dep)
|
||||
except ValueError as e:
|
||||
print(f"::error file={pyproject_path}::{e}")
|
||||
return 1
|
||||
required[name] = spec
|
||||
|
||||
optional = data.get("project", {}).get("optional-dependencies", {})
|
||||
if not optional:
|
||||
return 0
|
||||
|
||||
mismatches: list[str] = []
|
||||
for group, deps in optional.items():
|
||||
for dep in deps:
|
||||
try:
|
||||
name, spec = _parse_dep(dep)
|
||||
except ValueError as e:
|
||||
print(f"::error file={pyproject_path}::{e}")
|
||||
return 1
|
||||
if name in required and spec != required[name]:
|
||||
mismatches.append(
|
||||
f" [{group}] {name}: extra has '{spec}' "
|
||||
f"but required dep has '{required[name]}'"
|
||||
)
|
||||
|
||||
if mismatches:
|
||||
print(f"Extra / required dependency version mismatch in {pyproject_path}:")
|
||||
print("\n".join(mismatches))
|
||||
print(
|
||||
"\nUpdate the optional extras in [project.optional-dependencies] "
|
||||
"to match [project.dependencies]."
|
||||
)
|
||||
return 1
|
||||
|
||||
print(f"All extras in {pyproject_path} are in sync with required dependencies.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
path = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("pyproject.toml")
|
||||
raise SystemExit(main(path))
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Check that no dependencies allow prereleases unless we're releasing a prerelease."""
|
||||
|
||||
import sys
|
||||
|
||||
import tomllib
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get the TOML file path from the command line argument
|
||||
toml_file = sys.argv[1]
|
||||
|
||||
with open(toml_file, "rb") as file:
|
||||
toml_data = tomllib.load(file)
|
||||
|
||||
# See if we're releasing an rc or dev version
|
||||
version = toml_data["project"]["version"]
|
||||
releasing_rc = "rc" in version or "dev" in version
|
||||
|
||||
# If not, iterate through dependencies and make sure none allow prereleases
|
||||
if not releasing_rc:
|
||||
dependencies = toml_data["project"]["dependencies"]
|
||||
for dep_version in dependencies:
|
||||
dep_version_string = (
|
||||
dep_version["version"] if isinstance(dep_version, dict) else dep_version
|
||||
)
|
||||
|
||||
if "rc" in dep_version_string:
|
||||
raise ValueError(
|
||||
f"Dependency {dep_version} has a prerelease version. Please remove this."
|
||||
)
|
||||
|
||||
if isinstance(dep_version, dict) and dep_version.get(
|
||||
"allow-prereleases", False
|
||||
):
|
||||
raise ValueError(
|
||||
f"Dependency {dep_version} has allow-prereleases set to true. Please remove this."
|
||||
)
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Get minimum versions of dependencies from a pyproject.toml file."""
|
||||
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
if sys.version_info >= (3, 11):
|
||||
import tomllib
|
||||
else:
|
||||
# For Python 3.10 and below, which doesnt have stdlib tomllib
|
||||
import tomli as tomllib
|
||||
|
||||
import re
|
||||
from typing import List
|
||||
|
||||
import requests
|
||||
from packaging.requirements import Requirement
|
||||
from packaging.specifiers import SpecifierSet
|
||||
from packaging.version import Version, parse
|
||||
|
||||
MIN_VERSION_LIBS = [
|
||||
"langchain-core",
|
||||
"langchain",
|
||||
"langchain-text-splitters",
|
||||
"numpy",
|
||||
"SQLAlchemy",
|
||||
]
|
||||
|
||||
# some libs only get checked on release because of simultaneous changes in
|
||||
# multiple libs
|
||||
SKIP_IF_PULL_REQUEST = [
|
||||
"langchain-core",
|
||||
"langchain-text-splitters",
|
||||
"langchain",
|
||||
]
|
||||
|
||||
|
||||
def get_pypi_versions(package_name: str) -> List[str]:
|
||||
"""Fetch all available versions for a package from PyPI.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
|
||||
Returns:
|
||||
List of all available versions
|
||||
|
||||
Raises:
|
||||
requests.exceptions.RequestException: If PyPI API request fails
|
||||
KeyError: If package not found or response format unexpected
|
||||
"""
|
||||
pypi_url = f"https://pypi.org/pypi/{package_name}/json"
|
||||
response = requests.get(pypi_url, timeout=10.0)
|
||||
response.raise_for_status()
|
||||
return list(response.json()["releases"].keys())
|
||||
|
||||
|
||||
def get_minimum_version(package_name: str, spec_string: str) -> str | None:
|
||||
"""Find the minimum published version that satisfies the given constraints.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
spec_string: Version specification string (e.g., ">=0.2.43,<0.4.0,!=0.3.0")
|
||||
|
||||
Returns:
|
||||
Minimum compatible version or None if no compatible version found
|
||||
"""
|
||||
# Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string)
|
||||
spec_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", spec_string)
|
||||
# Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1 (can be anywhere in constraint string)
|
||||
for y in range(1, 10):
|
||||
spec_string = re.sub(
|
||||
rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}", spec_string
|
||||
)
|
||||
# Rewrite occurrences of ^x.y.z to >=x.y.z,<x+1.0.0 (can be anywhere in constraint string)
|
||||
for x in range(1, 10):
|
||||
spec_string = re.sub(
|
||||
rf"\^{x}\.(\d+)\.(\d+)", rf">={x}.\1.\2,<{x + 1}", spec_string
|
||||
)
|
||||
|
||||
spec_set = SpecifierSet(spec_string)
|
||||
all_versions = get_pypi_versions(package_name)
|
||||
|
||||
valid_versions = []
|
||||
for version_str in all_versions:
|
||||
try:
|
||||
version = parse(version_str)
|
||||
if spec_set.contains(version):
|
||||
valid_versions.append(version)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
return str(min(valid_versions)) if valid_versions else None
|
||||
|
||||
|
||||
def _check_python_version_from_requirement(
|
||||
requirement: Requirement, python_version: str
|
||||
) -> bool:
|
||||
if not requirement.marker:
|
||||
return True
|
||||
else:
|
||||
marker_str = str(requirement.marker)
|
||||
if "python_version" in marker_str or "python_full_version" in marker_str:
|
||||
python_version_str = "".join(
|
||||
char
|
||||
for char in marker_str
|
||||
if char.isdigit() or char in (".", "<", ">", "=", ",")
|
||||
)
|
||||
return check_python_version(python_version, python_version_str)
|
||||
return True
|
||||
|
||||
|
||||
def get_min_version_from_toml(
|
||||
toml_path: str,
|
||||
versions_for: str,
|
||||
python_version: str,
|
||||
*,
|
||||
include: list | None = None,
|
||||
):
|
||||
# Parse the TOML file
|
||||
with open(toml_path, "rb") as file:
|
||||
toml_data = tomllib.load(file)
|
||||
|
||||
dependencies = defaultdict(list)
|
||||
for dep in toml_data["project"]["dependencies"]:
|
||||
requirement = Requirement(dep)
|
||||
dependencies[requirement.name].append(requirement)
|
||||
|
||||
# Initialize a dictionary to store the minimum versions
|
||||
min_versions = {}
|
||||
|
||||
# Iterate over the libs in MIN_VERSION_LIBS
|
||||
for lib in set(MIN_VERSION_LIBS + (include or [])):
|
||||
if versions_for == "pull_request" and lib in SKIP_IF_PULL_REQUEST:
|
||||
# some libs only get checked on release because of simultaneous
|
||||
# changes in multiple libs
|
||||
continue
|
||||
# Check if the lib is present in the dependencies
|
||||
if lib in dependencies:
|
||||
if include and lib not in include:
|
||||
continue
|
||||
requirements = dependencies[lib]
|
||||
for requirement in requirements:
|
||||
if _check_python_version_from_requirement(requirement, python_version):
|
||||
version_string = str(requirement.specifier)
|
||||
break
|
||||
|
||||
# Use parse_version to get the minimum supported version from version_string
|
||||
min_version = get_minimum_version(lib, version_string)
|
||||
|
||||
# Store the minimum version in the min_versions dictionary
|
||||
min_versions[lib] = min_version
|
||||
|
||||
return min_versions
|
||||
|
||||
|
||||
def check_python_version(version_string, constraint_string):
|
||||
"""Check if the given Python version matches the given constraints.
|
||||
|
||||
Args:
|
||||
version_string: A string representing the Python version (e.g. "3.8.5").
|
||||
constraint_string: A string representing the package's Python version
|
||||
constraints (e.g. ">=3.6, <4.0").
|
||||
|
||||
Returns:
|
||||
True if the version matches the constraints
|
||||
"""
|
||||
|
||||
# Rewrite occurrences of ^0.0.z to 0.0.z (can be anywhere in constraint string)
|
||||
constraint_string = re.sub(r"\^0\.0\.(\d+)", r"0.0.\1", constraint_string)
|
||||
# Rewrite occurrences of ^0.y.z to >=0.y.z,<0.y+1.0 (can be anywhere in constraint string)
|
||||
for y in range(1, 10):
|
||||
constraint_string = re.sub(
|
||||
rf"\^0\.{y}\.(\d+)", rf">=0.{y}.\1,<0.{y + 1}.0", constraint_string
|
||||
)
|
||||
# Rewrite occurrences of ^x.y.z to >=x.y.z,<x+1.0.0 (can be anywhere in constraint string)
|
||||
for x in range(1, 10):
|
||||
constraint_string = re.sub(
|
||||
rf"\^{x}\.0\.(\d+)", rf">={x}.0.\1,<{x + 1}.0.0", constraint_string
|
||||
)
|
||||
|
||||
try:
|
||||
version = Version(version_string)
|
||||
constraints = SpecifierSet(constraint_string)
|
||||
return version in constraints
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Get the TOML file path from the command line argument
|
||||
toml_file = sys.argv[1]
|
||||
versions_for = sys.argv[2]
|
||||
python_version = sys.argv[3]
|
||||
assert versions_for in ["release", "pull_request"]
|
||||
|
||||
# Call the function to get the minimum versions
|
||||
min_versions = get_min_version_from_toml(toml_file, versions_for, python_version)
|
||||
|
||||
# A `None` value means no *published* version on PyPI satisfies the declared
|
||||
# constraint, e.g. a `release(...)` PR bumped a minimum pin to a version that
|
||||
# has not shipped yet. Emitting `pkg==None` would be passed verbatim to
|
||||
# `uv pip install` in the release workflow's minimum-version test step,
|
||||
# producing a cryptic install failure, so fail loudly here instead.
|
||||
unresolved = [lib for lib, version in min_versions.items() if version is None]
|
||||
if unresolved:
|
||||
print(
|
||||
"ERROR: no published version on PyPI satisfies the declared constraint "
|
||||
f"for: {', '.join(sorted(unresolved))}. A release likely pinned a "
|
||||
"dependency to a version that is not yet published. Release the "
|
||||
"dependency first, or relax the pin.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
print(" ".join([f"{lib}=={version}" for lib, version in min_versions.items()]))
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"trustedThreshold": 5,
|
||||
"labelColor": "b76e79",
|
||||
"sizeThresholds": [
|
||||
{ "label": "size: XS", "max": 50 },
|
||||
{ "label": "size: S", "max": 200 },
|
||||
{ "label": "size: M", "max": 500 },
|
||||
{ "label": "size: L", "max": 1000 },
|
||||
{ "label": "size: XL" }
|
||||
],
|
||||
"excludedFiles": ["uv.lock"],
|
||||
"excludedPaths": ["docs/"],
|
||||
"typeToLabel": {
|
||||
"feat": "feature",
|
||||
"fix": "fix",
|
||||
"docs": "documentation",
|
||||
"style": "linting",
|
||||
"refactor": "refactor",
|
||||
"perf": "performance",
|
||||
"test": "tests",
|
||||
"build": "infra",
|
||||
"ci": "infra",
|
||||
"chore": "infra",
|
||||
"revert": "revert",
|
||||
"release": "release",
|
||||
"hotfix": "hotfix",
|
||||
"breaking": "breaking"
|
||||
},
|
||||
"scopeToLabel": {
|
||||
"core": "core",
|
||||
"langchain": "langchain",
|
||||
"langchain-classic": "langchain-classic",
|
||||
"model-profiles": "model-profiles",
|
||||
"standard-tests": "standard-tests",
|
||||
"text-splitters": "text-splitters",
|
||||
"anthropic": "anthropic",
|
||||
"chroma": "chroma",
|
||||
"deepseek": "deepseek",
|
||||
"exa": "exa",
|
||||
"fireworks": "fireworks",
|
||||
"groq": "groq",
|
||||
"huggingface": "huggingface",
|
||||
"mistralai": "mistralai",
|
||||
"nomic": "nomic",
|
||||
"ollama": "ollama",
|
||||
"openai": "openai",
|
||||
"openrouter": "openrouter",
|
||||
"perplexity": "perplexity",
|
||||
"qdrant": "qdrant",
|
||||
"xai": "xai",
|
||||
"deps": "dependencies",
|
||||
"docs": "documentation",
|
||||
"infra": "infra"
|
||||
},
|
||||
"fileRules": [
|
||||
{ "label": "core", "prefix": "libs/core/", "skipExcludedFiles": true },
|
||||
{ "label": "langchain-classic", "prefix": "libs/langchain/", "skipExcludedFiles": true },
|
||||
{ "label": "langchain", "prefix": "libs/langchain_v1/", "skipExcludedFiles": true },
|
||||
{ "label": "standard-tests", "prefix": "libs/standard-tests/", "skipExcludedFiles": true },
|
||||
{ "label": "model-profiles", "prefix": "libs/model-profiles/", "skipExcludedFiles": true },
|
||||
{ "label": "text-splitters", "prefix": "libs/text-splitters/", "skipExcludedFiles": true },
|
||||
{ "label": "integration", "prefix": "libs/partners/", "skipExcludedFiles": true },
|
||||
{ "label": "anthropic", "prefix": "libs/partners/anthropic/", "skipExcludedFiles": true },
|
||||
{ "label": "chroma", "prefix": "libs/partners/chroma/", "skipExcludedFiles": true },
|
||||
{ "label": "deepseek", "prefix": "libs/partners/deepseek/", "skipExcludedFiles": true },
|
||||
{ "label": "exa", "prefix": "libs/partners/exa/", "skipExcludedFiles": true },
|
||||
{ "label": "fireworks", "prefix": "libs/partners/fireworks/", "skipExcludedFiles": true },
|
||||
{ "label": "groq", "prefix": "libs/partners/groq/", "skipExcludedFiles": true },
|
||||
{ "label": "huggingface", "prefix": "libs/partners/huggingface/", "skipExcludedFiles": true },
|
||||
{ "label": "mistralai", "prefix": "libs/partners/mistralai/", "skipExcludedFiles": true },
|
||||
{ "label": "nomic", "prefix": "libs/partners/nomic/", "skipExcludedFiles": true },
|
||||
{ "label": "ollama", "prefix": "libs/partners/ollama/", "skipExcludedFiles": true },
|
||||
{ "label": "openai", "prefix": "libs/partners/openai/", "skipExcludedFiles": true },
|
||||
{ "label": "openrouter", "prefix": "libs/partners/openrouter/", "skipExcludedFiles": true },
|
||||
{ "label": "perplexity", "prefix": "libs/partners/perplexity/", "skipExcludedFiles": true },
|
||||
{ "label": "qdrant", "prefix": "libs/partners/qdrant/", "skipExcludedFiles": true },
|
||||
{ "label": "xai", "prefix": "libs/partners/xai/", "skipExcludedFiles": true },
|
||||
{ "label": "github_actions", "prefix": ".github/workflows/" },
|
||||
{ "label": "github_actions", "prefix": ".github/actions/" },
|
||||
{ "label": "dependencies", "suffix": "pyproject.toml" },
|
||||
{ "label": "dependencies", "exact": "uv.lock" },
|
||||
{ "label": "dependencies", "pattern": "(?:^|/)requirements[^/]*\\.txt$" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
// Shared helpers for pr_labeler.yml and tag-external-issues.yml.
|
||||
//
|
||||
// Usage from actions/github-script (requires actions/checkout first):
|
||||
// const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = path.join(__dirname, 'pr-labeler-config.json');
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to read ${configPath}: ${e.message}`);
|
||||
}
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(raw);
|
||||
} catch (e) {
|
||||
throw new Error(`Failed to parse pr-labeler-config.json: ${e.message}`);
|
||||
}
|
||||
const required = [
|
||||
'labelColor', 'sizeThresholds', 'fileRules',
|
||||
'typeToLabel', 'scopeToLabel', 'trustedThreshold',
|
||||
'excludedFiles', 'excludedPaths',
|
||||
];
|
||||
const missing = required.filter(k => !(k in config));
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`pr-labeler-config.json missing required keys: ${missing.join(', ')}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function init(github, owner, repo, config, core) {
|
||||
if (!core) {
|
||||
throw new Error('init() requires a `core` parameter (e.g., from actions/github-script)');
|
||||
}
|
||||
const {
|
||||
trustedThreshold,
|
||||
labelColor,
|
||||
sizeThresholds,
|
||||
scopeToLabel,
|
||||
typeToLabel,
|
||||
fileRules: fileRulesDef,
|
||||
excludedFiles,
|
||||
excludedPaths,
|
||||
} = config;
|
||||
|
||||
const sizeLabels = sizeThresholds.map(t => t.label);
|
||||
const allTypeLabels = [...new Set(Object.values(typeToLabel))];
|
||||
const tierLabels = ['new-contributor', 'trusted-contributor'];
|
||||
|
||||
// ── Label management ──────────────────────────────────────────────
|
||||
|
||||
async function ensureLabel(name, color = labelColor) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color });
|
||||
} catch (createErr) {
|
||||
// 422 = label created by a concurrent run between our get and create
|
||||
if (createErr.status !== 422) throw createErr;
|
||||
core.info(`Label "${name}" creation returned 422 (likely already exists)`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Size calculation ──────────────────────────────────────────────
|
||||
|
||||
function getSizeLabel(totalChanged) {
|
||||
for (const t of sizeThresholds) {
|
||||
if (t.max != null && totalChanged < t.max) return t.label;
|
||||
}
|
||||
// Last entry has no max — it's the catch-all
|
||||
return sizeThresholds[sizeThresholds.length - 1].label;
|
||||
}
|
||||
|
||||
function computeSize(files) {
|
||||
const excluded = new Set(excludedFiles);
|
||||
const totalChanged = files.reduce((sum, f) => {
|
||||
const p = f.filename ?? '';
|
||||
const base = p.split('/').pop();
|
||||
if (excluded.has(base)) return sum;
|
||||
for (const prefix of excludedPaths) {
|
||||
if (p.startsWith(prefix)) return sum;
|
||||
}
|
||||
return sum + (f.additions ?? 0) + (f.deletions ?? 0);
|
||||
}, 0);
|
||||
return { totalChanged, sizeLabel: getSizeLabel(totalChanged) };
|
||||
}
|
||||
|
||||
// ── File-based labels ─────────────────────────────────────────────
|
||||
|
||||
function buildFileRules() {
|
||||
return fileRulesDef.map((rule, i) => {
|
||||
let test;
|
||||
if (rule.prefix) test = p => p.startsWith(rule.prefix);
|
||||
else if (rule.suffix) test = p => p.endsWith(rule.suffix);
|
||||
else if (rule.exact) test = p => p === rule.exact;
|
||||
else if (rule.pattern) {
|
||||
const re = new RegExp(rule.pattern);
|
||||
test = p => re.test(p);
|
||||
} else {
|
||||
throw new Error(
|
||||
`fileRules[${i}] (label: "${rule.label}") has no recognized matcher ` +
|
||||
`(expected one of: prefix, suffix, exact, pattern)`
|
||||
);
|
||||
}
|
||||
return { label: rule.label, test, skipExcluded: !!rule.skipExcludedFiles };
|
||||
});
|
||||
}
|
||||
|
||||
function matchFileLabels(files, fileRules) {
|
||||
const rules = fileRules || buildFileRules();
|
||||
const excluded = new Set(excludedFiles);
|
||||
const labels = new Set();
|
||||
for (const rule of rules) {
|
||||
// skipExcluded: ignore files whose basename is in the top-level
|
||||
// "excludedFiles" list (e.g. uv.lock) so lockfile-only changes
|
||||
// don't trigger package labels.
|
||||
const candidates = rule.skipExcluded
|
||||
? files.filter(f => !excluded.has((f.filename ?? '').split('/').pop()))
|
||||
: files;
|
||||
if (candidates.some(f => rule.test(f.filename ?? ''))) {
|
||||
labels.add(rule.label);
|
||||
}
|
||||
}
|
||||
return labels;
|
||||
}
|
||||
|
||||
// ── Title-based labels ────────────────────────────────────────────
|
||||
|
||||
function matchTitleLabels(title) {
|
||||
const labels = new Set();
|
||||
const m = (title ?? '').match(/^(\w+)(?:\(([^)]+)\))?(!)?:/);
|
||||
if (!m) return { labels, type: null, typeLabel: null, scopes: [], breaking: false };
|
||||
|
||||
const type = m[1].toLowerCase();
|
||||
const scopeStr = m[2] ?? '';
|
||||
const breaking = !!m[3];
|
||||
|
||||
const typeLabel = typeToLabel[type] || null;
|
||||
if (typeLabel) labels.add(typeLabel);
|
||||
if (breaking) labels.add('breaking');
|
||||
|
||||
const scopes = scopeStr.split(',').map(s => s.trim()).filter(Boolean);
|
||||
for (const scope of scopes) {
|
||||
const sl = scopeToLabel[scope];
|
||||
if (sl) labels.add(sl);
|
||||
}
|
||||
|
||||
return { labels, type, typeLabel, scopes, breaking };
|
||||
}
|
||||
|
||||
// ── Org membership ────────────────────────────────────────────────
|
||||
|
||||
async function checkMembership(author, userType) {
|
||||
if (userType === 'Bot') {
|
||||
console.log(`${author} is a Bot — treating as internal`);
|
||||
return { isExternal: false };
|
||||
}
|
||||
|
||||
try {
|
||||
const membership = await github.rest.orgs.getMembershipForUser({
|
||||
org: 'langchain-ai',
|
||||
username: author,
|
||||
});
|
||||
const isExternal = membership.data.state !== 'active';
|
||||
console.log(
|
||||
isExternal
|
||||
? `${author} has pending membership — treating as external`
|
||||
: `${author} is an active member of langchain-ai`,
|
||||
);
|
||||
return { isExternal };
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
console.log(`${author} is not a member of langchain-ai`);
|
||||
return { isExternal: true };
|
||||
}
|
||||
// Non-404 errors (rate limit, auth failure, server error) must not
|
||||
// silently default to external — rethrow to fail the step.
|
||||
throw new Error(
|
||||
`Membership check failed for ${author} (${e.status}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contributor analysis ──────────────────────────────────────────
|
||||
|
||||
async function getContributorInfo(contributorCache, author, userType) {
|
||||
if (contributorCache.has(author)) return contributorCache.get(author);
|
||||
|
||||
const { isExternal } = await checkMembership(author, userType);
|
||||
|
||||
let mergedCount = null;
|
||||
if (isExternal) {
|
||||
try {
|
||||
const result = await github.rest.search.issuesAndPullRequests({
|
||||
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = result?.data?.total_count ?? null;
|
||||
} catch (e) {
|
||||
if (e?.status !== 422) throw e;
|
||||
core.warning(`Search failed for ${author}; skipping tier.`);
|
||||
}
|
||||
}
|
||||
|
||||
const info = { isExternal, mergedCount };
|
||||
contributorCache.set(author, info);
|
||||
return info;
|
||||
}
|
||||
|
||||
// ── Tier label resolution ───────────────────────────────────────────
|
||||
|
||||
async function applyTierLabel(issueNumber, author, { skipNewContributor = false } = {}) {
|
||||
let mergedCount;
|
||||
try {
|
||||
const result = await github.rest.search.issuesAndPullRequests({
|
||||
q: `repo:${owner}/${repo} is:pr is:merged author:"${author}"`,
|
||||
per_page: 1,
|
||||
});
|
||||
mergedCount = result?.data?.total_count;
|
||||
} catch (error) {
|
||||
if (error?.status !== 422) throw error;
|
||||
core.warning(`Search failed for ${author}; skipping tier label.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (mergedCount == null) {
|
||||
core.warning(`Search response missing total_count for ${author}; skipping tier label.`);
|
||||
return;
|
||||
}
|
||||
|
||||
let tierLabel = null;
|
||||
if (mergedCount >= trustedThreshold) tierLabel = 'trusted-contributor';
|
||||
else if (mergedCount === 0 && !skipNewContributor) tierLabel = 'new-contributor';
|
||||
|
||||
if (tierLabel) {
|
||||
await ensureLabel(tierLabel);
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: issueNumber, labels: [tierLabel],
|
||||
});
|
||||
console.log(`Applied '${tierLabel}' to #${issueNumber} (${mergedCount} merged PRs)`);
|
||||
} else {
|
||||
console.log(`No tier label for ${author} (${mergedCount} merged PRs)`);
|
||||
}
|
||||
|
||||
return tierLabel;
|
||||
}
|
||||
|
||||
return {
|
||||
ensureLabel,
|
||||
getSizeLabel,
|
||||
computeSize,
|
||||
buildFileRules,
|
||||
matchFileLabels,
|
||||
matchTitleLabels,
|
||||
allTypeLabels,
|
||||
checkMembership,
|
||||
getContributorInfo,
|
||||
applyTierLabel,
|
||||
sizeLabels,
|
||||
tierLabels,
|
||||
trustedThreshold,
|
||||
labelColor,
|
||||
};
|
||||
}
|
||||
|
||||
function loadAndInit(github, owner, repo, core) {
|
||||
const config = loadConfig();
|
||||
return { config, h: init(github, owner, repo, config, core) };
|
||||
}
|
||||
|
||||
module.exports = { loadConfig, init, loadAndInit };
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Verify _release.yml dropdown options match actual package directories.
|
||||
|
||||
Dropdown options are short names (e.g. `openai`, `core`). The workflow's
|
||||
`EFFECTIVE_WORKING_DIR` expression re-adds the `libs/` prefix for top-level
|
||||
packages and `libs/partners/` for everything else. This test reconstructs the
|
||||
full path for each short name and compares against packages on disk.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
# Keep in sync with the non-partner allowlist in `EFFECTIVE_WORKING_DIR`
|
||||
# in `.github/workflows/_release.yml`.
|
||||
TOP_LEVEL_PACKAGES = frozenset(
|
||||
{"core", "langchain", "langchain_v1", "text-splitters", "standard-tests", "model-profiles"}
|
||||
)
|
||||
|
||||
|
||||
def _get_release_options() -> list[str]:
|
||||
workflow = REPO_ROOT / ".github" / "workflows" / "_release.yml"
|
||||
with open(workflow) as f:
|
||||
data = yaml.safe_load(f)
|
||||
try:
|
||||
# PyYAML (YAML 1.1) parses the bare key `on` as boolean True
|
||||
return data[True]["workflow_dispatch"]["inputs"]["working-directory"]["options"]
|
||||
except (KeyError, TypeError) as e:
|
||||
msg = f"Could not find workflow_dispatch options in {workflow}: {e}"
|
||||
raise AssertionError(msg) from e
|
||||
|
||||
|
||||
def _expand_option(option: str) -> str:
|
||||
if option in TOP_LEVEL_PACKAGES:
|
||||
return f"libs/{option}"
|
||||
return f"libs/partners/{option}"
|
||||
|
||||
|
||||
def _get_package_dirs() -> set[str]:
|
||||
libs = REPO_ROOT / "libs"
|
||||
dirs: set[str] = set()
|
||||
# Top-level packages (libs/core, libs/langchain, etc.)
|
||||
for p in libs.iterdir():
|
||||
if p.is_dir() and (p / "pyproject.toml").exists():
|
||||
dirs.add(f"libs/{p.name}")
|
||||
# Partner packages (libs/partners/*)
|
||||
partners = libs / "partners"
|
||||
if partners.exists():
|
||||
for p in partners.iterdir():
|
||||
if p.is_dir() and (p / "pyproject.toml").exists():
|
||||
dirs.add(f"libs/partners/{p.name}")
|
||||
return dirs
|
||||
|
||||
|
||||
def test_release_options_match_packages() -> None:
|
||||
options = {_expand_option(o) for o in _get_release_options()}
|
||||
packages = _get_package_dirs()
|
||||
missing_from_dropdown = packages - options
|
||||
extra_in_dropdown = options - packages
|
||||
assert not missing_from_dropdown, (
|
||||
f"Packages on disk missing from _release.yml dropdown: {missing_from_dropdown}"
|
||||
)
|
||||
assert not extra_in_dropdown, (
|
||||
f"Dropdown options with no matching package directory: {extra_in_dropdown}"
|
||||
)
|
||||
Executable
+756
@@ -0,0 +1,756 @@
|
||||
#!/usr/bin/env python3
|
||||
#
|
||||
# git-restore-mtime - Change mtime of files based on commit date of last change
|
||||
#
|
||||
# Copyright (C) 2012 Rodrigo Silva (MestreLion) <linux@rodrigosilva.com>
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. See <http://www.gnu.org/licenses/gpl.html>
|
||||
#
|
||||
# Source: https://github.com/MestreLion/git-tools
|
||||
# Version: July 13, 2023 (commit hash 5f832e72453e035fccae9d63a5056918d64476a2)
|
||||
"""
|
||||
Change the modification time (mtime) of files in work tree, based on the
|
||||
date of the most recent commit that modified the file, including renames.
|
||||
|
||||
Ignores untracked files and uncommitted deletions, additions and renames, and
|
||||
by default modifications too.
|
||||
---
|
||||
Useful prior to generating release tarballs, so each file is archived with a
|
||||
date that is similar to the date when the file was actually last modified,
|
||||
assuming the actual modification date and its commit date are close.
|
||||
"""
|
||||
|
||||
# TODO:
|
||||
# - Add -z on git whatchanged/ls-files, so we don't deal with filename decoding
|
||||
# - When Python is bumped to 3.7, use text instead of universal_newlines on subprocess
|
||||
# - Update "Statistics for some large projects" with modern hardware and repositories.
|
||||
# - Create a README.md for git-restore-mtime alone. It deserves extensive documentation
|
||||
# - Move Statistics there
|
||||
# - See git-extras as a good example on project structure and documentation
|
||||
|
||||
# FIXME:
|
||||
# - When current dir is outside the worktree, e.g. using --work-tree, `git ls-files`
|
||||
# assume any relative pathspecs are to worktree root, not the current dir. As such,
|
||||
# relative pathspecs may not work.
|
||||
# - Renames are tricky:
|
||||
# - R100 should not change mtime, but original name is not on filelist. Should
|
||||
# track renames until a valid (A, M) mtime found and then set on current name.
|
||||
# - Should set mtime for both current and original directories.
|
||||
# - Check mode changes with unchanged blobs?
|
||||
# - Check file (A, D) for the directory mtime is not sufficient:
|
||||
# - Renames also change dir mtime, unless rename was on a parent dir
|
||||
# - If most recent change of all files in a dir was a Modification (M),
|
||||
# dir might not be touched at all.
|
||||
# - Dirs containing only subdirectories but no direct files will also
|
||||
# not be touched. They're files' [grand]parent dir, but never their dirname().
|
||||
# - Some solutions:
|
||||
# - After files done, perform some dir processing for missing dirs, finding latest
|
||||
# file (A, D, R)
|
||||
# - Simple approach: dir mtime is the most recent child (dir or file) mtime
|
||||
# - Use a virtual concept of "created at most at" to fill missing info, bubble up
|
||||
# to parents and grandparents
|
||||
# - When handling [grand]parent dirs, stay inside <pathspec>
|
||||
# - Better handling of merge commits. `-m` is plain *wrong*. `-c/--cc` is perfect, but
|
||||
# painfully slow. First pass without merge commits is not accurate. Maybe add a new
|
||||
# `--accurate` mode for `--cc`?
|
||||
|
||||
if __name__ != "__main__":
|
||||
raise ImportError("{} should not be used as a module.".format(__name__))
|
||||
|
||||
import argparse
|
||||
import datetime
|
||||
import logging
|
||||
import os.path
|
||||
import shlex
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
__version__ = "2022.12+dev"
|
||||
|
||||
# Update symlinks only if the platform supports not following them
|
||||
UPDATE_SYMLINKS = bool(os.utime in getattr(os, "supports_follow_symlinks", []))
|
||||
|
||||
# Call os.path.normpath() only if not in a POSIX platform (Windows)
|
||||
NORMALIZE_PATHS = os.path.sep != "/"
|
||||
|
||||
# How many files to process in each batch when re-trying merge commits
|
||||
STEPMISSING = 100
|
||||
|
||||
# (Extra) keywords for the os.utime() call performed by touch()
|
||||
UTIME_KWS = {} if not UPDATE_SYMLINKS else {"follow_symlinks": False}
|
||||
|
||||
|
||||
# Command-line interface ######################################################
|
||||
|
||||
|
||||
def parse_args():
|
||||
parser = argparse.ArgumentParser(description=__doc__.split("\n---")[0])
|
||||
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.add_argument(
|
||||
"--quiet",
|
||||
"-q",
|
||||
dest="loglevel",
|
||||
action="store_const",
|
||||
const=logging.WARNING,
|
||||
default=logging.INFO,
|
||||
help="Suppress informative messages and summary statistics.",
|
||||
)
|
||||
group.add_argument(
|
||||
"--verbose",
|
||||
"-v",
|
||||
action="count",
|
||||
help="""
|
||||
Print additional information for each processed file.
|
||||
Specify twice to further increase verbosity.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--cwd",
|
||||
"-C",
|
||||
metavar="DIRECTORY",
|
||||
help="""
|
||||
Run as if %(prog)s was started in directory %(metavar)s.
|
||||
This affects how --work-tree, --git-dir and PATHSPEC arguments are handled.
|
||||
See 'man 1 git' or 'git --help' for more information.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--git-dir",
|
||||
dest="gitdir",
|
||||
metavar="GITDIR",
|
||||
help="""
|
||||
Path to the git repository, by default auto-discovered by searching
|
||||
the current directory and its parents for a .git/ subdirectory.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--work-tree",
|
||||
dest="workdir",
|
||||
metavar="WORKTREE",
|
||||
help="""
|
||||
Path to the work tree root, by default the parent of GITDIR if it's
|
||||
automatically discovered, or the current directory if GITDIR is set.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--force",
|
||||
"-f",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Force updating files with uncommitted modifications.
|
||||
Untracked files and uncommitted deletions, renames and additions are
|
||||
always ignored.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--merge",
|
||||
"-m",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Include merge commits.
|
||||
Leads to more recent times and more files per commit, thus with the same
|
||||
time, which may or may not be what you want.
|
||||
Including merge commits may lead to fewer commits being evaluated as files
|
||||
are found sooner, which can improve performance, sometimes substantially.
|
||||
But as merge commits are usually huge, processing them may also take longer.
|
||||
By default, merge commits are only used for files missing from regular commits.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--first-parent",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Consider only the first parent, the "main branch", when evaluating merge commits.
|
||||
Only effective when merge commits are processed, either when --merge is
|
||||
used or when finding missing files after the first regular log search.
|
||||
See --skip-missing.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-missing",
|
||||
"-s",
|
||||
dest="missing",
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="""
|
||||
Do not try to find missing files.
|
||||
If merge commits were not evaluated with --merge and some files were
|
||||
not found in regular commits, by default %(prog)s searches for these
|
||||
files again in the merge commits.
|
||||
This option disables this retry, so files found only in merge commits
|
||||
will not have their timestamp updated.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--no-directories",
|
||||
"-D",
|
||||
dest="dirs",
|
||||
default=True,
|
||||
action="store_false",
|
||||
help="""
|
||||
Do not update directory timestamps.
|
||||
By default, use the time of its most recently created, renamed or deleted file.
|
||||
Note that just modifying a file will NOT update its directory time.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--test",
|
||||
"-t",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Test run: do not actually update any file timestamp.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--commit-time",
|
||||
"-c",
|
||||
dest="commit_time",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="Use commit time instead of author time.",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--oldest-time",
|
||||
"-o",
|
||||
dest="reverse_order",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Update times based on the oldest, instead of the most recent commit of a file.
|
||||
This reverses the order in which the git log is processed to emulate a
|
||||
file "creation" date. Note this will be inaccurate for files deleted and
|
||||
re-created at later dates.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-older-than",
|
||||
metavar="SECONDS",
|
||||
type=int,
|
||||
help="""
|
||||
Ignore files that are currently older than %(metavar)s.
|
||||
Useful in workflows that assume such files already have a correct timestamp,
|
||||
as it may improve performance by processing fewer files.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--skip-older-than-commit",
|
||||
"-N",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Ignore files older than the timestamp it would be updated to.
|
||||
Such files may be considered "original", likely in the author's repository.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--unique-times",
|
||||
default=False,
|
||||
action="store_true",
|
||||
help="""
|
||||
Set the microseconds to a unique value per commit.
|
||||
Allows telling apart changes that would otherwise have identical timestamps,
|
||||
as git's time accuracy is in seconds.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"pathspec",
|
||||
nargs="*",
|
||||
metavar="PATHSPEC",
|
||||
help="""
|
||||
Only modify paths matching %(metavar)s, relative to current directory.
|
||||
By default, update all but untracked files and submodules.
|
||||
""",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--version",
|
||||
"-V",
|
||||
action="version",
|
||||
version="%(prog)s version {version}".format(version=get_version()),
|
||||
)
|
||||
|
||||
args_ = parser.parse_args()
|
||||
if args_.verbose:
|
||||
args_.loglevel = max(logging.TRACE, logging.DEBUG // args_.verbose)
|
||||
args_.debug = args_.loglevel <= logging.DEBUG
|
||||
return args_
|
||||
|
||||
|
||||
def get_version(version=__version__):
|
||||
if not version.endswith("+dev"):
|
||||
return version
|
||||
try:
|
||||
cwd = os.path.dirname(os.path.realpath(__file__))
|
||||
return Git(cwd=cwd, errors=False).describe().lstrip("v")
|
||||
except Git.Error:
|
||||
return "-".join((version, "unknown"))
|
||||
|
||||
|
||||
# Helper functions ############################################################
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Add TRACE logging level and corresponding method, return the root logger"""
|
||||
logging.TRACE = TRACE = logging.DEBUG // 2
|
||||
logging.Logger.trace = lambda _, m, *a, **k: _.log(TRACE, m, *a, **k)
|
||||
return logging.getLogger()
|
||||
|
||||
|
||||
def normalize(path):
|
||||
r"""Normalize paths from git, handling non-ASCII characters.
|
||||
|
||||
Git stores paths as UTF-8 normalization form C.
|
||||
If path contains non-ASCII or non-printable characters, git outputs the UTF-8
|
||||
in octal-escaped notation, escaping double-quotes and backslashes, and then
|
||||
double-quoting the whole path.
|
||||
https://git-scm.com/docs/git-config#Documentation/git-config.txt-corequotePath
|
||||
|
||||
This function reverts this encoding, so:
|
||||
normalize(r'"Back\\slash_double\"quote_a\303\247a\303\255"') =>
|
||||
r'Back\slash_double"quote_açaí')
|
||||
|
||||
Paths with invalid UTF-8 encoding, such as single 0x80-0xFF bytes (e.g, from
|
||||
Latin1/Windows-1251 encoding) are decoded using surrogate escape, the same
|
||||
method used by Python for filesystem paths. So 0xE6 ("æ" in Latin1, r'\\346'
|
||||
from Git) is decoded as "\udce6". See https://peps.python.org/pep-0383/ and
|
||||
https://vstinner.github.io/painful-history-python-filesystem-encoding.html
|
||||
|
||||
Also see notes on `windows/non-ascii-paths.txt` about path encodings on
|
||||
non-UTF-8 platforms and filesystems.
|
||||
"""
|
||||
if path and path[0] == '"':
|
||||
# Python 2: path = path[1:-1].decode("string-escape")
|
||||
# Python 3: https://stackoverflow.com/a/46650050/624066
|
||||
path = (
|
||||
path[1:-1] # Remove enclosing double quotes
|
||||
.encode("latin1") # Convert to bytes, required by 'unicode-escape'
|
||||
.decode("unicode-escape") # Perform the actual octal-escaping decode
|
||||
.encode("latin1") # 1:1 mapping to bytes, UTF-8 encoded
|
||||
.decode("utf8", "surrogateescape")
|
||||
) # Decode from UTF-8
|
||||
if NORMALIZE_PATHS:
|
||||
# Make sure the slash matches the OS; for Windows we need a backslash
|
||||
path = os.path.normpath(path)
|
||||
return path
|
||||
|
||||
|
||||
def dummy(*_args, **_kwargs):
|
||||
"""No-op function used in dry-run tests"""
|
||||
|
||||
|
||||
def touch(path, mtime):
|
||||
"""The actual mtime update"""
|
||||
os.utime(path, (mtime, mtime), **UTIME_KWS)
|
||||
|
||||
|
||||
def touch_ns(path, mtime_ns):
|
||||
"""The actual mtime update, using nanoseconds for unique timestamps"""
|
||||
os.utime(path, None, ns=(mtime_ns, mtime_ns), **UTIME_KWS)
|
||||
|
||||
|
||||
def isodate(secs: int):
|
||||
# time.localtime() accepts floats, but discards fractional part
|
||||
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(secs))
|
||||
|
||||
|
||||
def isodate_ns(ns: int):
|
||||
# for integers fromtimestamp() is equivalent and ~16% slower than isodate()
|
||||
return datetime.datetime.fromtimestamp(ns / 1000000000).isoformat(sep=" ")
|
||||
|
||||
|
||||
def get_mtime_ns(secs: int, idx: int):
|
||||
# Time resolution for filesystems and functions:
|
||||
# ext-4 and other POSIX filesystems: 1 nanosecond
|
||||
# NTFS (Windows default): 100 nanoseconds
|
||||
# datetime.datetime() (due to 64-bit float epoch): 1 microsecond
|
||||
us = idx % 1000000 # 10**6
|
||||
return 1000 * (1000000 * secs + us)
|
||||
|
||||
|
||||
def get_mtime_path(path):
|
||||
return os.path.getmtime(path)
|
||||
|
||||
|
||||
# Git class and parse_log(), the heart of the script ##########################
|
||||
|
||||
|
||||
class Git:
|
||||
def __init__(self, workdir=None, gitdir=None, cwd=None, errors=True):
|
||||
self.gitcmd = ["git"]
|
||||
self.errors = errors
|
||||
self._proc = None
|
||||
if workdir:
|
||||
self.gitcmd.extend(("--work-tree", workdir))
|
||||
if gitdir:
|
||||
self.gitcmd.extend(("--git-dir", gitdir))
|
||||
if cwd:
|
||||
self.gitcmd.extend(("-C", cwd))
|
||||
self.workdir, self.gitdir = self._get_repo_dirs()
|
||||
|
||||
def ls_files(self, paths: list = None):
|
||||
return (normalize(_) for _ in self._run("ls-files --full-name", paths))
|
||||
|
||||
def ls_dirty(self, force=False):
|
||||
return (
|
||||
normalize(_[3:].split(" -> ", 1)[-1])
|
||||
for _ in self._run("status --porcelain")
|
||||
if _[:2] != "??" and (not force or (_[0] in ("R", "A") or _[1] == "D"))
|
||||
)
|
||||
|
||||
def log(
|
||||
self,
|
||||
merge=False,
|
||||
first_parent=False,
|
||||
commit_time=False,
|
||||
reverse_order=False,
|
||||
paths: list = None,
|
||||
):
|
||||
cmd = "whatchanged --pretty={}".format("%ct" if commit_time else "%at")
|
||||
if merge:
|
||||
cmd += " -m"
|
||||
if first_parent:
|
||||
cmd += " --first-parent"
|
||||
if reverse_order:
|
||||
cmd += " --reverse"
|
||||
return self._run(cmd, paths)
|
||||
|
||||
def describe(self):
|
||||
return self._run("describe --tags", check=True)[0]
|
||||
|
||||
def terminate(self):
|
||||
if self._proc is None:
|
||||
return
|
||||
try:
|
||||
self._proc.terminate()
|
||||
except OSError:
|
||||
# Avoid errors on OpenBSD
|
||||
pass
|
||||
|
||||
def _get_repo_dirs(self):
|
||||
return (
|
||||
os.path.normpath(_)
|
||||
for _ in self._run(
|
||||
"rev-parse --show-toplevel --absolute-git-dir", check=True
|
||||
)
|
||||
)
|
||||
|
||||
def _run(self, cmdstr: str, paths: list = None, output=True, check=False):
|
||||
cmdlist = self.gitcmd + shlex.split(cmdstr)
|
||||
if paths:
|
||||
cmdlist.append("--")
|
||||
cmdlist.extend(paths)
|
||||
popen_args = dict(universal_newlines=True, encoding="utf8")
|
||||
if not self.errors:
|
||||
popen_args["stderr"] = subprocess.DEVNULL
|
||||
log.trace("Executing: %s", " ".join(cmdlist))
|
||||
if not output:
|
||||
return subprocess.call(cmdlist, **popen_args)
|
||||
if check:
|
||||
try:
|
||||
stdout: str = subprocess.check_output(cmdlist, **popen_args)
|
||||
return stdout.splitlines()
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise self.Error(e.returncode, e.cmd, e.output, e.stderr)
|
||||
self._proc = subprocess.Popen(cmdlist, stdout=subprocess.PIPE, **popen_args)
|
||||
return (_.rstrip() for _ in self._proc.stdout)
|
||||
|
||||
def __del__(self):
|
||||
self.terminate()
|
||||
|
||||
class Error(subprocess.CalledProcessError):
|
||||
"""Error from git executable"""
|
||||
|
||||
|
||||
def parse_log(filelist, dirlist, stats, git, merge=False, filterlist=None):
|
||||
mtime = 0
|
||||
datestr = isodate(0)
|
||||
for line in git.log(
|
||||
merge, args.first_parent, args.commit_time, args.reverse_order, filterlist
|
||||
):
|
||||
stats["loglines"] += 1
|
||||
|
||||
# Blank line between Date and list of files
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Date line
|
||||
if line[0] != ":": # Faster than `not line.startswith(':')`
|
||||
stats["commits"] += 1
|
||||
mtime = int(line)
|
||||
if args.unique_times:
|
||||
mtime = get_mtime_ns(mtime, stats["commits"])
|
||||
if args.debug:
|
||||
datestr = isodate(mtime)
|
||||
continue
|
||||
|
||||
# File line: three tokens if it describes a renaming, otherwise two
|
||||
tokens = line.split("\t")
|
||||
|
||||
# Possible statuses:
|
||||
# M: Modified (content changed)
|
||||
# A: Added (created)
|
||||
# D: Deleted
|
||||
# T: Type changed: to/from regular file, symlinks, submodules
|
||||
# R099: Renamed (moved), with % of unchanged content. 100 = pure rename
|
||||
# Not possible in log: C=Copied, U=Unmerged, X=Unknown, B=pairing Broken
|
||||
status = tokens[0].split(" ")[-1]
|
||||
file = tokens[-1]
|
||||
|
||||
# Handles non-ASCII chars and OS path separator
|
||||
file = normalize(file)
|
||||
|
||||
def do_file():
|
||||
if args.skip_older_than_commit and get_mtime_path(file) <= mtime:
|
||||
stats["skip"] += 1
|
||||
return
|
||||
if args.debug:
|
||||
log.debug(
|
||||
"%d\t%d\t%d\t%s\t%s",
|
||||
stats["loglines"],
|
||||
stats["commits"],
|
||||
stats["files"],
|
||||
datestr,
|
||||
file,
|
||||
)
|
||||
try:
|
||||
touch(os.path.join(git.workdir, file), mtime)
|
||||
stats["touches"] += 1
|
||||
except Exception as e:
|
||||
log.error("ERROR: %s: %s", e, file)
|
||||
stats["errors"] += 1
|
||||
|
||||
def do_dir():
|
||||
if args.debug:
|
||||
log.debug(
|
||||
"%d\t%d\t-\t%s\t%s",
|
||||
stats["loglines"],
|
||||
stats["commits"],
|
||||
datestr,
|
||||
"{}/".format(dirname or "."),
|
||||
)
|
||||
try:
|
||||
touch(os.path.join(git.workdir, dirname), mtime)
|
||||
stats["dirtouches"] += 1
|
||||
except Exception as e:
|
||||
log.error("ERROR: %s: %s", e, dirname)
|
||||
stats["direrrors"] += 1
|
||||
|
||||
if file in filelist:
|
||||
stats["files"] -= 1
|
||||
filelist.remove(file)
|
||||
do_file()
|
||||
|
||||
if args.dirs and status in ("A", "D"):
|
||||
dirname = os.path.dirname(file)
|
||||
if dirname in dirlist:
|
||||
dirlist.remove(dirname)
|
||||
do_dir()
|
||||
|
||||
# All files done?
|
||||
if not stats["files"]:
|
||||
git.terminate()
|
||||
return
|
||||
|
||||
|
||||
# Main Logic ##################################################################
|
||||
|
||||
|
||||
def main():
|
||||
start = time.time() # yes, Wall time. CPU time is not realistic for users.
|
||||
stats = {
|
||||
_: 0
|
||||
for _ in (
|
||||
"loglines",
|
||||
"commits",
|
||||
"touches",
|
||||
"skip",
|
||||
"errors",
|
||||
"dirtouches",
|
||||
"direrrors",
|
||||
)
|
||||
}
|
||||
|
||||
logging.basicConfig(level=args.loglevel, format="%(message)s")
|
||||
log.trace("Arguments: %s", args)
|
||||
|
||||
# First things first: Where and Who are we?
|
||||
if args.cwd:
|
||||
log.debug("Changing directory: %s", args.cwd)
|
||||
try:
|
||||
os.chdir(args.cwd)
|
||||
except OSError as e:
|
||||
log.critical(e)
|
||||
return e.errno
|
||||
# Using both os.chdir() and `git -C` is redundant, but might prevent side effects
|
||||
# `git -C` alone could be enough if we make sure that:
|
||||
# - all paths, including args.pathspec, are processed by git: ls-files, rev-parse
|
||||
# - touch() / os.utime() path argument is always prepended with git.workdir
|
||||
try:
|
||||
git = Git(workdir=args.workdir, gitdir=args.gitdir, cwd=args.cwd)
|
||||
except Git.Error as e:
|
||||
# Not in a git repository, and git already informed user on stderr. So we just...
|
||||
return e.returncode
|
||||
|
||||
# Get the files managed by git and build file list to be processed
|
||||
if UPDATE_SYMLINKS and not args.skip_older_than:
|
||||
filelist = set(git.ls_files(args.pathspec))
|
||||
else:
|
||||
filelist = set()
|
||||
for path in git.ls_files(args.pathspec):
|
||||
fullpath = os.path.join(git.workdir, path)
|
||||
|
||||
# Symlink (to file, to dir or broken - git handles the same way)
|
||||
if not UPDATE_SYMLINKS and os.path.islink(fullpath):
|
||||
log.warning(
|
||||
"WARNING: Skipping symlink, no OS support for updates: %s", path
|
||||
)
|
||||
continue
|
||||
|
||||
# skip files which are older than given threshold
|
||||
if (
|
||||
args.skip_older_than
|
||||
and start - get_mtime_path(fullpath) > args.skip_older_than
|
||||
):
|
||||
continue
|
||||
|
||||
# Always add files relative to worktree root
|
||||
filelist.add(path)
|
||||
|
||||
# If --force, silently ignore uncommitted deletions (not in the filesystem)
|
||||
# and renames / additions (will not be found in log anyway)
|
||||
if args.force:
|
||||
filelist -= set(git.ls_dirty(force=True))
|
||||
# Otherwise, ignore any dirty files
|
||||
else:
|
||||
dirty = set(git.ls_dirty())
|
||||
if dirty:
|
||||
log.warning(
|
||||
"WARNING: Modified files in the working directory were ignored."
|
||||
"\nTo include such files, commit your changes or use --force."
|
||||
)
|
||||
filelist -= dirty
|
||||
|
||||
# Build dir list to be processed
|
||||
dirlist = set(os.path.dirname(_) for _ in filelist) if args.dirs else set()
|
||||
|
||||
stats["totalfiles"] = stats["files"] = len(filelist)
|
||||
log.info("{0:,} files to be processed in work dir".format(stats["totalfiles"]))
|
||||
|
||||
if not filelist:
|
||||
# Nothing to do. Exit silently and without errors, just like git does
|
||||
return
|
||||
|
||||
# Process the log until all files are 'touched'
|
||||
log.debug("Line #\tLog #\tF.Left\tModification Time\tFile Name")
|
||||
parse_log(filelist, dirlist, stats, git, args.merge, args.pathspec)
|
||||
|
||||
# Missing files
|
||||
if filelist:
|
||||
# Try to find them in merge logs, if not done already
|
||||
# (usually HUGE, thus MUCH slower!)
|
||||
if args.missing and not args.merge:
|
||||
filterlist = list(filelist)
|
||||
missing = len(filterlist)
|
||||
log.info(
|
||||
"{0:,} files not found in log, trying merge commits".format(missing)
|
||||
)
|
||||
for i in range(0, missing, STEPMISSING):
|
||||
parse_log(
|
||||
filelist,
|
||||
dirlist,
|
||||
stats,
|
||||
git,
|
||||
merge=True,
|
||||
filterlist=filterlist[i : i + STEPMISSING],
|
||||
)
|
||||
|
||||
# Still missing some?
|
||||
for file in filelist:
|
||||
log.warning("WARNING: not found in the log: %s", file)
|
||||
|
||||
# Final statistics
|
||||
# Suggestion: use git-log --before=mtime to brag about skipped log entries
|
||||
def log_info(msg, *a, width=13):
|
||||
ifmt = "{:%d,}" % (width,) # not using 'n' for consistency with ffmt
|
||||
ffmt = "{:%d,.2f}" % (width,)
|
||||
# %-formatting lacks a thousand separator, must pre-render with .format()
|
||||
log.info(msg.replace("%d", ifmt).replace("%f", ffmt).format(*a))
|
||||
|
||||
log_info(
|
||||
"Statistics:\n%f seconds\n%d log lines processed\n%d commits evaluated",
|
||||
time.time() - start,
|
||||
stats["loglines"],
|
||||
stats["commits"],
|
||||
)
|
||||
|
||||
if args.dirs:
|
||||
if stats["direrrors"]:
|
||||
log_info("%d directory update errors", stats["direrrors"])
|
||||
log_info("%d directories updated", stats["dirtouches"])
|
||||
|
||||
if stats["touches"] != stats["totalfiles"]:
|
||||
log_info("%d files", stats["totalfiles"])
|
||||
if stats["skip"]:
|
||||
log_info("%d files skipped", stats["skip"])
|
||||
if stats["files"]:
|
||||
log_info("%d files missing", stats["files"])
|
||||
if stats["errors"]:
|
||||
log_info("%d file update errors", stats["errors"])
|
||||
|
||||
log_info("%d files updated", stats["touches"])
|
||||
|
||||
if args.test:
|
||||
log.info("TEST RUN - No files modified!")
|
||||
|
||||
|
||||
# Keep only essential, global assignments here. Any other logic must be in main()
|
||||
log = setup_logging()
|
||||
args = parse_args()
|
||||
|
||||
# Set the actual touch() and other functions based on command-line arguments
|
||||
if args.unique_times:
|
||||
touch = touch_ns
|
||||
isodate = isodate_ns
|
||||
|
||||
# Make sure this is always set last to ensure --test behaves as intended
|
||||
if args.test:
|
||||
touch = dummy
|
||||
|
||||
# UI done, it's showtime!
|
||||
try:
|
||||
sys.exit(main())
|
||||
except KeyboardInterrupt:
|
||||
log.info("\nAborting")
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Validates that a package's integration tests compile without syntax or import errors.
|
||||
#
|
||||
# (If an integration test fails to compile, it won't run.)
|
||||
#
|
||||
# Called as part of check_diffs.yml workflow
|
||||
#
|
||||
# Runs pytest with compile marker to check syntax/imports.
|
||||
|
||||
name: "🔗 Compile Integration Tests"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
python-version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Python version to use"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
name: "Python ${{ inputs.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache-suffix: compile-integration-tests-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: "📦 Install Integration Dependencies"
|
||||
shell: bash
|
||||
run: uv sync --group test --group test_integration
|
||||
|
||||
- name: "🔗 Check Integration Tests Compile"
|
||||
shell: bash
|
||||
run: uv run pytest -m compile tests/integration_tests
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
@@ -0,0 +1,81 @@
|
||||
# Runs linting.
|
||||
#
|
||||
# Uses the package's Makefile to run the checks, specifically the
|
||||
# `lint_package` and `lint_tests` targets.
|
||||
#
|
||||
# Called as part of check_diffs.yml workflow.
|
||||
|
||||
name: "🧹 Linting"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
python-version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Python version to use"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
WORKDIR: ${{ inputs.working-directory == '' && '.' || inputs.working-directory }}
|
||||
|
||||
# This env var allows us to get inline annotations when ruff has complaints.
|
||||
RUFF_OUTPUT_FORMAT: github
|
||||
|
||||
UV_FROZEN: "true"
|
||||
|
||||
jobs:
|
||||
# Linting job - runs quality checks on package and test code
|
||||
build:
|
||||
name: "Python ${{ inputs.python-version }}"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache-suffix: lint-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
# - name: "🔒 Verify Lockfile is Up-to-Date"
|
||||
# working-directory: ${{ inputs.working-directory }}
|
||||
# run: |
|
||||
# unset UV_FROZEN
|
||||
# uv lock --check
|
||||
|
||||
- name: "📦 Install Lint & Typing Dependencies"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
uv sync --group lint --group typing
|
||||
|
||||
- name: "🔍 Analyze Package Code with Linters"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
make lint_package
|
||||
|
||||
- name: "📦 Install Test Dependencies (non-partners)"
|
||||
# (For directories NOT starting with libs/partners/)
|
||||
if: ${{ ! startsWith(inputs.working-directory, 'libs/partners/') }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
uv sync --inexact --group test
|
||||
- name: "📦 Install Test Dependencies"
|
||||
if: ${{ startsWith(inputs.working-directory, 'libs/partners/') }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
uv sync --inexact --group test --group test_integration
|
||||
|
||||
- name: "🔍 Analyze Test Code with Linters"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
run: |
|
||||
make lint_tests
|
||||
@@ -0,0 +1,228 @@
|
||||
# Reusable workflow: refreshes model profile data for any repo that uses the
|
||||
# `langchain-profiles` CLI. Creates (or updates) a pull request with the
|
||||
# resulting changes.
|
||||
#
|
||||
# Callers MUST set `permissions: { contents: write, pull-requests: write }` —
|
||||
# reusable workflows cannot escalate the caller's token permissions.
|
||||
#
|
||||
# ── Example: external repo (langchain-google) ──────────────────────────
|
||||
#
|
||||
# jobs:
|
||||
# refresh-profiles:
|
||||
# uses: langchain-ai/langchain/.github/workflows/_refresh_model_profiles.yml@master
|
||||
# with:
|
||||
# providers: >-
|
||||
# [
|
||||
# {"provider":"google", "data_dir":"libs/genai/langchain_google_genai/data"},
|
||||
# ]
|
||||
# secrets:
|
||||
# MODEL_PROFILE_BOT_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }}
|
||||
# MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
|
||||
|
||||
name: "Refresh Model Profiles (reusable)"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
providers:
|
||||
description: >-
|
||||
JSON array of objects, each with `provider` (models.dev provider ID)
|
||||
and `data_dir` (path relative to repo root where `_profiles.py` and
|
||||
`profile_augmentations.toml` live).
|
||||
required: true
|
||||
type: string
|
||||
cli-path:
|
||||
description: >-
|
||||
Path (relative to workspace) to an existing `libs/model-profiles`
|
||||
checkout. When set the workflow skips cloning the langchain repo and
|
||||
uses this directory for the CLI instead. Useful when the caller IS
|
||||
the langchain monorepo.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
cli-ref:
|
||||
description: >-
|
||||
Git ref of langchain-ai/langchain to checkout for the CLI.
|
||||
Ignored when `cli-path` is set.
|
||||
required: false
|
||||
type: string
|
||||
default: master
|
||||
add-paths:
|
||||
description: "Glob for files to stage in the PR commit."
|
||||
required: false
|
||||
type: string
|
||||
default: "**/_profiles.py"
|
||||
pr-branch:
|
||||
description: "Branch name for the auto-created PR."
|
||||
required: false
|
||||
type: string
|
||||
default: bot/refresh-model-profiles
|
||||
pr-title:
|
||||
description: "PR / commit title."
|
||||
required: false
|
||||
type: string
|
||||
default: "chore(model-profiles): refresh model profile data"
|
||||
pr-body:
|
||||
description: "PR body."
|
||||
required: false
|
||||
type: string
|
||||
default: |
|
||||
Automated refresh of model profile data via `langchain-profiles refresh`.
|
||||
|
||||
🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml).
|
||||
pr-labels:
|
||||
description: "Comma-separated labels to apply to the PR."
|
||||
required: false
|
||||
type: string
|
||||
default: bot
|
||||
secrets:
|
||||
MODEL_PROFILE_BOT_CLIENT_ID:
|
||||
required: true
|
||||
MODEL_PROFILE_BOT_PRIVATE_KEY:
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh-profiles:
|
||||
name: refresh model profiles
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "📋 Checkout"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "📋 Checkout langchain-profiles CLI"
|
||||
if: inputs.cli-path == ''
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: langchain-ai/langchain
|
||||
ref: ${{ inputs.cli-ref }}
|
||||
sparse-checkout: libs/model-profiles
|
||||
path: _langchain-cli
|
||||
|
||||
- name: "🔧 Resolve CLI directory"
|
||||
id: cli
|
||||
env:
|
||||
CLI_PATH: ${{ inputs.cli-path }}
|
||||
run: |
|
||||
if [ -n "${CLI_PATH}" ]; then
|
||||
resolved="${GITHUB_WORKSPACE}/${CLI_PATH}"
|
||||
if [ ! -d "${resolved}" ]; then
|
||||
echo "::error::cli-path '${CLI_PATH}' does not exist at ${resolved}"
|
||||
exit 1
|
||||
fi
|
||||
echo "dir=${CLI_PATH}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "dir=_langchain-cli/libs/model-profiles" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: "🐍 Set up Python + uv"
|
||||
uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
|
||||
with:
|
||||
version: "0.5.25"
|
||||
python-version: "3.12"
|
||||
enable-cache: true
|
||||
cache-dependency-glob: "**/model-profiles/uv.lock"
|
||||
|
||||
- name: "📦 Install langchain-profiles CLI"
|
||||
working-directory: ${{ steps.cli.outputs.dir }}
|
||||
run: uv sync --frozen --no-group test --no-group dev --no-group lint
|
||||
|
||||
- name: "✅ Validate providers input"
|
||||
env:
|
||||
PROVIDERS_JSON: ${{ inputs.providers }}
|
||||
run: |
|
||||
echo "${PROVIDERS_JSON}" | jq -e 'type == "array" and length > 0' > /dev/null || {
|
||||
echo "::error::providers input must be a non-empty JSON array"
|
||||
exit 1
|
||||
}
|
||||
echo "${PROVIDERS_JSON}" | jq -e 'all(has("provider") and has("data_dir"))' > /dev/null || {
|
||||
echo "::error::every entry in providers must have 'provider' and 'data_dir' keys"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: "🔄 Refresh profiles"
|
||||
env:
|
||||
PROVIDERS_JSON: ${{ inputs.providers }}
|
||||
run: |
|
||||
cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}"
|
||||
failed=""
|
||||
mapfile -t rows < <(echo "${PROVIDERS_JSON}" | jq -c '.[]')
|
||||
for row in "${rows[@]}"; do
|
||||
provider=$(echo "${row}" | jq -r '.provider')
|
||||
data_dir=$(echo "${row}" | jq -r '.data_dir')
|
||||
echo "--- Refreshing ${provider} -> ${data_dir} ---"
|
||||
if ! echo y | uv run --frozen --project "${cli_dir}" \
|
||||
langchain-profiles refresh \
|
||||
--provider "${provider}" \
|
||||
--data-dir "${GITHUB_WORKSPACE}/${data_dir}"; then
|
||||
echo "::error::Failed to refresh provider: ${provider}"
|
||||
failed="${failed} ${provider}"
|
||||
fi
|
||||
done
|
||||
if [ -n "${failed}" ]; then
|
||||
echo "::error::The following providers failed:${failed}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: "📝 Build PR body with change summary"
|
||||
id: pr-body
|
||||
env:
|
||||
PROVIDERS_JSON: ${{ inputs.providers }}
|
||||
PR_BODY: ${{ inputs.pr-body }}
|
||||
run: |
|
||||
# The refresh step modified the working tree without committing, so
|
||||
# comparing against HEAD yields exactly the refresh's changes.
|
||||
cli_dir="${GITHUB_WORKSPACE}/${{ steps.cli.outputs.dir }}"
|
||||
body_file="${RUNNER_TEMP}/pr_body.md"
|
||||
printf '%s\n\n' "${PR_BODY}" > "${body_file}"
|
||||
# `summarize` builds the whole summary in memory and prints it once,
|
||||
# so a failure exits non-zero before any stdout reaches the append —
|
||||
# the body keeps only the static note, never a half-written summary.
|
||||
if ! uv run --frozen --project "${cli_dir}" \
|
||||
langchain-profiles summarize \
|
||||
--providers "${PROVIDERS_JSON}" \
|
||||
--base-ref HEAD \
|
||||
--repo-root "${GITHUB_WORKSPACE}" >> "${body_file}"; then
|
||||
echo "::warning::Could not generate change summary; see job log."
|
||||
# Surface the degradation in the PR body too: the warning above only
|
||||
# lands in the Actions log, which a PR reviewer won't see.
|
||||
printf '\n> [!NOTE]\n> Automated change summary unavailable — see the workflow run log.\n' >> "${body_file}"
|
||||
fi
|
||||
echo "path=${body_file}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: "🔑 Generate GitHub App token"
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }}
|
||||
private-key: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
|
||||
|
||||
- name: "🔀 Create pull request"
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@5f6978faf089d4d20b00c7766989d076bb2fc7f1 # v8
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
branch: ${{ inputs.pr-branch }}
|
||||
commit-message: ${{ inputs.pr-title }}
|
||||
title: ${{ inputs.pr-title }}
|
||||
body-path: ${{ steps.pr-body.outputs.path }}
|
||||
labels: ${{ inputs.pr-labels }}
|
||||
add-paths: ${{ inputs.add-paths }}
|
||||
|
||||
- name: "📝 Summary"
|
||||
if: always()
|
||||
env:
|
||||
PR_OP: ${{ steps.create-pr.outputs.pull-request-operation }}
|
||||
PR_URL: ${{ steps.create-pr.outputs.pull-request-url }}
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
if [ "${PR_OP}" = "created" ] || [ "${PR_OP}" = "updated" ]; then
|
||||
echo "### ✅ PR ${PR_OP}: ${PR_URL}" >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ -z "${PR_OP}" ] && [ "${JOB_STATUS}" = "success" ]; then
|
||||
echo "### ⏭️ Skipped: profiles already up to date" >> "$GITHUB_STEP_SUMMARY"
|
||||
elif [ "${JOB_STATUS}" = "failure" ]; then
|
||||
echo "### ❌ Job failed — check step logs for details" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
@@ -0,0 +1,914 @@
|
||||
# Builds and publishes LangChain packages to PyPI.
|
||||
#
|
||||
# Manually triggered, though can be used as a reusable workflow (workflow_call).
|
||||
#
|
||||
# Handles version bumping, building, and publishing to PyPI with authentication.
|
||||
|
||||
name: "🚀 Package Release"
|
||||
# Run title resolves dropdown values to the published package name (e.g.
|
||||
# `core` -> `langchain-core`, `openai` -> `langchain-openai`). Falls back to
|
||||
# the raw input for override and `workflow_call` cases, which already pass
|
||||
# a full path. Three dropdown values don't follow `langchain-{name}`:
|
||||
# `langchain` -> `langchain-classic`, `langchain_v1` -> `langchain`,
|
||||
# `standard-tests` -> `langchain-tests`.
|
||||
run-name: >-
|
||||
Release ${{ inputs.working-directory-override ||
|
||||
(startsWith(inputs.working-directory, 'libs/') && inputs.working-directory) ||
|
||||
(inputs.working-directory == 'langchain' && 'langchain-classic') ||
|
||||
(inputs.working-directory == 'langchain_v1' && 'langchain') ||
|
||||
(inputs.working-directory == 'standard-tests' && 'langchain-tests') ||
|
||||
format('langchain-{0}', inputs.working-directory) }} ${{
|
||||
inputs.release-version }}
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
release-version:
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
description: "Expected package version. If provided, must match pyproject.toml."
|
||||
allow-prereleases:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Pass `--prerelease=allow` to wheel-install steps so
|
||||
transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled
|
||||
in by an alpha langgraph) resolve. Use only when the release itself
|
||||
is a prerelease and at least one dep is also a prerelease."
|
||||
# `workflow_call` callers must pass an exact lowercase value: `none` or a
|
||||
# partner name from the `test-prior-published-packages-against-new-core`
|
||||
# matrix (or `all`). Unrecognized values fail safe (the check still runs).
|
||||
# Keep this list in sync with that matrix and the `workflow_dispatch`
|
||||
# `options` below.
|
||||
skip-prior-published-package-checks:
|
||||
required: false
|
||||
type: string
|
||||
default: "none"
|
||||
description: "Prior published partner check to skip for core releases:
|
||||
none, anthropic, openai, or all."
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: choice
|
||||
description: "From which folder this pipeline executes"
|
||||
default: "langchain_v1"
|
||||
# Short names only — `EFFECTIVE_WORKING_DIR` below re-adds the `libs/`
|
||||
# or `libs/partners/` prefix. When adding a new option, also update the
|
||||
# non-partner allowlist in `EFFECTIVE_WORKING_DIR` if it isn't a partner
|
||||
# package (partners are the default branch).
|
||||
options:
|
||||
- core
|
||||
- langchain
|
||||
- langchain_v1
|
||||
- text-splitters
|
||||
- standard-tests
|
||||
- model-profiles
|
||||
- anthropic
|
||||
- chroma
|
||||
- deepseek
|
||||
- exa
|
||||
- fireworks
|
||||
- groq
|
||||
- huggingface
|
||||
- mistralai
|
||||
- nomic
|
||||
- ollama
|
||||
- openai
|
||||
- openrouter
|
||||
- perplexity
|
||||
- qdrant
|
||||
- xai
|
||||
working-directory-override:
|
||||
required: false
|
||||
type: string
|
||||
description: "Manual override — takes precedence over dropdown (e.g.
|
||||
libs/partners/partner-xyz)"
|
||||
release-version:
|
||||
required: true
|
||||
type: string
|
||||
default: "0.1.0"
|
||||
description: "New version of package being released"
|
||||
dangerous-nonmaster-release:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Release from a non-master branch (danger!) - Only use for hotfixes"
|
||||
allow-prereleases:
|
||||
required: false
|
||||
type: boolean
|
||||
default: false
|
||||
description: "Pass `--prerelease=allow` to wheel-install steps so
|
||||
transitive prerelease deps (e.g. langgraph-checkpoint>=4.1.0a3 pulled
|
||||
in by an alpha langgraph) resolve. Use only when the release itself
|
||||
is a prerelease and at least one dep is also a prerelease."
|
||||
skip-prior-published-package-checks:
|
||||
required: false
|
||||
type: choice
|
||||
default: none
|
||||
description: "Prior published partner check to skip for core releases"
|
||||
options:
|
||||
- none
|
||||
- anthropic
|
||||
- openai
|
||||
- all
|
||||
|
||||
env:
|
||||
PYTHON_VERSION: "3.11"
|
||||
UV_FROZEN: "true"
|
||||
UV_NO_SYNC: "true"
|
||||
# Resolves to a full path. Accepts either:
|
||||
# - `working-directory-override` as a full path (e.g. `libs/partners/partner-xyz`)
|
||||
# - `working-directory` as a full path (from `workflow_call` callers)
|
||||
# - `working-directory` as a short dropdown name (from `workflow_dispatch`)
|
||||
EFFECTIVE_WORKING_DIR: >-
|
||||
${{
|
||||
inputs.working-directory-override
|
||||
|| (startsWith(inputs.working-directory, 'libs/') && inputs.working-directory)
|
||||
|| (contains(fromJSON('["core","langchain","langchain_v1","text-splitters","standard-tests","model-profiles"]'), inputs.working-directory) && format('libs/{0}', inputs.working-directory))
|
||||
|| format('libs/partners/{0}', inputs.working-directory)
|
||||
}}
|
||||
|
||||
permissions:
|
||||
contents: read # Job-level overrides grant write only where needed (mark-release)
|
||||
|
||||
jobs:
|
||||
# Build the distribution package and extract version info
|
||||
# Runs in isolated environment with minimal permissions for security
|
||||
build:
|
||||
name: 📦 Build distribution
|
||||
if: github.repository_owner == 'langchain-ai' && (github.ref == 'refs/heads/master' || inputs.dangerous-nonmaster-release)
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
outputs:
|
||||
pkg-name: ${{ steps.check-version.outputs.pkg-name }}
|
||||
version: ${{ steps.check-version.outputs.version }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Set up Python + uv
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
enable-cache: "false"
|
||||
|
||||
- name: Summarize release bypasses
|
||||
if: >-
|
||||
inputs.dangerous-nonmaster-release || inputs.allow-prereleases ||
|
||||
inputs.skip-prior-published-package-checks != 'none'
|
||||
env:
|
||||
ALLOW_PRERELEASES: ${{ inputs.allow-prereleases }}
|
||||
DANGEROUS_NONMASTER_RELEASE: ${{ inputs.dangerous-nonmaster-release }}
|
||||
SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS: ${{ inputs.skip-prior-published-package-checks }}
|
||||
run: |
|
||||
echo "::warning::Release bypass input(s) enabled. See job summary."
|
||||
{
|
||||
echo "## ⚠️ Release bypasses enabled"
|
||||
echo
|
||||
echo "One or more release safety bypasses were selected for this run:"
|
||||
echo
|
||||
if [ "$DANGEROUS_NONMASTER_RELEASE" = "true" ]; then
|
||||
echo "- \`dangerous-nonmaster-release\`: release jobs may run from a non-\`master\` ref."
|
||||
fi
|
||||
if [ "$ALLOW_PRERELEASES" = "true" ]; then
|
||||
echo "- \`allow-prereleases\`: install checks use \`--prerelease=allow\`."
|
||||
fi
|
||||
if [ -n "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" ] && [ "$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS" != "none" ]; then
|
||||
echo "- \`skip-prior-published-package-checks\`: \`$SKIP_PRIOR_PUBLISHED_PACKAGE_CHECKS\`."
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Check version
|
||||
id: check-version
|
||||
shell: python
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
env:
|
||||
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
|
||||
run: |
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import tomllib
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
with open("pyproject.toml", "rb") as f:
|
||||
data = tomllib.load(f)
|
||||
|
||||
pkg_name = data["project"]["name"]
|
||||
version = data["project"]["version"]
|
||||
requested_version = os.environ.get("RELEASE_VERSION_INPUT", "").strip()
|
||||
|
||||
|
||||
def normalize(v):
|
||||
# Lightweight PEP 440 comparison key: lowercase and drop the `-`,
|
||||
# `_`, or `.` separators that precede a pre/post/dev segment so that
|
||||
# e.g. `0.1.0-rc1` and `0.1.0rc1` compare equal. Full canonicalization
|
||||
# lives in `packaging`, which isn't installed in this bare release step.
|
||||
return re.sub(r"[-_.]+(?=[a-z])", "", v.lower())
|
||||
|
||||
|
||||
if requested_version and normalize(requested_version) != normalize(version):
|
||||
print(
|
||||
f"::error::Requested release version {requested_version!r} does "
|
||||
f"not match {pkg_name} pyproject.toml version {version!r}."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
# Query the per-version endpoint so PyPI applies PEP 440 normalization
|
||||
# (e.g. `0.1.0-rc1` and `0.1.0rc1` resolve to the same release): HTTP 200
|
||||
# means the version is already published, 404 means it's available
|
||||
# (including the first-ever release of a new package). Only the status
|
||||
# code is used, so a malicious or malformed response body can't mislead us.
|
||||
url = f"https://pypi.org/pypi/{pkg_name}/{version}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10):
|
||||
already_published = True
|
||||
except urllib.error.HTTPError as err:
|
||||
if err.code == 404:
|
||||
already_published = False
|
||||
else:
|
||||
# Fail closed: an unexpected status means we can't verify.
|
||||
print(
|
||||
f"::error::PyPI returned HTTP {err.code} checking whether "
|
||||
f"{pkg_name}=={version} exists; cannot verify, aborting."
|
||||
)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as err:
|
||||
# Fail closed: if PyPI is unreachable we must not assume the version
|
||||
# is free, or we risk re-publishing an existing release.
|
||||
print(
|
||||
f"::error::Could not reach PyPI to verify {pkg_name}=={version} "
|
||||
f"({err.reason}); cannot verify, aborting."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
if already_published:
|
||||
print(f"::error::{pkg_name}=={version} already exists on PyPI.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
with open(os.environ["GITHUB_OUTPUT"], "a") as f:
|
||||
f.write(f"pkg-name={pkg_name}\n")
|
||||
f.write(f"version={version}\n")
|
||||
|
||||
# We want to keep this build stage *separate* from the release stage,
|
||||
# so that there's no sharing of permissions between them.
|
||||
# (Release stage has trusted publishing and GitHub repo contents write access,
|
||||
# which the build stage must not have access to.)
|
||||
#
|
||||
# Otherwise, a malicious `build` step (e.g. via a compromised dependency)
|
||||
# could get access to our GitHub or PyPI credentials.
|
||||
#
|
||||
# Per the trusted publishing GitHub Action:
|
||||
# > It is strongly advised to separate jobs for building [...]
|
||||
# > from the publish job.
|
||||
# https://github.com/pypa/gh-action-pypi-publish#non-goals
|
||||
- name: Build project for distribution
|
||||
run: uv build
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
- name: Upload build
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
release-notes:
|
||||
name: 📝 Generate release notes
|
||||
# release-notes must run before publishing because its check-tags step
|
||||
# validates version/tag state — do not remove this dependency.
|
||||
needs:
|
||||
- build
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
outputs:
|
||||
release-body: ${{ steps.generate-release-body.outputs.release-body }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: langchain-ai/langchain
|
||||
path: langchain
|
||||
sparse-checkout: | # this only grabs files for relevant dir
|
||||
${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
ref: ${{ github.ref }} # this scopes to just ref'd branch
|
||||
fetch-depth: 0 # this fetches entire commit history
|
||||
- name: Check tags
|
||||
id: check-tags
|
||||
shell: bash
|
||||
working-directory: langchain/${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
run: |
|
||||
# Handle regular versions and pre-release versions differently
|
||||
if [[ "$VERSION" == *"-"* ]]; then
|
||||
# This is a pre-release version (contains a hyphen)
|
||||
# Extract the base version without the pre-release suffix
|
||||
BASE_VERSION=${VERSION%%-*}
|
||||
# Look for the latest release of the same base version
|
||||
REGEX="^$PKG_NAME==$BASE_VERSION\$"
|
||||
PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
|
||||
|
||||
# If no exact base version match, look for the latest release of any kind
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
|
||||
PREV_TAG=$(git tag --sort=-creatordate | (grep -P "$REGEX" || true) | head -1)
|
||||
fi
|
||||
else
|
||||
# Regular version handling
|
||||
PREV_TAG="$PKG_NAME==${VERSION%.*}.$(( ${VERSION##*.} - 1 ))"; [[ "${VERSION##*.}" -eq 0 ]] && PREV_TAG=""
|
||||
|
||||
# backup case if releasing e.g. 0.3.0, looks up last release
|
||||
# note if last release (chronologically) was e.g. 0.1.47 it will get
|
||||
# that instead of the last 0.2 release
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
REGEX="^$PKG_NAME==\\d+\\.\\d+\\.\\d+\$"
|
||||
echo $REGEX
|
||||
PREV_TAG=$(git tag --sort=-creatordate | (grep -P $REGEX || true) | head -1)
|
||||
fi
|
||||
fi
|
||||
|
||||
# if PREV_TAG is empty or came out to 0.0.0, let it be empty
|
||||
if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
|
||||
echo "No previous tag found - first release"
|
||||
else
|
||||
# confirm prev-tag actually exists in git repo with git tag
|
||||
GIT_TAG_RESULT=$(git tag -l "$PREV_TAG")
|
||||
if [ -z "$GIT_TAG_RESULT" ]; then
|
||||
echo "Previous tag $PREV_TAG not found in git repo"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
TAG="${PKG_NAME}==${VERSION}"
|
||||
if [ "$TAG" == "$PREV_TAG" ]; then
|
||||
echo "No new version to release"
|
||||
exit 1
|
||||
fi
|
||||
echo tag="$TAG" >> $GITHUB_OUTPUT
|
||||
echo prev-tag="$PREV_TAG" >> $GITHUB_OUTPUT
|
||||
- name: Generate release body
|
||||
id: generate-release-body
|
||||
working-directory: langchain
|
||||
env:
|
||||
WORKING_DIR: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
TAG: ${{ steps.check-tags.outputs.tag }}
|
||||
PREV_TAG: ${{ steps.check-tags.outputs.prev-tag }}
|
||||
run: |
|
||||
PREAMBLE="Changes since $PREV_TAG"
|
||||
# if PREV_TAG is empty or 0.0.0, then we are releasing the first version
|
||||
if [ -z "$PREV_TAG" ] || [ "$PREV_TAG" = "$PKG_NAME==0.0.0" ]; then
|
||||
PREAMBLE="Initial release"
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
{
|
||||
echo 'release-body<<EOF'
|
||||
echo $PREAMBLE
|
||||
echo
|
||||
git log --format="%s" "$PREV_TAG"..HEAD -- $WORKING_DIR
|
||||
echo EOF
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
pre-release-checks:
|
||||
name: ✅ Pre-release checks
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
# We explicitly *don't* set up caching here. This ensures our tests are
|
||||
# maximally sensitive to catching breakage.
|
||||
#
|
||||
# For example, here's a way that caching can cause a falsely-passing test:
|
||||
# - Make the langchain package manifest no longer list a dependency package
|
||||
# as a requirement. This means it won't be installed by `pip install`,
|
||||
# and attempting to use it would cause a crash.
|
||||
# - That dependency used to be required, so it may have been cached.
|
||||
# When restoring the venv packages from cache, that dependency gets included.
|
||||
# - Tests pass, because the dependency is present even though it wasn't specified.
|
||||
# - The package is published, and it breaks on the missing dependency when
|
||||
# used in the real world.
|
||||
|
||||
- name: Set up Python + uv
|
||||
uses: "./.github/actions/uv_setup"
|
||||
id: setup-python
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
enable-cache: "false"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
|
||||
- name: Import dist package
|
||||
shell: bash
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
|
||||
# Install directly from the locally-built wheel (no index resolution needed).
|
||||
# `PRERELEASE_FLAG` is empty by default; opt-in via the `allow-prereleases`
|
||||
# workflow input lets transitive prerelease deps resolve during alpha
|
||||
# release cycles. Stable-release safety is still enforced by the
|
||||
# `Check for prerelease versions` step below.
|
||||
run: |
|
||||
uv venv
|
||||
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG dist/*.whl
|
||||
|
||||
# Replace all dashes in the package name with underscores,
|
||||
# since that's how Python imports packages with dashes in the name.
|
||||
# also remove _official suffix
|
||||
IMPORT_NAME="$(echo "$PKG_NAME" | sed s/-/_/g | sed s/_official//g)"
|
||||
|
||||
uv run python -c "import $IMPORT_NAME; print(dir($IMPORT_NAME))"
|
||||
|
||||
- name: Import test dependencies
|
||||
run: uv sync --group test
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
# Overwrite the local version of the package with the built version
|
||||
- name: Import published package (again)
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
shell: bash
|
||||
env:
|
||||
PKG_NAME: ${{ needs.build.outputs.pkg-name }}
|
||||
VERSION: ${{ needs.build.outputs.version }}
|
||||
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
|
||||
run: |
|
||||
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG dist/*.whl
|
||||
|
||||
- name: Check for prerelease versions
|
||||
# Block release if any dependencies allow prerelease versions
|
||||
# (unless this is itself a prerelease version)
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
run: |
|
||||
uv run python $GITHUB_WORKSPACE/.github/scripts/check_prerelease_dependencies.py pyproject.toml
|
||||
|
||||
- name: Run unit tests
|
||||
run: make tests
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
- name: Get minimum versions
|
||||
# Find the minimum published versions that satisfies the given constraints
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
id: min-version
|
||||
run: |
|
||||
VIRTUAL_ENV=.venv uv pip install packaging requests
|
||||
python_version="$(uv run python --version | awk '{print $2}')"
|
||||
min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml release $python_version)"
|
||||
echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT"
|
||||
echo "min-versions=$min_versions"
|
||||
|
||||
- name: Run unit tests with minimum dependency versions
|
||||
if: ${{ steps.min-version.outputs.min-versions != '' }}
|
||||
env:
|
||||
MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }}
|
||||
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
|
||||
run: |
|
||||
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall --editable .
|
||||
VIRTUAL_ENV=.venv uv pip install $PRERELEASE_FLAG --force-reinstall $MIN_VERSIONS
|
||||
make tests PYTEST_EXTRA="-q -k 'not test_serdes'"
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
- name: Import integration test dependencies
|
||||
run: uv sync --group test --group test_integration
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
- name: Run integration tests
|
||||
# Uses the Makefile's `integration_tests` target for the specified package
|
||||
if: ${{ startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/partners/') }}
|
||||
env:
|
||||
AI21_API_KEY: ${{ secrets.AI21_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
|
||||
AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
|
||||
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
|
||||
GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }}
|
||||
GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }}
|
||||
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
|
||||
NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
|
||||
WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }}
|
||||
WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }}
|
||||
ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }}
|
||||
ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }}
|
||||
ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }}
|
||||
ES_URL: ${{ secrets.ES_URL }}
|
||||
ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }}
|
||||
ES_API_KEY: ${{ secrets.ES_API_KEY }}
|
||||
MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }}
|
||||
UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }}
|
||||
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}
|
||||
run: make integration_tests
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
test-pypi-publish:
|
||||
name: 🧪 Publish to TestPyPI
|
||||
# release-notes must run before publishing because its check-tags step
|
||||
# validates version/tag state — do not remove this dependency.
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- pre-release-checks
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is used for trusted publishing:
|
||||
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
|
||||
#
|
||||
# Trusted publishing has to also be configured on PyPI for each package:
|
||||
# https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
|
||||
- name: Publish to test PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
verbose: true
|
||||
print-hash: true
|
||||
repository-url: https://test.pypi.org/legacy/
|
||||
# We overwrite any existing distributions with the same name and version.
|
||||
# This is *only for CI use* and is *extremely dangerous* otherwise!
|
||||
# https://github.com/pypa/gh-action-pypi-publish#tolerating-release-package-file-duplicates
|
||||
skip-existing: true
|
||||
# Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0
|
||||
attestations: false
|
||||
|
||||
# Test select published packages against new core
|
||||
# Done when code changes are made to langchain-core
|
||||
test-prior-published-packages-against-new-core:
|
||||
name: 🔄 Test prior partners against new core
|
||||
# Installs the new core with old partners: Installs the new unreleased core
|
||||
# alongside the previously published partner packages and runs unit and integration tests
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
strategy:
|
||||
matrix:
|
||||
# When adding a partner, also update the `skip-prior-published-package-checks`
|
||||
# input (the `workflow_dispatch` `options` list and the `workflow_call`
|
||||
# description) so the per-partner skip remains selectable.
|
||||
partner: [ anthropic, openai ]
|
||||
fail-fast: false # Continue testing other partners if one fails
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }}
|
||||
ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
|
||||
AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
|
||||
LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
# We implement this conditional as Github Actions does not have good support
|
||||
# for conditionally needing steps. https://github.com/actions/runner/issues/491
|
||||
# TODO: this seems to be resolved upstream, so we can probably remove this workaround
|
||||
- name: Check if libs/core
|
||||
run: |
|
||||
if [ "${{ startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') }}" != "true" ]; then
|
||||
echo "Not in libs/core. Exiting successfully."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
- name: Set up Python + uv
|
||||
if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core')
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
enable-cache: "false"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
if: startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core')
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
|
||||
- name: Skip prior published ${{ matrix.partner }} check
|
||||
if: >-
|
||||
startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') &&
|
||||
(inputs.skip-prior-published-package-checks == matrix.partner ||
|
||||
inputs.skip-prior-published-package-checks == 'all')
|
||||
run: |
|
||||
echo "Skipping prior published ${{ matrix.partner }} check as requested."
|
||||
|
||||
- name: Test against ${{ matrix.partner }}
|
||||
if: >-
|
||||
startsWith(env.EFFECTIVE_WORKING_DIR, 'libs/core') &&
|
||||
inputs.skip-prior-published-package-checks != matrix.partner &&
|
||||
inputs.skip-prior-published-package-checks != 'all'
|
||||
env:
|
||||
PARTNER: ${{ matrix.partner }}
|
||||
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
|
||||
run: |
|
||||
PACKAGE_NAME="langchain-$PARTNER"
|
||||
|
||||
# Identify the latest non-yanked published package release, excluding pre-releases.
|
||||
# Fail closed (matching the `Check version` step) so a PyPI outage or a
|
||||
# missing release aborts with a clear message rather than an empty version.
|
||||
LATEST_PACKAGE_VERSION="$(PACKAGE_NAME="$PACKAGE_NAME" python - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
package_name = os.environ["PACKAGE_NAME"]
|
||||
url = f"https://pypi.org/pypi/{package_name}/json"
|
||||
try:
|
||||
with urllib.request.urlopen(url, timeout=10) as response:
|
||||
data = json.load(response)
|
||||
except urllib.error.HTTPError as err:
|
||||
print(
|
||||
f"::error::PyPI returned HTTP {err.code} listing {package_name} "
|
||||
f"releases; cannot determine latest version, aborting.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
except urllib.error.URLError as err:
|
||||
print(
|
||||
f"::error::Could not reach PyPI to list {package_name} releases "
|
||||
f"({err.reason}); cannot determine latest version, aborting.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
versions: list[tuple[int, int, int, str]] = []
|
||||
for version, files in data["releases"].items():
|
||||
if not re.fullmatch(r"\d+\.\d+\.\d+", version):
|
||||
continue
|
||||
if not files or all(file.get("yanked", False) for file in files):
|
||||
continue
|
||||
versions.append((*map(int, version.split(".")), version))
|
||||
|
||||
if not versions:
|
||||
print(f"::error::No non-yanked final releases found for {package_name}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(max(versions)[3])
|
||||
PY
|
||||
)"
|
||||
|
||||
# Belt-and-suspenders: a bare assignment masks the heredoc's exit status
|
||||
# in some shells, so guard explicitly rather than relying on `set -e`.
|
||||
if [ -z "$LATEST_PACKAGE_VERSION" ]; then
|
||||
echo "::error::Could not determine latest published $PACKAGE_NAME version; aborting."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
LATEST_PACKAGE_TAG="$PACKAGE_NAME==$LATEST_PACKAGE_VERSION"
|
||||
echo "Latest non-yanked package tag: $LATEST_PACKAGE_TAG"
|
||||
|
||||
# Ensure the PyPI release maps to a source tag before running tests.
|
||||
git ls-remote --exit-code --tags origin "refs/tags/$LATEST_PACKAGE_TAG"
|
||||
|
||||
# Shallow-fetch just that single tag
|
||||
git fetch --depth=1 origin tag "$LATEST_PACKAGE_TAG"
|
||||
|
||||
# Checkout the latest package files
|
||||
rm -rf "$GITHUB_WORKSPACE/libs/partners/$PARTNER"/*
|
||||
rm -rf $GITHUB_WORKSPACE/libs/standard-tests/*
|
||||
cd $GITHUB_WORKSPACE/libs/
|
||||
git checkout "$LATEST_PACKAGE_TAG" -- standard-tests/
|
||||
git checkout "$LATEST_PACKAGE_TAG" -- "partners/$PARTNER/"
|
||||
cd "partners/$PARTNER"
|
||||
|
||||
# Print as a sanity check
|
||||
echo "Version number from pyproject.toml: "
|
||||
cat pyproject.toml | grep "version = "
|
||||
|
||||
# Run tests
|
||||
uv sync --group test --group test_integration
|
||||
uv pip install $PRERELEASE_FLAG ../../core/dist/*.whl
|
||||
make test
|
||||
make integration_tests
|
||||
|
||||
# Test external packages that depend on langchain-core/langchain against the new release
|
||||
# Only runs for core and langchain_v1 releases to catch breaking changes before publish
|
||||
test-dependents:
|
||||
name: "🐍 Test dependent: ${{ matrix.package.path }} (Python ${{
|
||||
matrix.python-version }})"
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
# Only run for core or langchain_v1 releases.
|
||||
# Job-level 'if' does not support env context, so EFFECTIVE_WORKING_DIR is
|
||||
# unavailable; must use inputs directly and match both forms: short dropdown
|
||||
# names (workflow_dispatch, e.g. 'core') and full 'libs/' paths
|
||||
# (workflow_call / working-directory-override).
|
||||
if: >-
|
||||
contains(fromJSON('["core","langchain_v1"]'),
|
||||
inputs.working-directory-override || inputs.working-directory) ||
|
||||
startsWith(inputs.working-directory-override || inputs.working-directory,
|
||||
'libs/core') || startsWith(inputs.working-directory-override ||
|
||||
inputs.working-directory, 'libs/langchain_v1')
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ "3.11", "3.13" ]
|
||||
package:
|
||||
- name: deepagents
|
||||
repo: langchain-ai/deepagents
|
||||
path: libs/deepagents
|
||||
# No API keys needed for now - deepagents `make test` only runs unit tests
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
path: langchain
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: ${{ matrix.package.repo }}
|
||||
path: ${{ matrix.package.name }}
|
||||
|
||||
- name: Set up Python + uv
|
||||
uses: "./langchain/.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
enable-cache: "false"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: dist
|
||||
path: dist/
|
||||
|
||||
- name: Install ${{ matrix.package.name }} with local packages
|
||||
# External dependents don't have [tool.uv.sources] pointing to this repo,
|
||||
# so we install the package normally then override with the built wheel.
|
||||
env:
|
||||
PRERELEASE_FLAG: ${{ inputs.allow-prereleases && '--prerelease=allow' || '' }}
|
||||
run: |
|
||||
cd ${{ matrix.package.name }}/${{ matrix.package.path }}
|
||||
|
||||
# Install the package with test dependencies
|
||||
uv sync --group test
|
||||
|
||||
# Override with the built wheel from this release
|
||||
uv pip install $PRERELEASE_FLAG $GITHUB_WORKSPACE/dist/*.whl
|
||||
|
||||
- name: Run ${{ matrix.package.name }} tests
|
||||
run: |
|
||||
cd ${{ matrix.package.name }}/${{ matrix.package.path }}
|
||||
make test
|
||||
|
||||
publish:
|
||||
name: 🚀 Publish to PyPI
|
||||
# Publishes the package to PyPI
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
- test-dependents
|
||||
- test-prior-published-packages-against-new-core
|
||||
# Run if all needed jobs succeeded or were skipped (test-dependents and
|
||||
# test-prior-published-packages-against-new-core only run for core/langchain_v1)
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is used for trusted publishing:
|
||||
# https://blog.pypi.org/posts/2023-04-20-introducing-trusted-publishers/
|
||||
#
|
||||
# Trusted publishing has to also be configured on PyPI for each package:
|
||||
# https://docs.pypi.org/trusted-publishers/adding-a-publisher/
|
||||
id-token: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Set up Python + uv
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
enable-cache: "false"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
|
||||
- name: Publish package distributions to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # release/v1
|
||||
with:
|
||||
packages-dir: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
verbose: true
|
||||
print-hash: true
|
||||
# Temp workaround since attestations are on by default as of gh-action-pypi-publish v1.11.0
|
||||
attestations: false
|
||||
|
||||
mark-release:
|
||||
name: 🏷️ Tag GitHub release
|
||||
# Marks the GitHub release with the new version tag
|
||||
needs:
|
||||
- build
|
||||
- release-notes
|
||||
- test-pypi-publish
|
||||
- pre-release-checks
|
||||
- publish
|
||||
# Run if all needed jobs succeeded or were skipped
|
||||
if: ${{ !cancelled() && !failure() }}
|
||||
environment: Release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
# This permission is needed by `ncipollo/release-action` to
|
||||
# create the GitHub release/tag
|
||||
contents: write
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ env.EFFECTIVE_WORKING_DIR }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Set up Python + uv
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ env.PYTHON_VERSION }}
|
||||
enable-cache: "false"
|
||||
|
||||
- uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8
|
||||
with:
|
||||
name: dist
|
||||
path: ${{ env.EFFECTIVE_WORKING_DIR }}/dist/
|
||||
|
||||
- name: Create Tag
|
||||
uses: ncipollo/release-action@339a81892b84b4eeb0f6e744e4574d79d0d9b8dd # v1
|
||||
with:
|
||||
# JS actions ignore `defaults.run.working-directory`, so this glob is
|
||||
# resolved from the repo root. Point it at the package's `dist/`
|
||||
# (where `download-artifact` placed the wheels) instead of a bare
|
||||
# `dist/*`, which never matched and attached no assets to releases.
|
||||
artifacts: "${{ env.EFFECTIVE_WORKING_DIR }}/dist/*"
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
generateReleaseNotes: false
|
||||
tag: ${{needs.build.outputs.pkg-name}}==${{ needs.build.outputs.version }}
|
||||
body: ${{ needs.release-notes.outputs.release-body }}
|
||||
commit: ${{ github.sha }}
|
||||
makeLatest: ${{ needs.build.outputs.pkg-name == 'langchain-core'}}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Runs unit tests with both current and minimum supported dependency versions
|
||||
# to ensure compatibility across the supported range.
|
||||
|
||||
name: "🧪 Unit Testing"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
python-version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Python version to use"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
UV_NO_SYNC: "true"
|
||||
|
||||
jobs:
|
||||
# Main test job - runs unit tests with current deps, then retests with minimum versions
|
||||
build:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
name: "Python ${{ inputs.python-version }}"
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
id: setup-python
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache-suffix: test-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: "📦 Install Test Dependencies"
|
||||
shell: bash
|
||||
run: uv sync --group test --dev
|
||||
|
||||
- name: "🧪 Run Core Unit Tests"
|
||||
shell: bash
|
||||
run: |
|
||||
make test PYTEST_EXTRA=-q
|
||||
|
||||
- name: "🔍 Calculate Minimum Dependency Versions"
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
id: min-version
|
||||
shell: bash
|
||||
run: |
|
||||
VIRTUAL_ENV=.venv uv pip install packaging tomli requests
|
||||
python_version="$(uv run python --version | awk '{print $2}')"
|
||||
min_versions="$(uv run python $GITHUB_WORKSPACE/.github/scripts/get_min_versions.py pyproject.toml pull_request $python_version)"
|
||||
echo "min-versions=$min_versions" >> "$GITHUB_OUTPUT"
|
||||
echo "min-versions=$min_versions"
|
||||
|
||||
- name: "🧪 Run Tests with Minimum Dependencies"
|
||||
if: ${{ steps.min-version.outputs.min-versions != '' }}
|
||||
env:
|
||||
MIN_VERSIONS: ${{ steps.min-version.outputs.min-versions }}
|
||||
run: |
|
||||
VIRTUAL_ENV=.venv uv pip install $MIN_VERSIONS
|
||||
make tests PYTEST_EXTRA=-q
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
@@ -0,0 +1,73 @@
|
||||
# Facilitate unit testing against different Pydantic versions for a provided package.
|
||||
|
||||
name: "🐍 Pydantic Version Testing"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
python-version:
|
||||
required: false
|
||||
type: string
|
||||
description: "Python version to use"
|
||||
default: "3.12"
|
||||
pydantic-version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Pydantic version to test."
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
UV_NO_SYNC: "true"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
name: "Pydantic ~=${{ inputs.pydantic-version }}"
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache-suffix: test-pydantic-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: "📦 Install Test Dependencies"
|
||||
shell: bash
|
||||
run: uv sync --group test
|
||||
|
||||
- name: "🔄 Install Specific Pydantic Version"
|
||||
shell: bash
|
||||
env:
|
||||
PYDANTIC_VERSION: ${{ inputs.pydantic-version }}
|
||||
run: VIRTUAL_ENV=.venv uv pip install "pydantic~=$PYDANTIC_VERSION"
|
||||
|
||||
- name: "🧪 Run Core Tests"
|
||||
shell: bash
|
||||
run: |
|
||||
make test
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
@@ -0,0 +1,66 @@
|
||||
# Runs VCR cassette-backed integration tests in playback-only mode.
|
||||
#
|
||||
# No API keys needed — catches stale cassettes caused by test input
|
||||
# changes without re-recording.
|
||||
#
|
||||
# Called as part of check_diffs.yml workflow.
|
||||
|
||||
name: "📼 VCR Cassette Tests"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
working-directory:
|
||||
required: true
|
||||
type: string
|
||||
description: "From which folder this pipeline executes"
|
||||
python-version:
|
||||
required: true
|
||||
type: string
|
||||
description: "Python version to use"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
name: "Python ${{ inputs.python-version }}"
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ inputs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ inputs.python-version }}
|
||||
cache-suffix: test-vcr-${{ inputs.working-directory }}
|
||||
working-directory: ${{ inputs.working-directory }}
|
||||
|
||||
- name: "📦 Install Test Dependencies"
|
||||
shell: bash
|
||||
run: uv sync --group test
|
||||
|
||||
- name: "📼 Run VCR Cassette Tests (playback-only)"
|
||||
shell: bash
|
||||
env:
|
||||
OPENAI_API_KEY: sk-fake
|
||||
run: make test_vcr
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
@@ -0,0 +1,116 @@
|
||||
name: Auto Label Issues by Package
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
label-by-package:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Sync package labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const body = context.payload.issue.body || "";
|
||||
|
||||
// Extract text under "## Package" or "### Package" (handles " (Required)" suffix and being last section)
|
||||
const match = body.match(/#{2,3} Package[^\n]*\n([\s\S]*?)(?:\n#{2,3} |$)/i);
|
||||
if (!match) {
|
||||
core.setFailed(
|
||||
`Could not find "## Package" section in issue #${context.issue.number} body. ` +
|
||||
`The issue template may have changed — update the regex in this workflow.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const packageSection = match[1].trim();
|
||||
|
||||
// Mapping table for package names to labels
|
||||
const mapping = {
|
||||
"langchain": "langchain",
|
||||
"langchain-openai": "openai",
|
||||
"langchain-anthropic": "anthropic",
|
||||
"langchain-classic": "langchain-classic",
|
||||
"langchain-core": "core",
|
||||
"langchain-model-profiles": "model-profiles",
|
||||
"langchain-tests": "standard-tests",
|
||||
"langchain-text-splitters": "text-splitters",
|
||||
"langchain-chroma": "chroma",
|
||||
"langchain-deepseek": "deepseek",
|
||||
"langchain-exa": "exa",
|
||||
"langchain-fireworks": "fireworks",
|
||||
"langchain-groq": "groq",
|
||||
"langchain-huggingface": "huggingface",
|
||||
"langchain-mistralai": "mistralai",
|
||||
"langchain-nomic": "nomic",
|
||||
"langchain-ollama": "ollama",
|
||||
"langchain-openrouter": "openrouter",
|
||||
"langchain-perplexity": "perplexity",
|
||||
"langchain-qdrant": "qdrant",
|
||||
"langchain-xai": "xai",
|
||||
};
|
||||
|
||||
// All possible package labels we manage
|
||||
const allPackageLabels = Object.values(mapping);
|
||||
const selectedLabels = [];
|
||||
|
||||
// Check if this is checkbox format (multiple selection)
|
||||
const checkboxMatches = packageSection.match(/- \[x\]\s+([^\n\r]+)/gi);
|
||||
if (checkboxMatches) {
|
||||
// Handle checkbox format
|
||||
for (const match of checkboxMatches) {
|
||||
const packageName = match.replace(/- \[x\]\s+/i, '').trim();
|
||||
const label = mapping[packageName];
|
||||
if (label && !selectedLabels.includes(label)) {
|
||||
selectedLabels.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Handle dropdown format (single selection)
|
||||
const label = mapping[packageSection];
|
||||
if (label) {
|
||||
selectedLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
// Get current issue labels
|
||||
const issue = await github.rest.issues.get({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number
|
||||
});
|
||||
|
||||
const currentLabels = issue.data.labels.map(label => label.name);
|
||||
const currentPackageLabels = currentLabels.filter(label => allPackageLabels.includes(label));
|
||||
|
||||
// Determine labels to add and remove
|
||||
const labelsToAdd = selectedLabels.filter(label => !currentPackageLabels.includes(label));
|
||||
const labelsToRemove = currentPackageLabels.filter(label => !selectedLabels.includes(label));
|
||||
|
||||
// Add new labels
|
||||
if (labelsToAdd.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels: labelsToAdd
|
||||
});
|
||||
}
|
||||
|
||||
// Remove old labels
|
||||
for (const label of labelsToRemove) {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
name: label
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
# Block PRs whose head ref is `main` (or `master`) from a fork. This topology
|
||||
# (`<fork>:master -> langchain-ai/langchain:master`) lets contributors click
|
||||
# "Update branch" on the PR, producing a `Merge branch 'master' into master`
|
||||
# commit on the source side that — under admin merge override — can land
|
||||
# directly on `master` as a 2-parent merge commit, bypassing the repo's
|
||||
# squash-only policy and polluting the changelog.
|
||||
#
|
||||
# `pull_request_target` is required so the job receives a token scoped to
|
||||
# write PR labels/comments on fork PRs (the standard `pull_request` token is
|
||||
# read-only for forks). This also means the job MUST NOT check out PR code —
|
||||
# see the inline warning in the trigger block below.
|
||||
#
|
||||
# Maintainer bypass: add the `bypass-fork-main-check` label to the PR.
|
||||
|
||||
name: Block fork main PRs
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.
|
||||
# Doing so would allow attackers to execute arbitrary code in the context of your repository.
|
||||
types: [opened, reopened, synchronize, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
guard:
|
||||
if: >-
|
||||
github.repository_owner == 'langchain-ai' &&
|
||||
github.event.pull_request.head.repo.fork == true &&
|
||||
(github.event.pull_request.head.ref == 'main' || github.event.pull_request.head.ref == 'master') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'bypass-fork-main-check')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Close PR and post guidance
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const headRef = context.payload.pull_request.head.ref;
|
||||
const marker = '<!-- block-fork-main -->';
|
||||
|
||||
// Ensure the warning label exists and apply it
|
||||
const labelName = 'fork-main-head';
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: labelName });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) {
|
||||
throw new Error(`getLabel(${labelName}) failed: ${e.message}`);
|
||||
}
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: labelName, color: 'b76e79',
|
||||
});
|
||||
} catch (createErr) {
|
||||
// A 422 with code `already_exists` means a race created the
|
||||
// label between getLabel and createLabel — safe to ignore.
|
||||
// Any other 422 (bad color, name too long) indicates a real
|
||||
// bug introduced by editing this step, so rethrow.
|
||||
const alreadyExists =
|
||||
createErr.status === 422 &&
|
||||
Array.isArray(createErr.errors) &&
|
||||
createErr.errors.some(e => e.code === 'already_exists');
|
||||
if (!alreadyExists) throw createErr;
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: [labelName],
|
||||
});
|
||||
|
||||
const defaultBranch = context.payload.repository.default_branch;
|
||||
const lines = [
|
||||
marker,
|
||||
`**This PR has been automatically closed** because its head branch is \`${headRef}\` on a fork.`,
|
||||
'',
|
||||
'PRs opened from a fork\'s `main` (or `master`) branch can produce a `Merge branch \'main\' into main` commit on the source side. Under an admin merge override that commit can land directly on this repo\'s default branch, bypassing the squash-only policy and polluting the changelog.',
|
||||
'',
|
||||
'To fix:',
|
||||
`1. Sync your fork's \`${defaultBranch}\` first (\`git fetch upstream && git switch ${defaultBranch} && git merge --ff-only upstream/${defaultBranch}\`)`,
|
||||
'2. Create a feature branch: `git switch -c feat/my-change`',
|
||||
'3. Push it: `git push -u origin feat/my-change`',
|
||||
`4. Open a new PR from \`feat/my-change\` → \`langchain-ai/langchain:${defaultBranch}\``,
|
||||
'',
|
||||
'*Maintainers: add the `bypass-fork-main-check` label to override.*',
|
||||
];
|
||||
const body = lines.join('\n');
|
||||
|
||||
// Dedup: update existing marker comment instead of stacking.
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: prNumber, body,
|
||||
});
|
||||
} else if (existing.body !== body) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner, repo, comment_id: existing.id, body,
|
||||
});
|
||||
}
|
||||
|
||||
if (context.payload.pull_request.state === 'open') {
|
||||
await github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel still-queued/in-progress checks on this PR head.
|
||||
// Best-effort: new runs may still queue after this loop (e.g., other
|
||||
// pull_request triggers fanning out). The PR is already closed above,
|
||||
// so leftover runs are wasted compute, not a correctness issue.
|
||||
// We track the cancel ratio so a wholesale failure (token-scope
|
||||
// regression making EVERY cancel return 403) is surfaced rather
|
||||
// than silently producing N warnings + green job.
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
let attempted = 0;
|
||||
let cancelled = 0;
|
||||
for (const status of ['in_progress', 'queued']) {
|
||||
const runs = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunsForRepo,
|
||||
{ owner, repo, head_sha: headSha, status, per_page: 100 },
|
||||
);
|
||||
for (const run of runs) {
|
||||
if (run.id === context.runId) continue;
|
||||
attempted++;
|
||||
try {
|
||||
await github.rest.actions.cancelWorkflowRun({
|
||||
owner, repo, run_id: run.id,
|
||||
});
|
||||
cancelled++;
|
||||
} catch (err) {
|
||||
core.warning(`Could not cancel run ${run.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (attempted > 0 && cancelled === 0) {
|
||||
core.warning(`Attempted to cancel ${attempted} run(s) on head ${headSha} but none succeeded — check token scope.`);
|
||||
}
|
||||
|
||||
core.setFailed(`PR head ref is \`${headRef}\` on a fork — open from a feature branch instead.`);
|
||||
@@ -0,0 +1,205 @@
|
||||
# Monthly bump of the uv pin in `.github/actions/uv_setup/action.yml`.
|
||||
#
|
||||
# We pin uv (rather than letting setup-uv resolve latest) because
|
||||
# `releases.astral.sh` lags GitHub Releases on new uv versions, causing CI
|
||||
# to flap on fresh-release days. This workflow keeps the pin fresh without
|
||||
# exposing that race.
|
||||
#
|
||||
# Dependabot's `github-actions` ecosystem only updates `uses:` SHA pins, not
|
||||
# the `UV_VERSION` env value the action passes to `astral-sh/setup-uv`, so we
|
||||
# open the PR ourselves. Idempotent: if a PR for the target version already
|
||||
# exists, the workflow exits without creating a duplicate.
|
||||
|
||||
name: "Bump uv pin"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 9 1 * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: bump-uv-pin
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
bump:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
name: "Open PR if uv has a newer release"
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Resolve current and latest uv versions
|
||||
id: versions
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_file=".github/actions/uv_setup/action.yml"
|
||||
current=$(grep -oE 'UV_VERSION: "[0-9]+\.[0-9]+\.[0-9]+"' "$action_file" \
|
||||
| sed -E 's/UV_VERSION: "([^"]+)"/\1/' | head -n1)
|
||||
latest=$(gh api repos/astral-sh/uv/releases/latest --jq .tag_name)
|
||||
semver='^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
if [[ ! "$current" =~ $semver ]]; then
|
||||
echo "::error::Could not parse current uv pin from $action_file (got '$current')"
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "$latest" =~ $semver ]]; then
|
||||
echo "::error::Unexpected uv tag from GitHub API (got '$latest')"
|
||||
exit 1
|
||||
fi
|
||||
echo "current=$current" >> "$GITHUB_OUTPUT"
|
||||
echo "latest=$latest" >> "$GITHUB_OUTPUT"
|
||||
echo "branch=chore/bump-uv-$latest" >> "$GITHUB_OUTPUT"
|
||||
echo "Current pin: $current"
|
||||
echo "Latest uv: $latest"
|
||||
|
||||
- name: Log if already up to date
|
||||
# The actual skip is implemented by the `if:` guards on every
|
||||
# subsequent step; this step only emits a log line so the run
|
||||
# history shows why no PR was opened.
|
||||
if: steps.versions.outputs.current == steps.versions.outputs.latest
|
||||
run: echo "uv pin already at ${{ steps.versions.outputs.latest }}; nothing to do."
|
||||
|
||||
- name: Skip if PR already open for this version
|
||||
id: existing
|
||||
if: steps.versions.outputs.current != steps.versions.outputs.latest
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
BRANCH: ${{ steps.versions.outputs.branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
count=$(gh pr list --head "$BRANCH" --state open --json number --jq 'length')
|
||||
echo "count=$count" >> "$GITHUB_OUTPUT"
|
||||
if [ "$count" -gt 0 ]; then
|
||||
echo "Open PR already exists for $BRANCH; skipping."
|
||||
fi
|
||||
|
||||
- name: Wait for astral mirror to replicate
|
||||
id: mirror
|
||||
if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0'
|
||||
env:
|
||||
LATEST: ${{ steps.versions.outputs.latest }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# The mirror can lag GitHub Releases. If it hasn't replicated yet,
|
||||
# defer the bump rather than landing a pin that races the mirror
|
||||
# on every CI run. We probe several arches because partial
|
||||
# replication (linux ready, macOS/aarch64 not) would still race
|
||||
# CI on other runners.
|
||||
assets=(
|
||||
"uv-x86_64-unknown-linux-gnu.tar.gz"
|
||||
"uv-aarch64-unknown-linux-gnu.tar.gz"
|
||||
"uv-x86_64-apple-darwin.tar.gz"
|
||||
"uv-aarch64-apple-darwin.tar.gz"
|
||||
)
|
||||
ready=true
|
||||
for asset in "${assets[@]}"; do
|
||||
url="https://releases.astral.sh/github/uv/releases/download/${LATEST}/${asset}"
|
||||
# `curl -sI` returns nothing on stderr at -s; capture exit code so a
|
||||
# permanently broken DNS/TLS path is surfaced instead of collapsing
|
||||
# to an opaque "000".
|
||||
set +e
|
||||
status=$(curl -sIo /dev/null -w '%{http_code}' --max-time 30 "$url" 2>/tmp/curl.err)
|
||||
curl_rc=$?
|
||||
set -e
|
||||
echo "Mirror HEAD $url -> HTTP $status (curl exit=$curl_rc)"
|
||||
if [ "$status" != "200" ]; then
|
||||
ready=false
|
||||
if [ "$curl_rc" -ne 0 ]; then
|
||||
echo "::warning::curl failed for $asset (exit=$curl_rc): $(cat /tmp/curl.err 2>/dev/null || true)"
|
||||
else
|
||||
echo "::warning::astral mirror has not replicated $asset for uv $LATEST yet (HTTP $status)."
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [ "$ready" = "true" ]; then
|
||||
echo "ready=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ready=false" >> "$GITHUB_OUTPUT"
|
||||
echo "::warning::Deferring uv bump to $LATEST until all probed arches are mirrored."
|
||||
fi
|
||||
|
||||
- name: Open bump PR
|
||||
if: steps.versions.outputs.current != steps.versions.outputs.latest && steps.existing.outputs.count == '0' && steps.mirror.outputs.ready == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
CURRENT: ${{ steps.versions.outputs.current }}
|
||||
LATEST: ${{ steps.versions.outputs.latest }}
|
||||
BRANCH: ${{ steps.versions.outputs.branch }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
action_file=".github/actions/uv_setup/action.yml"
|
||||
|
||||
# `grep -c` returns 1 on no-match and 2 on read errors. We want
|
||||
# "no match" surfaced as the explicit count-of-zero check below;
|
||||
# read errors must abort. Capture the exit code separately so
|
||||
# `set -e` doesn't swallow either case.
|
||||
set +e
|
||||
before=$(grep -cE "UV_VERSION: \"${CURRENT}\"" "$action_file")
|
||||
before_rc=$?
|
||||
set -e
|
||||
if [ "$before_rc" -gt 1 ]; then
|
||||
echo "::error::grep read error on $action_file (exit=$before_rc)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$before" -ne 1 ]; then
|
||||
echo "::error::Expected exactly 1 'UV_VERSION: \"$CURRENT\"' in $action_file, found $before"
|
||||
exit 1
|
||||
fi
|
||||
sed -i -E "s/UV_VERSION: \"${CURRENT}\"/UV_VERSION: \"${LATEST}\"/" "$action_file"
|
||||
set +e
|
||||
after=$(grep -cE "UV_VERSION: \"${LATEST}\"" "$action_file")
|
||||
after_rc=$?
|
||||
set -e
|
||||
if [ "$after_rc" -gt 1 ]; then
|
||||
echo "::error::grep read error on $action_file (exit=$after_rc)"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$after" -ne 1 ]; then
|
||||
echo "::error::Expected exactly 1 'UV_VERSION: \"$LATEST\"' after sed, found $after"
|
||||
exit 1
|
||||
fi
|
||||
if git diff --quiet "$action_file"; then
|
||||
echo "No changes after sed; bailing out (current=$CURRENT, latest=$LATEST)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Reuse-or-recreate orphan branch from a prior run that pushed
|
||||
# but failed before `gh pr create` (no open PR sits on it).
|
||||
# The delete can race a concurrent run (manual workflow_dispatch
|
||||
# firing while the cron is mid-flight, since concurrency group
|
||||
# does not cancel-in-progress); fall through with a warning so a
|
||||
# losing race does not kill an otherwise-clean job mid-state.
|
||||
if git ls-remote --exit-code --heads origin "$BRANCH" >/dev/null 2>&1; then
|
||||
echo "::warning::Branch $BRANCH exists on origin without an open PR; deleting before recreating."
|
||||
if ! git push origin --delete "$BRANCH"; then
|
||||
echo "::warning::Delete of $BRANCH failed (concurrent run, or branch already gone); the subsequent push will surface any real conflict."
|
||||
fi
|
||||
fi
|
||||
|
||||
git config --local user.name "github-actions[bot]"
|
||||
git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add "$action_file"
|
||||
git commit -m "chore(deps): bump uv to $LATEST"
|
||||
git push --set-upstream origin "$BRANCH"
|
||||
|
||||
body_file="$(mktemp)"
|
||||
{
|
||||
printf 'Bumps the uv pin in `.github/actions/uv_setup/action.yml` from `%s` to [`%s`](https://github.com/astral-sh/uv/releases/tag/%s).\n\n' "$CURRENT" "$LATEST" "$LATEST"
|
||||
printf 'Opened automatically by `bump_uv_pin.yml`. Mirror availability on `releases.astral.sh` was verified before this PR was created, so CI should not race the fallback.\n'
|
||||
} > "$body_file"
|
||||
|
||||
gh pr create \
|
||||
--head "$BRANCH" \
|
||||
--base "$DEFAULT_BRANCH" \
|
||||
--title "chore(deps): bump uv to $LATEST" \
|
||||
--body-file "$body_file"
|
||||
@@ -0,0 +1,43 @@
|
||||
# Ensures CLAUDE.md and AGENTS.md stay synchronized.
|
||||
#
|
||||
# These files contain the same development guidelines but are named differently
|
||||
# for compatibility with different AI coding assistants (Claude Code uses CLAUDE.md,
|
||||
# other tools may use AGENTS.md).
|
||||
|
||||
name: "🔄 Check CLAUDE.md / AGENTS.md Sync"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "CLAUDE.md"
|
||||
- "AGENTS.md"
|
||||
pull_request:
|
||||
paths:
|
||||
- "CLAUDE.md"
|
||||
- "AGENTS.md"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-sync:
|
||||
name: "verify files are identical"
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🔍 Check CLAUDE.md and AGENTS.md are in sync"
|
||||
run: |
|
||||
if ! diff -q CLAUDE.md AGENTS.md > /dev/null 2>&1; then
|
||||
echo "❌ CLAUDE.md and AGENTS.md are out of sync!"
|
||||
echo ""
|
||||
echo "These files must contain identical content."
|
||||
echo "Differences:"
|
||||
echo ""
|
||||
diff --color=always CLAUDE.md AGENTS.md || true
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ CLAUDE.md and AGENTS.md are in sync"
|
||||
@@ -0,0 +1,235 @@
|
||||
# Primary CI workflow.
|
||||
#
|
||||
# Only runs against packages that have changed files.
|
||||
#
|
||||
# Runs:
|
||||
# - Linting (_lint.yml)
|
||||
# - Unit Tests (_test.yml)
|
||||
# - Pydantic compatibility tests (_test_pydantic.yml)
|
||||
# - Integration test compilation checks (_compile_integration_test.yml)
|
||||
# - Extended test suites that require additional dependencies
|
||||
#
|
||||
# Reports status to GitHub checks and PR status.
|
||||
|
||||
name: "🔧 CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
merge_group:
|
||||
|
||||
# Optimizes CI performance by canceling redundant workflow runs
|
||||
# If another push to the same PR or branch happens while this workflow is still running,
|
||||
# cancel the earlier run in favor of the next run.
|
||||
#
|
||||
# There's no point in testing an outdated version of the code. GitHub only allows
|
||||
# a limited number of job runners to be active at the same time, so it's better to
|
||||
# cancel pointless jobs early so that more useful jobs can run sooner.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
UV_NO_SYNC: "true"
|
||||
|
||||
jobs:
|
||||
# This job analyzes which files changed and creates a dynamic test matrix
|
||||
# to only run tests/lints for the affected packages, improving CI efficiency
|
||||
build:
|
||||
name: "Detect Changes & Set Matrix"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !contains(github.event.pull_request.labels.*.name, 'ci-ignore') }}
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
- name: "🐍 Setup Python 3.11"
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: "📂 Get Changed Files"
|
||||
id: files
|
||||
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
|
||||
with:
|
||||
format: json
|
||||
- name: "🔍 Analyze Changed Files & Generate Build Matrix"
|
||||
id: set-matrix
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.files.outputs.all }}
|
||||
run: |
|
||||
python -m pip install packaging requests
|
||||
python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
lint: ${{ steps.set-matrix.outputs.lint }}
|
||||
test: ${{ steps.set-matrix.outputs.test }}
|
||||
extended-tests: ${{ steps.set-matrix.outputs.extended-tests }}
|
||||
compile-integration-tests: ${{ steps.set-matrix.outputs.compile-integration-tests }}
|
||||
dependencies: ${{ steps.set-matrix.outputs.dependencies }}
|
||||
test-pydantic: ${{ steps.set-matrix.outputs.test-pydantic }}
|
||||
vcr-tests: ${{ steps.set-matrix.outputs.vcr-tests }}
|
||||
# Run linting only on packages that have changed files
|
||||
lint:
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.lint != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.lint) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/_lint.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
python-version: ${{ matrix.job-configs.python-version }}
|
||||
secrets: inherit
|
||||
|
||||
# Run unit tests only on packages that have changed files
|
||||
test:
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.test != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.test) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/_test.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
python-version: ${{ matrix.job-configs.python-version }}
|
||||
secrets: inherit
|
||||
|
||||
# Test compatibility with different Pydantic versions for affected packages
|
||||
test-pydantic:
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.test-pydantic != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.test-pydantic) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/_test_pydantic.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
pydantic-version: ${{ matrix.job-configs.pydantic-version }}
|
||||
secrets: inherit
|
||||
|
||||
# Verify integration tests compile without actually running them (faster feedback)
|
||||
compile-integration-tests:
|
||||
name: "Compile Integration Tests"
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.compile-integration-tests != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.compile-integration-tests) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/_compile_integration_test.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
python-version: ${{ matrix.job-configs.python-version }}
|
||||
secrets: inherit
|
||||
|
||||
# Run VCR cassette-backed integration tests in playback-only mode (no API keys)
|
||||
vcr-tests:
|
||||
name: "VCR Cassette Tests"
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.vcr-tests != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.vcr-tests) }}
|
||||
fail-fast: false
|
||||
uses: ./.github/workflows/_test_vcr.yml
|
||||
with:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
python-version: ${{ matrix.job-configs.python-version }}
|
||||
secrets: inherit
|
||||
|
||||
# Run extended test suites that require additional dependencies
|
||||
extended-tests:
|
||||
name: "Extended Tests"
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.extended-tests != '[]' }}
|
||||
strategy:
|
||||
matrix:
|
||||
# note different variable for extended test dirs
|
||||
job-configs: ${{ fromJson(needs.build.outputs.extended-tests) }}
|
||||
fail-fast: false
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
defaults:
|
||||
run:
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python ${{ matrix.job-configs.python-version }} + UV"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.job-configs.python-version }}
|
||||
cache-suffix: extended-tests-${{ matrix.job-configs.working-directory }}
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
|
||||
- name: "📦 Install Dependencies & Run Extended Tests"
|
||||
shell: bash
|
||||
run: |
|
||||
echo "Running extended tests, installing dependencies with uv..."
|
||||
uv venv
|
||||
uv sync --group test
|
||||
VIRTUAL_ENV=.venv uv pip install -r extended_testing_deps.txt
|
||||
VIRTUAL_ENV=.venv make extended_tests
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
# Verify _release.yml dropdown options stay in sync with package directories
|
||||
check-release-options:
|
||||
name: "Validate Release Options"
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
- name: "🐍 Setup Python 3.11"
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: "📦 Install Dependencies"
|
||||
run: python -m pip install pyyaml pytest
|
||||
- name: "🔍 Check release dropdown matches packages"
|
||||
run: python -m pytest .github/scripts/test_release_options.py -v
|
||||
|
||||
# Final status check - ensures all required jobs passed before allowing merge
|
||||
ci_success:
|
||||
name: "✅ CI Success"
|
||||
needs:
|
||||
[
|
||||
build,
|
||||
lint,
|
||||
test,
|
||||
compile-integration-tests,
|
||||
vcr-tests,
|
||||
extended-tests,
|
||||
test-pydantic,
|
||||
check-release-options,
|
||||
]
|
||||
if: |
|
||||
always()
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
JOBS_JSON: ${{ toJSON(needs) }}
|
||||
RESULTS_JSON: ${{ toJSON(needs.*.result) }}
|
||||
EXIT_CODE: ${{!contains(needs.*.result, 'failure') && !contains(needs.*.result, 'cancelled') && '0' || '1'}}
|
||||
steps:
|
||||
- name: "🎉 All Checks Passed"
|
||||
run: |
|
||||
echo $JOBS_JSON
|
||||
echo $RESULTS_JSON
|
||||
echo "Exiting with $EXIT_CODE"
|
||||
exit $EXIT_CODE
|
||||
@@ -0,0 +1,73 @@
|
||||
# See `.github/scripts/check_extras_sync.py` for the rationale.
|
||||
|
||||
name: "🔍 Check Extras Sync"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "libs/**/pyproject.toml"
|
||||
- ".github/scripts/check_extras_sync.py"
|
||||
- ".github/workflows/check_extras_sync.yml"
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- "libs/**/pyproject.toml"
|
||||
- ".github/scripts/check_extras_sync.py"
|
||||
- ".github/workflows/check_extras_sync.yml"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-extras-sync:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
name: "Verify extras match required deps"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 2
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "🐍 Set up Python and uv"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: "3.13"
|
||||
enable-cache: "false"
|
||||
|
||||
- name: "🔍 Check extras sync"
|
||||
# Iterate every package pyproject.toml under libs/. The script
|
||||
# no-ops on packages without [project.optional-dependencies], so
|
||||
# this is harmless on packages without extras and automatically
|
||||
# picks up new partners as they're added. No `-maxdepth` cap so
|
||||
# deeper future restructures (e.g. `libs/partners/<group>/<pkg>/`)
|
||||
# are picked up automatically.
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t files < <(
|
||||
find libs -name pyproject.toml \
|
||||
-not -path "*/.venv/*" \
|
||||
-not -path "*/node_modules/*" \
|
||||
-not -path "*/build/*" \
|
||||
-not -path "*/dist/*" \
|
||||
-not -path "*/.tox/*" \
|
||||
| sort
|
||||
)
|
||||
if [ ${#files[@]} -eq 0 ]; then
|
||||
echo "::error::No pyproject.toml files found under libs/"
|
||||
exit 1
|
||||
fi
|
||||
failed=()
|
||||
for f in "${files[@]}"; do
|
||||
if ! python .github/scripts/check_extras_sync.py "$f"; then
|
||||
failed+=("$f")
|
||||
fi
|
||||
done
|
||||
if [ ${#failed[@]} -gt 0 ]; then
|
||||
echo "::error::Extras-sync check failed for ${#failed[@]} package(s):"
|
||||
printf '::error:: %s\n' "${failed[@]}"
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,288 @@
|
||||
# Validate that a release PR's declared dependencies are actually published on
|
||||
# PyPI *before* the package itself is released.
|
||||
#
|
||||
# WHY: `release(scope): x.y.z` PRs frequently bump intra-monorepo minimum pins
|
||||
# (e.g. `langchain-core>=1.4.4`). The regular PR test suite deliberately SKIPS
|
||||
# minimum-version resolution for langchain-core / langchain / langchain-text-splitters
|
||||
# (see `SKIP_IF_PULL_REQUEST` in `.github/scripts/get_min_versions.py`) because normal
|
||||
# feature PRs may bump those in lockstep with an as-yet-unpublished sibling release.
|
||||
#
|
||||
# For a `release` PR, though, every runtime dependency should already be on PyPI
|
||||
# unless that dependency is another package version introduced by the same PR.
|
||||
# If a pin points at any other version that does not exist yet, the published wheel's
|
||||
# metadata is unresolvable and `pip install <pkg>==x.y.z` breaks for end users. Without this
|
||||
# workflow, that is only caught at release-trigger time, when `get_min_versions.py`
|
||||
# resolves the pins against PyPI (its companion change in this PR now exits loudly on
|
||||
# an unpublished pin instead of emitting `pkg==None`). This workflow adds a second,
|
||||
# earlier guard: it shifts the same check left onto the release PR, so the author
|
||||
# finds out before merge rather than when the release job runs.
|
||||
#
|
||||
# HOW: for each changed manifest, diff it against the PR base to find packages whose
|
||||
# own version is bumped by this PR, then resolve each package's runtime dependencies
|
||||
# against real PyPI with `uv pip compile --no-sources` — which ignores the editable
|
||||
# `[tool.uv.sources]` workspace overrides so intra-monorepo deps resolve from the index
|
||||
# exactly as an end user's installer would see them. Dependencies that a same-PR version
|
||||
# bump satisfies are stripped first (their wheels are not published until merge);
|
||||
# everything else must resolve. This reads package index, git, and TOML metadata only —
|
||||
# it does not build or run the PR's own project code.
|
||||
|
||||
name: "🚀 Check Release Dependencies"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited, labeled, unlabeled]
|
||||
paths:
|
||||
- "libs/**/pyproject.toml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-release-deps:
|
||||
name: "✅ Verify release dependencies exist on PyPI"
|
||||
# Only run for release PRs (`release(scope): x.y.z`). Other PRs may bump
|
||||
# intra-monorepo pins ahead of a sibling release on purpose. Maintainers can
|
||||
# acknowledge an unusual coordinated release with the bypass label.
|
||||
if: >-
|
||||
github.repository_owner == 'langchain-ai' &&
|
||||
startsWith(github.event.pull_request.title, 'release') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'release-deps: acknowledged')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: "🐍 Set up Python + uv"
|
||||
uses: "./.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: "3.12"
|
||||
enable-cache: "false"
|
||||
|
||||
- name: "🔍 Resolve runtime dependencies against PyPI"
|
||||
shell: bash
|
||||
env:
|
||||
# Sourced from env so the `${{ }}` expansion never lands in the `run:`
|
||||
# block; the SHAs reach git only via list-form subprocess (no shell).
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# pyproject.toml manifests changed by this PR.
|
||||
mapfile -t changed < <(
|
||||
git diff --name-only "$BASE_SHA" "$HEAD_SHA" -- 'libs/**/pyproject.toml'
|
||||
)
|
||||
|
||||
if [ "${#changed[@]}" -eq 0 ]; then
|
||||
# The `paths:` filter should prevent this, so an empty list is
|
||||
# surprising — surface it loudly rather than passing silently.
|
||||
echo "::notice::No libs/**/pyproject.toml changed in this PR; nothing to validate."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
tmp_dir="$(mktemp -d)"
|
||||
trap 'rm -rf "$tmp_dir"' EXIT
|
||||
|
||||
# Step 1: detect packages whose own version is bumped by this PR. Their
|
||||
# wheels are not on PyPI until merge, so dependencies the new version
|
||||
# satisfies are stripped before resolving. Emits one TSV row per bump:
|
||||
# `<canonical-name>\t<name>\t<new-version>`. `uv run --with packaging`
|
||||
# guarantees the PEP 508/440 parser is available on the runner.
|
||||
printf '%s\n' "${changed[@]}" > "$tmp_dir/changed.txt"
|
||||
uv run -q --no-project --with packaging python - "$BASE_SHA" "$tmp_dir/changed.txt" "$tmp_dir/released.txt" <<'PY'
|
||||
import subprocess
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
base_sha = sys.argv[1]
|
||||
changed_path = Path(sys.argv[2])
|
||||
released_path = Path(sys.argv[3])
|
||||
|
||||
def project_table(text: str) -> dict:
|
||||
return tomllib.loads(text).get("project") or {}
|
||||
|
||||
released = []
|
||||
for manifest in changed_path.read_text(encoding="utf-8").splitlines():
|
||||
current = project_table(Path(manifest).read_text(encoding="utf-8"))
|
||||
name, version = current.get("name"), current.get("version")
|
||||
if name is None or version is None:
|
||||
# No static name/version (e.g. a dynamic version) — it cannot be
|
||||
# compared against the base, so cannot be a same-PR bump.
|
||||
print(f"::warning file={manifest}::no static project.name/version; skipping bump detection")
|
||||
continue
|
||||
shown = subprocess.run(
|
||||
["git", "show", f"{base_sha}:{manifest}"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if shown.returncode != 0:
|
||||
stderr = shown.stderr.strip()
|
||||
if "does not exist" in stderr or "exists on disk, but not in" in stderr:
|
||||
# New manifest: absent at the base ref, so not a version bump.
|
||||
continue
|
||||
print(f"::error file={manifest}::failed to read base manifest: {stderr}")
|
||||
sys.exit(1)
|
||||
base = project_table(shown.stdout)
|
||||
if base.get("name") == name and base.get("version") not in (None, version):
|
||||
released.append(f"{canonicalize_name(name)}\t{name}\t{version}")
|
||||
|
||||
released_path.write_text("".join(f"{row}\n" for row in released), encoding="utf-8")
|
||||
PY
|
||||
|
||||
if [ -s "$tmp_dir/released.txt" ]; then
|
||||
echo "The following package versions are introduced by this PR and may be referenced before PyPI publication:"
|
||||
cut -f2- "$tmp_dir/released.txt" | sed 's/^/ - /'
|
||||
else
|
||||
echo "No package version bumps found in changed manifests."
|
||||
fi
|
||||
|
||||
failed=0
|
||||
transient=0
|
||||
for manifest in "${changed[@]}"; do
|
||||
pkg_dir="$(dirname "$manifest")"
|
||||
filtered_dir="$tmp_dir/${manifest//\//__}.dir"
|
||||
mkdir -p "$filtered_dir"
|
||||
filtered_manifest="$filtered_dir/pyproject.toml"
|
||||
# Step 2: rebuild a resolver-equivalent manifest that drops only the
|
||||
# dependencies a same-PR version bump satisfies. `[tool.uv]` keys that
|
||||
# affect resolution (prerelease, constraint/override deps) are preserved
|
||||
# — e.g. `langchain-fireworks` needs `prerelease = "allow"` to resolve
|
||||
# its prerelease-only `fireworks-ai` pin. Skipped deps print here, before
|
||||
# the resolver group opens, so the exclusions stay visible on a green run.
|
||||
uv run -q --no-project --with packaging python - "$manifest" "$filtered_manifest" "$tmp_dir/released.txt" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
|
||||
from packaging.requirements import InvalidRequirement, Requirement
|
||||
from packaging.utils import canonicalize_name
|
||||
|
||||
manifest_path = Path(sys.argv[1])
|
||||
filtered_path = Path(sys.argv[2])
|
||||
released_path = Path(sys.argv[3])
|
||||
|
||||
released_versions = {}
|
||||
for line in released_path.read_text(encoding="utf-8").splitlines():
|
||||
canonical_name, _name, version = line.split("\t", maxsplit=2)
|
||||
released_versions[canonical_name] = version
|
||||
|
||||
def is_same_pr_bump(dependency: str) -> bool:
|
||||
try:
|
||||
requirement = Requirement(dependency)
|
||||
except InvalidRequirement:
|
||||
# Keep anything we cannot parse so the resolver judges it.
|
||||
return False
|
||||
version = released_versions.get(canonicalize_name(requirement.name))
|
||||
if version is None:
|
||||
return False
|
||||
# Strip only when the just-bumped version satisfies the pin; a pin to
|
||||
# any other (still unpublished) version must keep resolving.
|
||||
return requirement.specifier.contains(version, prereleases=True)
|
||||
|
||||
data = tomllib.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
project = data.get("project")
|
||||
if project is None:
|
||||
print(f"::error file={manifest_path}::no [project] table to resolve")
|
||||
sys.exit(1)
|
||||
|
||||
filtered_dependencies, skipped_dependencies = [], []
|
||||
for dependency in project.get("dependencies", []):
|
||||
bucket = skipped_dependencies if is_same_pr_bump(dependency) else filtered_dependencies
|
||||
bucket.append(dependency)
|
||||
|
||||
def toml_string(value: str) -> str:
|
||||
return json.dumps(value)
|
||||
|
||||
lines = ["[project]"]
|
||||
lines.append(f"name = {toml_string(project.get('name') or 'release-deps-check')}")
|
||||
lines.append(f"version = {toml_string(project.get('version') or '0.0.0')}")
|
||||
if "requires-python" in project:
|
||||
lines.append(f"requires-python = {toml_string(project['requires-python'])}")
|
||||
lines.append("dependencies = [")
|
||||
lines += [f" {toml_string(dependency)}," for dependency in filtered_dependencies]
|
||||
lines.append("]")
|
||||
|
||||
# Preserve the `[tool.uv]` keys that change PyPI resolution. `[tool.uv.sources]`
|
||||
# is intentionally dropped — `uv pip compile --no-sources` ignores it anyway.
|
||||
tool_uv = (data.get("tool") or {}).get("uv") or {}
|
||||
uv_lines = []
|
||||
if isinstance(tool_uv.get("prerelease"), str):
|
||||
uv_lines.append(f"prerelease = {toml_string(tool_uv['prerelease'])}")
|
||||
for key in ("constraint-dependencies", "override-dependencies"):
|
||||
values = tool_uv.get(key) or []
|
||||
if values:
|
||||
uv_lines.append(f"{key} = [")
|
||||
uv_lines += [f" {toml_string(value)}," for value in values]
|
||||
uv_lines.append("]")
|
||||
if uv_lines:
|
||||
lines.append("")
|
||||
lines.append("[tool.uv]")
|
||||
lines += uv_lines
|
||||
|
||||
filtered_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||
|
||||
if skipped_dependencies:
|
||||
print("Ignoring dependencies satisfied by package versions introduced by this PR:")
|
||||
for dependency in skipped_dependencies:
|
||||
print(f" - {dependency}")
|
||||
PY
|
||||
|
||||
echo "::group::Resolving ${manifest} against PyPI"
|
||||
# --no-sources ignores [tool.uv.sources] editable workspace overrides,
|
||||
# so intra-monorepo deps resolve from PyPI like an end-user install.
|
||||
# --universal resolves across the full requires-python range, so deps
|
||||
# gated behind Python-version markers are validated too.
|
||||
if uv pip compile --no-sources --universal "$filtered_manifest" > "$filtered_dir/compile.log" 2>&1; then
|
||||
echo "✅ ${pkg_dir}: all runtime dependencies resolve on PyPI or are released by this PR"
|
||||
else
|
||||
# Surface the resolver's reason (stdout+stderr were captured) and tell a
|
||||
# likely-transient index/network error apart from a genuinely bad pin.
|
||||
cat "$filtered_dir/compile.log"
|
||||
if grep -qiE 'error sending request|failed to fetch|error trying to connect|connection|timed out|temporarily unavailable|status code (429|50[0-9])' "$filtered_dir/compile.log"; then
|
||||
echo "❌ ${pkg_dir}: resolver hit a possible transient PyPI/index error"
|
||||
transient=1
|
||||
else
|
||||
echo "❌ ${pkg_dir}: a dependency pin is not satisfiable on PyPI"
|
||||
fi
|
||||
failed=1
|
||||
fi
|
||||
echo "::endgroup::"
|
||||
done
|
||||
|
||||
if [ "$failed" -ne 0 ]; then
|
||||
if [ "$transient" -ne 0 ]; then
|
||||
echo "::warning::A failure looked like a network/index error rather than an unsatisfiable pin — re-running the job may clear it."
|
||||
fi
|
||||
cat >&2 <<'EOF'
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ One or more dependency pins could not be resolved from PyPI. │
|
||||
│ See the per-package resolver output above for the exact reason. │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
|
||||
Dependencies on package versions introduced by this PR are ignored,
|
||||
because coordinated release metadata may point at wheels that are not
|
||||
published until this PR merges. Any remaining failure means the released
|
||||
wheel metadata may be unresolvable for end users.
|
||||
|
||||
Fix by:
|
||||
• Releasing the dependency package first so the pinned version exists
|
||||
on PyPI, then re-running this check; or
|
||||
• Relaxing the version pin to a published version.
|
||||
|
||||
If this is an intentional coordinated release outside the detected
|
||||
package version bumps, a maintainer may add the label
|
||||
`release-deps: acknowledged` to bypass this check after reviewing the
|
||||
install risk.
|
||||
|
||||
If the resolver output above shows a network/index error (rather than
|
||||
"No solution found"), this may be a transient PyPI issue — re-run the job.
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,55 @@
|
||||
# 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:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # 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
|
||||
@@ -0,0 +1,197 @@
|
||||
# Auto-close issues that bypass or ignore the issue template checkboxes.
|
||||
#
|
||||
# GitHub issue forms enforce `required: true` checkboxes in the web UI,
|
||||
# but the API bypasses form validation entirely — bots/scripts can open
|
||||
# issues with every box unchecked or skip the template altogether.
|
||||
#
|
||||
# Rules:
|
||||
# 0. No issue type -> close unless author is an org member
|
||||
# 1. No checkboxes at all -> close unless author is an org member or bot
|
||||
# 2. Checkboxes present but none checked -> close
|
||||
# 3. "Submission checklist" section incomplete -> close
|
||||
# 4. "Package (Required)" section has no selection -> close
|
||||
#
|
||||
# Org membership check reuses the shared helper from pr-labeler.js and
|
||||
# the same GitHub App used by tag-external-issues.yml.
|
||||
|
||||
name: Close Unchecked Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-boxes:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Validate issue checkboxes
|
||||
if: steps.app-token.outcome == 'success'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
const body = context.payload.issue.body ?? '';
|
||||
const allChecked = (body.match(/- \[x\]/gi) || []).length;
|
||||
const allUnchecked = (body.match(/- \[ \]/g) || []).length;
|
||||
const total = allChecked + allUnchecked;
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────
|
||||
// Extract checkboxes under a markdown H2/H3 heading.
|
||||
// Returns { checked, unchecked } counts, or null if the
|
||||
// section heading is not found in the body.
|
||||
function parseSection(heading) {
|
||||
const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
// Find the heading line
|
||||
const headingRe = new RegExp(`^#{2,3}\\s+${escaped}\\s*$`, 'm');
|
||||
const headingMatch = headingRe.exec(body);
|
||||
if (!headingMatch) return null;
|
||||
// Slice from after the heading to the next heading or end
|
||||
const rest = body.slice(headingMatch.index + headingMatch[0].length);
|
||||
const nextHeading = rest.search(/\n#{2,3}\s/);
|
||||
const block = nextHeading === -1 ? rest : rest.slice(0, nextHeading);
|
||||
return {
|
||||
checked: (block.match(/- \[x\]/gi) || []).length,
|
||||
unchecked: (block.match(/- \[ \]/g) || []).length,
|
||||
};
|
||||
}
|
||||
|
||||
let _cachedMember;
|
||||
async function isOrgMember() {
|
||||
if (_cachedMember) return _cachedMember;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js')
|
||||
.loadAndInit(github, owner, repo, core);
|
||||
const author = context.payload.sender.login;
|
||||
const { isExternal } = await h.checkMembership(
|
||||
author, context.payload.sender.type,
|
||||
);
|
||||
_cachedMember = { internal: !isExternal, author };
|
||||
return _cachedMember;
|
||||
}
|
||||
|
||||
async function closeWithComment(lines) {
|
||||
const templateUrl = `https://github.com/${owner}/${repo}/issues/new/choose`;
|
||||
lines.push(
|
||||
'',
|
||||
`Please use one of the [issue templates](${templateUrl}).`,
|
||||
);
|
||||
|
||||
// Post comment first so the author sees the reason even if
|
||||
// the subsequent close call fails.
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number,
|
||||
body: lines.join('\n'),
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner, repo, issue_number,
|
||||
state: 'closed',
|
||||
state_reason: 'not_planned',
|
||||
});
|
||||
}
|
||||
|
||||
// ── Rule 0: no issue type (API/CLI bypass) ──────────────────
|
||||
// Issue types are set automatically when using web UI templates.
|
||||
// External users cannot set issue types via the API (requires
|
||||
// write/triage permissions), so a missing type reliably indicates
|
||||
// programmatic submission.
|
||||
if (!context.payload.issue.type) {
|
||||
let membership;
|
||||
try {
|
||||
membership = await isOrgMember();
|
||||
} catch (e) {
|
||||
// Org membership check failed — skip Rule 0 and let
|
||||
// Rules 1-4 handle validation via checkboxes.
|
||||
core.warning(`Rule 0: org membership check failed, skipping: ${e.message}`);
|
||||
}
|
||||
if (membership?.internal) {
|
||||
console.log(`No issue type, but ${membership.author} is internal — OK`);
|
||||
} else if (membership) {
|
||||
console.log(`No issue type and ${membership.author} is external — closing`);
|
||||
await closeWithComment([
|
||||
'This issue was automatically closed because it appears to have been submitted programmatically — issue types are automatically set when using the GitHub web interface, and this issue has none.',
|
||||
'',
|
||||
'We do not allow automated issue submission at this time.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Rule 1: no checkboxes at all ────────────────────────────
|
||||
if (total === 0) {
|
||||
const { internal, author } = await isOrgMember();
|
||||
if (internal) {
|
||||
console.log(`No checkboxes, but ${author} is internal — OK`);
|
||||
return;
|
||||
}
|
||||
console.log(`No checkboxes and ${author} is external — closing`);
|
||||
await closeWithComment([
|
||||
'This issue was automatically closed because no issue template was used.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Rule 2: checkboxes present but none checked ─────────────
|
||||
if (allChecked === 0) {
|
||||
console.log(`${allUnchecked} checkbox(es) present, none checked — closing`);
|
||||
await closeWithComment([
|
||||
'This issue was automatically closed because none of the required checkboxes were checked. Please re-file using an issue template and complete the checklist.',
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Rules 3–4: parse sections for targeted feedback ─────────
|
||||
const checklist = parseSection('Submission checklist');
|
||||
const pkg = parseSection('Package (Required)');
|
||||
console.log(`Section parse — checklist: ${JSON.stringify(checklist)}, pkg: ${JSON.stringify(pkg)}`);
|
||||
|
||||
const problems = [];
|
||||
|
||||
if (checklist && checklist.unchecked > 0) {
|
||||
problems.push(
|
||||
'the submission checklist is incomplete — please confirm you searched for duplicates, included a reproduction, etc.'
|
||||
);
|
||||
}
|
||||
if (pkg !== null && pkg.checked === 0) {
|
||||
problems.push(
|
||||
'no package was selected (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team'
|
||||
);
|
||||
} else if (pkg === null) {
|
||||
problems.push(
|
||||
'the package selection is missing (e.g. langchain-core, langchain, langgraph) — this helps us route the issue to the right team'
|
||||
);
|
||||
}
|
||||
|
||||
if (problems.length === 0) {
|
||||
console.log(`All section checks passed (${allChecked} checked) — OK`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Closing — problems: ${problems.join('; ')}`);
|
||||
await closeWithComment([
|
||||
'Thanks for opening an issue! It was automatically closed because:',
|
||||
'',
|
||||
...problems.map(p => `- ${p}`),
|
||||
]);
|
||||
@@ -0,0 +1,85 @@
|
||||
# CodSpeed performance benchmarks.
|
||||
#
|
||||
# Runs benchmarks on changed packages and uploads results to CodSpeed.
|
||||
# Separated from the main CI workflow so that push-to-master baseline runs
|
||||
# are never cancelled by subsequent merges (cancel-in-progress is only
|
||||
# enabled for pull_request events).
|
||||
|
||||
name: "⚡ CodSpeed"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
|
||||
# On PRs, cancel stale runs when new commits are pushed.
|
||||
# On push-to-master, never cancel — these runs populate CodSpeed baselines.
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event_name == 'push' && github.sha || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
UV_NO_SYNC: "true"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: "Detect Changes"
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'langchain-ai' && !contains(github.event.pull_request.labels.*.name, 'codspeed-ignore') }}
|
||||
steps:
|
||||
- name: "📋 Checkout Code"
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
- name: "🐍 Setup Python 3.11"
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.11"
|
||||
- name: "📂 Get Changed Files"
|
||||
id: files
|
||||
uses: Ana06/get-changed-files@25f79e676e7ea1868813e21465014798211fad8c # v2.3.0
|
||||
with:
|
||||
format: json
|
||||
- name: "🔍 Analyze Changed Files"
|
||||
id: set-matrix
|
||||
env:
|
||||
ALL_CHANGED_FILES: ${{ steps.files.outputs.all }}
|
||||
run: |
|
||||
python -m pip install packaging requests
|
||||
python .github/scripts/check_diff.py "$ALL_CHANGED_FILES" >> $GITHUB_OUTPUT
|
||||
outputs:
|
||||
codspeed: ${{ steps.set-matrix.outputs.codspeed }}
|
||||
|
||||
benchmarks:
|
||||
name: "⚡ CodSpeed Benchmarks"
|
||||
needs: [build]
|
||||
if: ${{ needs.build.outputs.codspeed != '[]' }}
|
||||
runs-on: codspeed-macro
|
||||
strategy:
|
||||
matrix:
|
||||
job-configs: ${{ fromJson(needs.build.outputs.codspeed) }}
|
||||
fail-fast: false
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: "📦 Install UV Package Manager"
|
||||
uses: astral-sh/setup-uv@0ca8f610542aa7f4acaf39e65cf4eb3c35091883 # v7
|
||||
with:
|
||||
# Pinned to 3.13.11 to work around CodSpeed walltime segfault on 3.13.12+
|
||||
# See: https://github.com/CodSpeedHQ/pytest-codspeed/issues/106
|
||||
python-version: "3.13.11"
|
||||
|
||||
- name: "📦 Install Test Dependencies"
|
||||
run: uv sync --group test
|
||||
working-directory: ${{ matrix.job-configs.working-directory }}
|
||||
|
||||
- name: "⚡ Run Benchmarks: ${{ matrix.job-configs.working-directory }}"
|
||||
uses: CodSpeedHQ/action@a50965600eafa04edcd6717761f55b77e52aafbd # v4
|
||||
with:
|
||||
token: ${{ secrets.CODSPEED_TOKEN }}
|
||||
run: |
|
||||
cd ${{ matrix.job-configs.working-directory }}
|
||||
uv run --no-sync pytest ./tests/benchmarks --codspeed
|
||||
mode: ${{ matrix.job-configs.codspeed-mode }}
|
||||
@@ -0,0 +1,408 @@
|
||||
# Routine integration tests against partner libraries with live API credentials.
|
||||
#
|
||||
# Uses `make integration_tests` within each library being tested.
|
||||
#
|
||||
# Runs daily with the option to trigger manually.
|
||||
|
||||
name: "⏰ Integration Tests"
|
||||
run-name: "Run Integration Tests - ${{ inputs.working-directory-override || (inputs.working-directory != 'all' && inputs.working-directory) || (inputs.exclude != '' && format('exclude:{0}', inputs.exclude)) || 'all libs' }} (Python ${{ inputs.python-version-override || '3.10, 3.14' }})"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
working-directory:
|
||||
type: choice
|
||||
description: "Library to test (select from dropdown)"
|
||||
default: "all"
|
||||
# Short names only — the `compute-matrix` job re-adds the `libs/` or
|
||||
# `libs/partners/` prefix. When adding a new option, also update the
|
||||
# `case` statement in `compute-matrix` if it isn't a partner package
|
||||
# (partners are the default branch).
|
||||
options:
|
||||
- "all"
|
||||
- "core"
|
||||
- "langchain"
|
||||
- "langchain_v1"
|
||||
- "text-splitters"
|
||||
- "standard-tests"
|
||||
- "model-profiles"
|
||||
- "anthropic"
|
||||
- "aws"
|
||||
- "chroma"
|
||||
- "deepseek"
|
||||
- "exa"
|
||||
- "fireworks"
|
||||
- "google-genai"
|
||||
- "google-vertexai"
|
||||
- "groq"
|
||||
- "huggingface"
|
||||
- "mistralai"
|
||||
- "nomic"
|
||||
- "ollama"
|
||||
- "openai"
|
||||
- "openrouter"
|
||||
- "perplexity"
|
||||
- "qdrant"
|
||||
- "xai"
|
||||
working-directory-override:
|
||||
type: string
|
||||
description: "Manual override — takes precedence over dropdown (e.g. libs/partners/partner-xyz)"
|
||||
exclude:
|
||||
type: string
|
||||
description: "Comma-separated short names to drop from the 'all' run (e.g. openai,anthropic). Ignored unless dropdown is 'all' and no working-directory-override is set."
|
||||
python-version-override:
|
||||
type: string
|
||||
description: "Python version override — defaults to 3.10 and 3.14 in matrix (e.g. 3.11)"
|
||||
schedule:
|
||||
- cron: "0 13 * * *" # Runs daily at 1PM UTC (9AM EDT/6AM PDT)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
UV_FROZEN: "true"
|
||||
DEFAULT_LIBS: >-
|
||||
["libs/partners/openai",
|
||||
"libs/partners/anthropic",
|
||||
"libs/partners/fireworks",
|
||||
"libs/partners/groq",
|
||||
"libs/partners/mistralai",
|
||||
"libs/partners/xai",
|
||||
"libs/partners/google-vertexai",
|
||||
"libs/partners/google-genai",
|
||||
"libs/partners/aws"]
|
||||
|
||||
jobs:
|
||||
# Generate dynamic test matrix based on input parameters or defaults
|
||||
# Only runs on the main repo (for scheduled runs) or when manually triggered
|
||||
compute-matrix:
|
||||
# Defend against forks running scheduled jobs, but allow manual runs from forks
|
||||
if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'
|
||||
|
||||
runs-on: ubuntu-latest
|
||||
name: "📋 Compute Test Matrix"
|
||||
outputs:
|
||||
matrix: ${{ steps.set-matrix.outputs.matrix }}
|
||||
python-version-min-3-11: ${{ steps.set-matrix.outputs.python-version-min-3-11 }}
|
||||
steps:
|
||||
- name: "🔢 Generate Python & Library Matrix"
|
||||
id: set-matrix
|
||||
env:
|
||||
DEFAULT_LIBS: ${{ env.DEFAULT_LIBS }}
|
||||
WORKING_DIRECTORY_OVERRIDE: ${{ github.event.inputs.working-directory-override || '' }}
|
||||
WORKING_DIRECTORY_CHOICE: ${{ github.event.inputs.working-directory || 'all' }}
|
||||
PYTHON_VERSION_OVERRIDE: ${{ github.event.inputs.python-version-override || '' }}
|
||||
EXCLUDE: ${{ github.event.inputs.exclude || '' }}
|
||||
run: |
|
||||
# echo "matrix=..." where matrix is a json formatted str with keys python-version and working-directory
|
||||
# python-version defaults to 3.10 and 3.14, overridden to [PYTHON_VERSION_OVERRIDE] if set
|
||||
# working-directory priority: override string > dropdown choice > DEFAULT_LIBS
|
||||
python_version='["3.10", "3.14"]'
|
||||
python_version_min_3_11='["3.11", "3.14"]'
|
||||
working_directory="$DEFAULT_LIBS"
|
||||
if [ -n "$PYTHON_VERSION_OVERRIDE" ]; then
|
||||
python_version="[\"$PYTHON_VERSION_OVERRIDE\"]"
|
||||
# Bound override version to >= 3.11 for packages requiring it
|
||||
if [ "$(echo "$PYTHON_VERSION_OVERRIDE >= 3.11" | bc -l)" -eq 1 ]; then
|
||||
python_version_min_3_11="[\"$PYTHON_VERSION_OVERRIDE\"]"
|
||||
else
|
||||
python_version_min_3_11='["3.11"]'
|
||||
fi
|
||||
fi
|
||||
if [ -n "$WORKING_DIRECTORY_OVERRIDE" ]; then
|
||||
working_directory="[\"$WORKING_DIRECTORY_OVERRIDE\"]"
|
||||
elif [ "$WORKING_DIRECTORY_CHOICE" != "all" ]; then
|
||||
# Map short dropdown name back to full path
|
||||
case "$WORKING_DIRECTORY_CHOICE" in
|
||||
core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles)
|
||||
working_directory="[\"libs/$WORKING_DIRECTORY_CHOICE\"]"
|
||||
;;
|
||||
*)
|
||||
working_directory="[\"libs/partners/$WORKING_DIRECTORY_CHOICE\"]"
|
||||
;;
|
||||
esac
|
||||
elif [ -n "$EXCLUDE" ]; then
|
||||
# Only honored on the 'all' run (no override, dropdown left at 'all').
|
||||
# Map each comma-separated short name to its full path (mirroring the
|
||||
# case statement above), then subtract from the DEFAULT_LIBS array.
|
||||
exclude_paths='[]'
|
||||
IFS=',' read -ra exclude_names <<< "$EXCLUDE"
|
||||
for name in "${exclude_names[@]}"; do
|
||||
# Trim surrounding whitespace so "openai, anthropic" works.
|
||||
name="${name#"${name%%[![:space:]]*}"}" # ltrim
|
||||
name="${name%"${name##*[![:space:]]}"}" # rtrim
|
||||
[ -z "$name" ] && continue
|
||||
case "$name" in
|
||||
core|langchain|langchain_v1|text-splitters|standard-tests|model-profiles)
|
||||
path="libs/$name"
|
||||
;;
|
||||
*)
|
||||
path="libs/partners/$name"
|
||||
;;
|
||||
esac
|
||||
exclude_paths="$(jq -nc --argjson acc "$exclude_paths" --arg path "$path" '$acc + [$path]')"
|
||||
done
|
||||
working_directory="$(jq -nc --argjson libs "$working_directory" --argjson excl "$exclude_paths" '$libs - $excl')"
|
||||
fi
|
||||
matrix="{\"python-version\": $python_version, \"working-directory\": $working_directory}"
|
||||
echo "$matrix"
|
||||
echo "matrix=$matrix" >> $GITHUB_OUTPUT
|
||||
echo "python-version-min-3-11=$python_version_min_3_11" >> $GITHUB_OUTPUT
|
||||
|
||||
# Run integration tests against partner libraries with live API credentials
|
||||
integration-tests:
|
||||
if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'
|
||||
name: "🐍 Python ${{ matrix.python-version }}: ${{ matrix.working-directory }}"
|
||||
runs-on: ubuntu-latest
|
||||
# Scopes LangSmith tracing credentials (and any other env-scoped secrets)
|
||||
environment: "Scheduled testing"
|
||||
needs: [compute-matrix]
|
||||
timeout-minutes: 30
|
||||
# Serialize same-package shards across workflow runs so a per-package
|
||||
# manual dispatch doesn't race the scheduled "all libs" run against the
|
||||
# same live API credentials. Keyed per (working-directory, python-version)
|
||||
# so the 3.10/3.14 matrix legs within one run still execute in parallel.
|
||||
concurrency:
|
||||
group: integration-tests-${{ matrix.working-directory }}-${{ matrix.python-version }}
|
||||
cancel-in-progress: false
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ${{ fromJSON(needs.compute-matrix.outputs.matrix).python-version }}
|
||||
working-directory: ${{ fromJSON(needs.compute-matrix.outputs.matrix).working-directory }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
path: langchain
|
||||
|
||||
# These libraries exist outside of the monorepo and need to be checked out separately
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: langchain-ai/langchain-google
|
||||
path: langchain-google
|
||||
- name: "🔐 Authenticate to Google Cloud"
|
||||
id: "auth"
|
||||
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
|
||||
with:
|
||||
credentials_json: "${{ secrets.GOOGLE_CREDENTIALS }}"
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: langchain-ai/langchain-aws
|
||||
path: langchain-aws
|
||||
- name: "🔐 Configure AWS Credentials"
|
||||
uses: aws-actions/configure-aws-credentials@254c19bd240aabef8777f48595e9d2d7b972184b # v6
|
||||
with:
|
||||
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
|
||||
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
|
||||
aws-region: ${{ secrets.AWS_REGION }}
|
||||
- name: "📦 Organize External Libraries"
|
||||
run: |
|
||||
rm -rf \
|
||||
langchain/libs/partners/google-genai \
|
||||
langchain/libs/partners/google-vertexai
|
||||
mv langchain-google/libs/genai langchain/libs/partners/google-genai
|
||||
mv langchain-google/libs/vertexai langchain/libs/partners/google-vertexai
|
||||
mv langchain-aws/libs/aws langchain/libs/partners/aws
|
||||
|
||||
- name: "🐍 Set up Python ${{ matrix.python-version }} + UV"
|
||||
uses: "./langchain/.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: "📦 Install Dependencies"
|
||||
# Partner packages use [tool.uv.sources] in their pyproject.toml to resolve
|
||||
# langchain-core/langchain to local editable installs, so `uv sync` automatically
|
||||
# tests against the versions from the current branch (not published releases).
|
||||
#
|
||||
# External google/aws packages live in separate repos and don't declare
|
||||
# [tool.uv.sources], so `uv sync` pulls langchain-* from PyPI. Overlay
|
||||
# local editable installs after sync so integration tests exercise the
|
||||
# current branch's langchain code. Matches the pattern used by the
|
||||
# `test-dependents` job below for deepagents.
|
||||
run: |
|
||||
echo "Running scheduled tests, installing dependencies with uv..."
|
||||
cd langchain/${{ matrix.working-directory }}
|
||||
uv sync --group test --group test_integration
|
||||
|
||||
case "${{ matrix.working-directory }}" in
|
||||
libs/partners/google-genai)
|
||||
uv pip install \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/core \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests
|
||||
;;
|
||||
libs/partners/google-vertexai)
|
||||
uv pip install \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/core \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests
|
||||
;;
|
||||
libs/partners/aws)
|
||||
uv pip install \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/core \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/langchain_v1 \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/langchain \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/standard-tests \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/partners/anthropic
|
||||
;;
|
||||
esac
|
||||
|
||||
- name: "🧾 Build LangSmith Metadata"
|
||||
# GHA expression values flow through intermediate env vars (injection
|
||||
# hardening) and jq -nc builds the JSON, so quotes/newlines in any
|
||||
# field can't corrupt the payload.
|
||||
env:
|
||||
GH_SHA: ${{ github.sha }}
|
||||
GH_RUN_ID: ${{ github.run_id }}
|
||||
GH_RUN_ATTEMPT: ${{ github.run_attempt }}
|
||||
GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_WORKFLOW: ${{ github.workflow }}
|
||||
GH_EVENT: ${{ github.event_name }}
|
||||
GH_REF: ${{ github.ref }}
|
||||
WORKING_DIRECTORY: ${{ matrix.working-directory }}
|
||||
PYTHON_VERSION: ${{ matrix.python-version }}
|
||||
run: |
|
||||
metadata=$(jq -nc \
|
||||
--arg github_sha "$GH_SHA" \
|
||||
--arg github_run_id "$GH_RUN_ID" \
|
||||
--arg github_run_attempt "$GH_RUN_ATTEMPT" \
|
||||
--arg github_run_url "$GH_RUN_URL" \
|
||||
--arg github_workflow "$GH_WORKFLOW" \
|
||||
--arg github_event "$GH_EVENT" \
|
||||
--arg github_ref "$GH_REF" \
|
||||
--arg working_directory "$WORKING_DIRECTORY" \
|
||||
--arg python_version "$PYTHON_VERSION" \
|
||||
'{github_sha: $github_sha, github_run_id: $github_run_id, github_run_attempt: $github_run_attempt, github_run_url: $github_run_url, github_workflow: $github_workflow, github_event: $github_event, github_ref: $github_ref, working_directory: $working_directory, python_version: $python_version}')
|
||||
echo "LANGSMITH_METADATA=$metadata" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "🚀 Run Integration Tests"
|
||||
# WARNING: All secrets below are available to every matrix job regardless of
|
||||
# which package is being tested. This is intentional for simplicity, but means
|
||||
# any test file could technically access any key. Only use for trusted code.
|
||||
env:
|
||||
LANGCHAIN_TESTS_USER_AGENT: ${{ secrets.LANGCHAIN_TESTS_USER_AGENT }}
|
||||
|
||||
# Route traces to one project with GitHub run metadata so failures link back to the originating Actions run.
|
||||
LANGSMITH_TRACING: "true"
|
||||
LANGSMITH_API_KEY: ${{ secrets.LANGSMITH_API_KEY }}
|
||||
LANGSMITH_PROJECT: ${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}
|
||||
LANGSMITH_TAGS: "github-actions,${{ matrix.working-directory }},python-${{ matrix.python-version }},sha-${{ github.sha }}"
|
||||
|
||||
AI21_API_KEY: ${{ secrets.AI21_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_FILES_API_IMAGE_ID: ${{ secrets.ANTHROPIC_FILES_API_IMAGE_ID }}
|
||||
ANTHROPIC_FILES_API_PDF_ID: ${{ secrets.ANTHROPIC_FILES_API_PDF_ID }}
|
||||
ASTRA_DB_API_ENDPOINT: ${{ secrets.ASTRA_DB_API_ENDPOINT }}
|
||||
ASTRA_DB_APPLICATION_TOKEN: ${{ secrets.ASTRA_DB_APPLICATION_TOKEN }}
|
||||
ASTRA_DB_KEYSPACE: ${{ secrets.ASTRA_DB_KEYSPACE }}
|
||||
AZURE_OPENAI_API_VERSION: ${{ secrets.AZURE_OPENAI_API_VERSION }}
|
||||
AZURE_OPENAI_API_BASE: ${{ secrets.AZURE_OPENAI_API_BASE }}
|
||||
AZURE_OPENAI_API_KEY: ${{ secrets.AZURE_OPENAI_API_KEY }}
|
||||
AZURE_OPENAI_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LEGACY_CHAT_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_LLM_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_LLM_DEPLOYMENT_NAME }}
|
||||
AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME: ${{ secrets.AZURE_OPENAI_EMBEDDINGS_DEPLOYMENT_NAME }}
|
||||
COHERE_API_KEY: ${{ secrets.COHERE_API_KEY }}
|
||||
DEEPSEEK_API_KEY: ${{ secrets.DEEPSEEK_API_KEY }}
|
||||
ES_URL: ${{ secrets.ES_URL }}
|
||||
ES_CLOUD_ID: ${{ secrets.ES_CLOUD_ID }}
|
||||
ES_API_KEY: ${{ secrets.ES_API_KEY }}
|
||||
EXA_API_KEY: ${{ secrets.EXA_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GOOGLE_SEARCH_API_KEY: ${{ secrets.GOOGLE_SEARCH_API_KEY }}
|
||||
GOOGLE_CSE_ID: ${{ secrets.GOOGLE_CSE_ID }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
HUGGINGFACEHUB_API_TOKEN: ${{ secrets.HUGGINGFACEHUB_API_TOKEN }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MONGODB_ATLAS_URI: ${{ secrets.MONGODB_ATLAS_URI }}
|
||||
NOMIC_API_KEY: ${{ secrets.NOMIC_API_KEY }}
|
||||
NVIDIA_API_KEY: ${{ secrets.NVIDIA_API_KEY }}
|
||||
OLLAMA_API_KEY: ${{ secrets.OLLAMA_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
PPLX_API_KEY: ${{ secrets.PPLX_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
UPSTAGE_API_KEY: ${{ secrets.UPSTAGE_API_KEY }}
|
||||
WATSONX_APIKEY: ${{ secrets.WATSONX_APIKEY }}
|
||||
WATSONX_PROJECT_ID: ${{ secrets.WATSONX_PROJECT_ID }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
run: |
|
||||
cd langchain/${{ matrix.working-directory }}
|
||||
make integration_tests
|
||||
|
||||
- name: "🧹 Clean up External Libraries"
|
||||
# Clean up external libraries to avoid affecting the following git status check
|
||||
run: |
|
||||
rm -rf \
|
||||
langchain/libs/partners/google-genai \
|
||||
langchain/libs/partners/google-vertexai \
|
||||
langchain/libs/partners/aws
|
||||
|
||||
- name: "🧹 Verify Clean Working Directory"
|
||||
working-directory: langchain
|
||||
run: |
|
||||
set -eu
|
||||
|
||||
STATUS="$(git status)"
|
||||
echo "$STATUS"
|
||||
|
||||
# grep will exit non-zero if the target message isn't found,
|
||||
# and `set -e` above will cause the step to fail.
|
||||
echo "$STATUS" | grep 'nothing to commit, working tree clean'
|
||||
|
||||
# Test dependent packages against local packages to catch breaking changes
|
||||
test-dependents:
|
||||
# Defend against forks running scheduled jobs, but allow manual runs from forks
|
||||
if: github.repository_owner == 'langchain-ai' || github.event_name != 'schedule'
|
||||
|
||||
name: "🐍 Python ${{ matrix.python-version }}: ${{ matrix.package.path }}"
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-matrix]
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# deepagents requires Python >= 3.11, use bounded version from compute-matrix
|
||||
python-version: ${{ fromJSON(needs.compute-matrix.outputs.python-version-min-3-11) }}
|
||||
package:
|
||||
- name: deepagents
|
||||
repo: langchain-ai/deepagents
|
||||
path: libs/deepagents
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
path: langchain
|
||||
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
with:
|
||||
repository: ${{ matrix.package.repo }}
|
||||
path: ${{ matrix.package.name }}
|
||||
|
||||
- name: "🐍 Set up Python ${{ matrix.python-version }} + UV"
|
||||
uses: "./langchain/.github/actions/uv_setup"
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: "📦 Install ${{ matrix.package.name }} with Local"
|
||||
# Unlike partner packages (which use [tool.uv.sources] for local resolution),
|
||||
# external dependents live in separate repos and need explicit overrides to
|
||||
# test against the langchain versions from the current branch, as their
|
||||
# pyproject.toml files point to released versions.
|
||||
run: |
|
||||
cd ${{ matrix.package.name }}/${{ matrix.package.path }}
|
||||
|
||||
# Install the package with test dependencies
|
||||
uv sync --group test
|
||||
|
||||
# Override langchain packages with local versions
|
||||
uv pip install \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/core \
|
||||
-e $GITHUB_WORKSPACE/langchain/libs/langchain_v1
|
||||
|
||||
# No API keys needed for now - deepagents `make test` only runs unit tests
|
||||
- name: "🚀 Run ${{ matrix.package.name }} Tests"
|
||||
run: |
|
||||
cd ${{ matrix.package.name }}/${{ matrix.package.path }}
|
||||
make test
|
||||
@@ -0,0 +1,214 @@
|
||||
# Unified PR labeler — applies size, file-based, title-based, and
|
||||
# contributor classification labels in a single sequential workflow.
|
||||
#
|
||||
# Consolidates pr_labeler_file.yml, pr_labeler_title.yml,
|
||||
# pr_size_labeler.yml, and PR-handling from tag-external-contributions.yml
|
||||
# into one workflow to eliminate race conditions from concurrent label
|
||||
# mutations. tag-external-issues.yml remains active for issue-only
|
||||
# labeling. Backfill lives in pr_labeler_backfill.yml.
|
||||
#
|
||||
# Config and shared logic live in .github/scripts/pr-labeler-config.json
|
||||
# and .github/scripts/pr-labeler.js — update those when adding partners.
|
||||
#
|
||||
# Setup Requirements:
|
||||
# 1. Create a GitHub App with permissions:
|
||||
# - Repository: Pull requests (write)
|
||||
# - Repository: Issues (write)
|
||||
# - Organization: Members (read)
|
||||
# 2. Install the app on your organization and this repository
|
||||
# 3. Add these repository secrets:
|
||||
# - ORG_MEMBERSHIP_APP_CLIENT_ID: Your app's client ID
|
||||
# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key
|
||||
#
|
||||
# The GitHub App token is required to check private organization membership
|
||||
# and to propagate label events to downstream workflows.
|
||||
|
||||
name: "🏷️ PR Labeler"
|
||||
|
||||
on:
|
||||
# Safe since we're not checking out or running the PR's code.
|
||||
# NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.
|
||||
# Doing so would allow attackers to execute arbitrary code in the context of your repository.
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# Separate opened events so external/tier labels are never lost to cancellation
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.run_id }}-${{ github.event.action == 'opened' && 'opened' || 'update' }}
|
||||
cancel-in-progress: ${{ github.event.action != 'opened' }}
|
||||
|
||||
jobs:
|
||||
label:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
# Checks out the BASE branch (safe for pull_request_target — never
|
||||
# the PR head). Needed to load .github/scripts/pr-labeler*.
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Generate GitHub App token
|
||||
if: github.event.action == 'opened'
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Verify App token
|
||||
if: github.event.action == 'opened'
|
||||
run: |
|
||||
if [ -z "${{ steps.app-token.outputs.token }}" ]; then
|
||||
echo "::error::GitHub App token generation failed — cannot classify contributor"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check org membership
|
||||
if: github.event.action == 'opened'
|
||||
id: check-membership
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const author = context.payload.sender.login;
|
||||
const { isExternal } = await h.checkMembership(
|
||||
author, context.payload.sender.type,
|
||||
);
|
||||
core.setOutput('is-external', isExternal ? 'true' : 'false');
|
||||
|
||||
- name: Apply PR labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
env:
|
||||
IS_EXTERNAL: ${{ steps.check-membership.outputs.is-external }}
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
if (!pr) return;
|
||||
const prNumber = pr.number;
|
||||
const action = context.payload.action;
|
||||
|
||||
const toAdd = new Set();
|
||||
const toRemove = new Set();
|
||||
|
||||
const currentLabels = (await github.paginate(
|
||||
github.rest.issues.listLabelsOnIssue,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
)).map(l => l.name ?? '');
|
||||
|
||||
// ── Size + file labels (skip on 'edited' — files unchanged) ──
|
||||
if (action !== 'edited') {
|
||||
for (const sl of h.sizeLabels) await h.ensureLabel(sl);
|
||||
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: prNumber, per_page: 100,
|
||||
});
|
||||
|
||||
const { totalChanged, sizeLabel } = h.computeSize(files);
|
||||
toAdd.add(sizeLabel);
|
||||
for (const sl of h.sizeLabels) {
|
||||
if (currentLabels.includes(sl) && sl !== sizeLabel) toRemove.add(sl);
|
||||
}
|
||||
console.log(`Size: ${totalChanged} changed lines → ${sizeLabel}`);
|
||||
|
||||
for (const label of h.matchFileLabels(files)) {
|
||||
toAdd.add(label);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Title-based labels ──
|
||||
const { labels: titleLabels, typeLabel } = h.matchTitleLabels(pr.title || '');
|
||||
for (const label of titleLabels) toAdd.add(label);
|
||||
|
||||
// Remove stale type labels only when a type was detected
|
||||
if (typeLabel) {
|
||||
for (const tl of h.allTypeLabels) {
|
||||
if (currentLabels.includes(tl) && !titleLabels.has(tl)) toRemove.add(tl);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Internal label (only on open, non-external contributors) ──
|
||||
// IS_EXTERNAL is empty string on non-opened events (step didn't
|
||||
// run), so this guard is only true for opened + internal.
|
||||
if (action === 'opened' && process.env.IS_EXTERNAL === 'false') {
|
||||
toAdd.add('internal');
|
||||
}
|
||||
|
||||
// ── Apply changes ──
|
||||
// Ensure all labels we're about to add exist (addLabels returns
|
||||
// 422 if any label in the batch is missing, which would prevent
|
||||
// ALL labels from being applied).
|
||||
for (const name of toAdd) {
|
||||
await h.ensureLabel(name);
|
||||
}
|
||||
|
||||
for (const name of toRemove) {
|
||||
if (toAdd.has(name)) continue;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const addList = [...toAdd];
|
||||
if (addList.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: addList,
|
||||
});
|
||||
}
|
||||
|
||||
const removed = [...toRemove].filter(r => !toAdd.has(r));
|
||||
console.log(`PR #${prNumber}: +[${addList.join(', ')}] -[${removed.join(', ')}]`);
|
||||
|
||||
# Apply tier label BEFORE the external label so that
|
||||
# "trusted-contributor" is already present when the "external" labeled
|
||||
# event fires and triggers require_issue_link.yml.
|
||||
- name: Apply contributor tier label
|
||||
if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const pr = context.payload.pull_request;
|
||||
await h.applyTierLabel(pr.number, pr.user.login);
|
||||
|
||||
- name: Add external label
|
||||
if: github.event.action == 'opened' && steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
# Use App token so the "labeled" event propagates to downstream
|
||||
# workflows (e.g. require_issue_link.yml). Events created by the
|
||||
# default GITHUB_TOKEN do not trigger additional workflow runs.
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
await h.ensureLabel('external');
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo,
|
||||
issue_number: prNumber,
|
||||
labels: ['external'],
|
||||
});
|
||||
console.log(`Added 'external' label to PR #${prNumber}`);
|
||||
@@ -0,0 +1,131 @@
|
||||
# Backfill PR labels on all open PRs.
|
||||
#
|
||||
# Manual-only workflow that applies the same labels as pr_labeler.yml
|
||||
# (size, file, title, contributor classification) to existing open PRs.
|
||||
# Reuses shared logic from .github/scripts/pr-labeler.js.
|
||||
|
||||
name: "🏷️ PR Labeler Backfill"
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_items:
|
||||
description: "Maximum number of open PRs to process"
|
||||
default: "100"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
backfill:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: write
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Backfill labels on open PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const rawMax = '${{ inputs.max_items }}';
|
||||
const maxItems = parseInt(rawMax, 10);
|
||||
if (isNaN(maxItems) || maxItems <= 0) {
|
||||
core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
for (const name of [...h.sizeLabels, ...h.tierLabels]) {
|
||||
await h.ensureLabel(name);
|
||||
}
|
||||
|
||||
const contributorCache = new Map();
|
||||
const fileRules = h.buildFileRules();
|
||||
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
let failures = 0;
|
||||
for (const pr of prs) {
|
||||
if (processed >= maxItems) break;
|
||||
try {
|
||||
const author = pr.user.login;
|
||||
const info = await h.getContributorInfo(contributorCache, author, pr.user.type);
|
||||
const labels = new Set();
|
||||
|
||||
labels.add(info.isExternal ? 'external' : 'internal');
|
||||
if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) {
|
||||
labels.add('trusted-contributor');
|
||||
} else if (info.isExternal && info.mergedCount === 0) {
|
||||
labels.add('new-contributor');
|
||||
}
|
||||
|
||||
// Size + file labels
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
owner, repo, pull_number: pr.number, per_page: 100,
|
||||
});
|
||||
const { sizeLabel } = h.computeSize(files);
|
||||
labels.add(sizeLabel);
|
||||
|
||||
for (const label of h.matchFileLabels(files, fileRules)) {
|
||||
labels.add(label);
|
||||
}
|
||||
|
||||
// Title labels
|
||||
const { labels: titleLabels } = h.matchTitleLabels(pr.title ?? '');
|
||||
for (const tl of titleLabels) labels.add(tl);
|
||||
|
||||
// Ensure all labels exist before batch add
|
||||
for (const name of labels) {
|
||||
await h.ensureLabel(name);
|
||||
}
|
||||
|
||||
// Remove stale managed labels
|
||||
const currentLabels = (await github.paginate(
|
||||
github.rest.issues.listLabelsOnIssue,
|
||||
{ owner, repo, issue_number: pr.number, per_page: 100 },
|
||||
)).map(l => l.name ?? '');
|
||||
|
||||
const managed = [...h.sizeLabels, ...h.tierLabels, ...h.allTypeLabels];
|
||||
for (const name of currentLabels) {
|
||||
if (managed.includes(name) && !labels.has(name)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: pr.number, name,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: pr.number, labels: [...labels],
|
||||
});
|
||||
console.log(`PR #${pr.number} (${author}): ${[...labels].join(', ')}`);
|
||||
processed++;
|
||||
} catch (e) {
|
||||
failures++;
|
||||
core.warning(`Failed to process PR #${pr.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nBackfill complete. Processed ${processed} PRs, ${failures} failures. ${contributorCache.size} unique authors.`);
|
||||
@@ -0,0 +1,128 @@
|
||||
# PR title linting.
|
||||
#
|
||||
# FORMAT (Conventional Commits 1.0.0):
|
||||
#
|
||||
# <type>[optional scope]: <description>
|
||||
# [optional body]
|
||||
# [optional footer(s)]
|
||||
#
|
||||
# Examples:
|
||||
# feat(core): add multi‐tenant support
|
||||
# fix(langchain): resolve error
|
||||
# docs: update API usage examples
|
||||
# docs(openai): update API usage examples
|
||||
#
|
||||
# Allowed Types:
|
||||
# * feat — a new feature (MINOR)
|
||||
# * fix — a bug fix (PATCH)
|
||||
# * docs — documentation only changes
|
||||
# * style — formatting, linting, etc.; no code change or typing refactors
|
||||
# * refactor — code change that neither fixes a bug nor adds a feature
|
||||
# * perf — code change that improves performance
|
||||
# * test — adding tests or correcting existing
|
||||
# * build — changes that affect the build system/external dependencies
|
||||
# * ci — continuous integration/configuration changes
|
||||
# * chore — other changes that don't modify source or test files
|
||||
# * revert — reverts a previous commit
|
||||
# * release — prepare a new release
|
||||
# * hotfix — urgent fix
|
||||
#
|
||||
# Allowed Scope(s) (optional):
|
||||
# core, langchain, langchain-classic, model-profiles,
|
||||
# standard-tests, text-splitters, docs, anthropic, chroma, deepseek, exa,
|
||||
# fireworks, groq, huggingface, mistralai, nomic, ollama, openai,
|
||||
# perplexity, qdrant, xai, infra, deps, partners
|
||||
#
|
||||
# Multiple scopes can be used by separating them with a comma. For example:
|
||||
#
|
||||
# feat(core,langchain): add multi‐tenant support to core and langchain
|
||||
#
|
||||
# Note: PRs touching the langchain package should use the 'langchain' scope. It is not
|
||||
# acceptable to omit the scope for changes to the langchain package, despite it being
|
||||
# the main package & name of the repo.
|
||||
#
|
||||
# Rules:
|
||||
# 1. The 'Type' must start with a lowercase letter.
|
||||
# 2. Breaking changes: append "!" after type/scope (e.g., feat!: drop x support)
|
||||
# 3. When releasing (updating the pyproject.toml and uv.lock), the commit message
|
||||
# should be: `release(scope): x.y.z` (e.g., `release(core): 1.2.0` with no
|
||||
# body, footer, or preceeding/proceeding text).
|
||||
#
|
||||
# Enforces Conventional Commits format for pull request titles to maintain a clear and
|
||||
# machine-readable change history.
|
||||
|
||||
name: "🏷️ PR Title Lint"
|
||||
|
||||
permissions:
|
||||
pull-requests: read
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, edited, synchronize]
|
||||
|
||||
jobs:
|
||||
# Validates that PR title follows Conventional Commits 1.0.0 specification
|
||||
lint-pr-title:
|
||||
name: "validate format"
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: "🚫 Reject empty scope"
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
run: |
|
||||
if [[ "$PR_TITLE" =~ ^[a-z]+\(\)[!]?: ]]; then
|
||||
echo "::error::PR title has empty scope parentheses: '$PR_TITLE'"
|
||||
echo "Either remove the parentheses or provide a scope (e.g., 'fix(core): ...')."
|
||||
exit 1
|
||||
fi
|
||||
- name: "✅ Validate Conventional Commits Format"
|
||||
uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
types: |
|
||||
feat
|
||||
fix
|
||||
docs
|
||||
style
|
||||
refactor
|
||||
perf
|
||||
test
|
||||
build
|
||||
ci
|
||||
chore
|
||||
revert
|
||||
release
|
||||
hotfix
|
||||
scopes: |
|
||||
core
|
||||
langchain
|
||||
langchain-classic
|
||||
model-profiles
|
||||
standard-tests
|
||||
text-splitters
|
||||
docs
|
||||
anthropic
|
||||
chroma
|
||||
deepseek
|
||||
exa
|
||||
fireworks
|
||||
groq
|
||||
huggingface
|
||||
mistralai
|
||||
nomic
|
||||
ollama
|
||||
openai
|
||||
openrouter
|
||||
perplexity
|
||||
qdrant
|
||||
xai
|
||||
infra
|
||||
deps
|
||||
partners
|
||||
requireScope: false
|
||||
disallowScopes: |
|
||||
release
|
||||
[A-Z]+
|
||||
ignoreLabels: |
|
||||
ignore-lint-pr-title
|
||||
@@ -0,0 +1,175 @@
|
||||
# Pre-merge banned-trailer check.
|
||||
|
||||
name: "🏷️ PR trailer lint"
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [ opened, edited, synchronize, reopened ]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
trailer-check:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
name: "validate squash-merge has no banned trailers"
|
||||
runs-on: ubuntu-latest
|
||||
# Serialize per-PR. Rapid `edited`/`synchronize` events on a PR open can
|
||||
# otherwise produce two concurrent runs that both observe "no existing
|
||||
# sticky" and both call `createComment`, leaving a duplicate failure
|
||||
# comment that the find-first updater will never reconcile. We queue
|
||||
# (cancel-in-progress: false) rather than cancel, so the in-flight run
|
||||
# finishes its sticky write before the next event evaluates.
|
||||
concurrency:
|
||||
group: pr-trailer-lint-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Check PR title and body for banned trailer
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# Bound the comment-write tail so a hung GitHub API call cannot leave
|
||||
# the check stuck "in progress" past the runner default. `core.setFailed`
|
||||
# is invoked before the sticky write, so the failure status is already
|
||||
# recorded if this timeout fires.
|
||||
timeout-minutes: 5
|
||||
with:
|
||||
script: |
|
||||
if (!context.payload.pull_request) {
|
||||
core.setFailed('No pull_request payload — workflow must run on pull_request events.');
|
||||
return;
|
||||
}
|
||||
const { title, body, number } = context.payload.pull_request;
|
||||
// Normalize line endings — GitHub returns whatever the editor used,
|
||||
// and CRLF leaves stray \r chars in offending-line displays.
|
||||
const fullBody = (body || '').replace(/\r\n/g, '\n');
|
||||
const STICKY_MARKER = '<!-- pr-trailer-lint -->';
|
||||
|
||||
// Mirrors the org ruleset regex on the default branch. Keep in lock-step:
|
||||
// the live source of truth is the ruleset's `commit_message_pattern.pattern`
|
||||
// field at GitHub org settings → Rulesets → `block-anthropic-coauthor`
|
||||
// (or whichever ruleset blocks this trailer on the default branch).
|
||||
// The pattern below is informational; verify against the live ruleset
|
||||
// when updating either side, or this check silently passes pushes
|
||||
// that the ruleset will then reject (defeating the entire purpose).
|
||||
//
|
||||
// Case-folding is intentionally narrow (`[Aa]`/`[Bb]`) because the
|
||||
// ruleset's pattern is narrow. Do NOT add the `i` flag — that would
|
||||
// catch cases the ruleset does not, surfacing false positives the
|
||||
// ruleset would let through.
|
||||
const BANNED_REGEX = /Co-[Aa]uthored-[Bb]y:.*<noreply@anthropic\.com>/;
|
||||
|
||||
const squashMessage = `${title} (#${number})\n\n${fullBody}`;
|
||||
|
||||
async function findStickyComment() {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
...context.repo,
|
||||
issue_number: number,
|
||||
per_page: 100,
|
||||
});
|
||||
return comments.find(c => c.body && c.body.startsWith(STICKY_MARKER));
|
||||
}
|
||||
|
||||
// Comment write paths can fail for several reasons that should not
|
||||
// turn this advisory job red on its own: fork PRs run with
|
||||
// restricted tokens, secondary rate limits, transient API errors.
|
||||
// Fall back to `core.summary` so a maintainer can paste the
|
||||
// remediation manually. The check still fails — `setFailed` is
|
||||
// invoked before this function, so the failure signal is already
|
||||
// recorded by the time the comment write is attempted.
|
||||
//
|
||||
// The try/catch wraps ONLY the write call so that a bug in
|
||||
// `findStickyComment` (e.g., pagination throwing) surfaces with
|
||||
// its true cause instead of being misattributed to "fork PR token".
|
||||
async function postStickyOrSummary(commentBody, summaryHeading) {
|
||||
const existing = await findStickyComment();
|
||||
try {
|
||||
if (existing) {
|
||||
if (existing.body !== commentBody) {
|
||||
await github.rest.issues.updateComment({
|
||||
...context.repo,
|
||||
comment_id: existing.id,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
...context.repo,
|
||||
issue_number: number,
|
||||
body: commentBody,
|
||||
});
|
||||
}
|
||||
} catch (commentErr) {
|
||||
core.warning(`Could not post sticky comment (fork PR token, rate limit, or transient API error): ${commentErr.message}`);
|
||||
await core.summary
|
||||
.addHeading(summaryHeading)
|
||||
.addRaw('Paste the following into the PR as a comment:')
|
||||
.addCodeBlock(commentBody, 'markdown')
|
||||
.write();
|
||||
}
|
||||
}
|
||||
|
||||
const lines = squashMessage.split('\n');
|
||||
const offendingIndices = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (BANNED_REGEX.test(lines[i])) {
|
||||
offendingIndices.push(i);
|
||||
}
|
||||
}
|
||||
|
||||
if (offendingIndices.length === 0) {
|
||||
core.info('No banned trailer in squash-merge message.');
|
||||
// Mark any prior failure comment as resolved. We update rather
|
||||
// than delete because `deleteComment` 403s under restricted
|
||||
// fork-PR tokens, whereas `updateComment` on a bot-authored
|
||||
// comment works in both modes. Wrapped in try/catch because a
|
||||
// transient API failure during cleanup must NOT turn a green
|
||||
// check into red.
|
||||
try {
|
||||
const existing = await findStickyComment();
|
||||
if (existing) {
|
||||
const resolvedBody = [
|
||||
STICKY_MARKER,
|
||||
'✅ **Trailer fixed.** The previous warning is resolved.',
|
||||
].join('\n');
|
||||
if (existing.body !== resolvedBody) {
|
||||
await github.rest.issues.updateComment({
|
||||
...context.repo,
|
||||
comment_id: existing.id,
|
||||
body: resolvedBody,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (cleanupErr) {
|
||||
core.warning(`Check passed but could not update prior failure comment to resolved: ${cleanupErr.message}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const offendingExcerpt = offendingIndices
|
||||
.map(i => `Line ${i + 1}: ${lines[i]}`)
|
||||
.join('\n');
|
||||
|
||||
const commentBody = [
|
||||
STICKY_MARKER,
|
||||
'⚠️ **Banned trailer in PR — would block the squash-merge push to the default branch.**',
|
||||
'',
|
||||
'The would-be squash-merge commit message contains a `Co-authored-by: ... <noreply@anthropic.com>` line. An organization ruleset on the default branch rejects any push whose commit message matches that pattern, so this PR cannot be merged until the trailer is removed.',
|
||||
'',
|
||||
'**Found:**',
|
||||
'```',
|
||||
offendingExcerpt,
|
||||
'```',
|
||||
'',
|
||||
'### Fix',
|
||||
'',
|
||||
'Edit the PR description and remove the offending line(s). The trailer is auto-inserted by some Claude-based authoring tools — strip it before opening or merging the PR. Save the description; this check will re-run automatically.',
|
||||
].join('\n');
|
||||
|
||||
// Set the failure signal BEFORE the sticky write — if the comment
|
||||
// API hangs, the runner-level timeout fires with the failure
|
||||
// status already recorded. Reversing the order leaves the check
|
||||
// stuck "in progress" instead of red.
|
||||
core.setFailed(`PR contains banned trailer matching ${BANNED_REGEX}`);
|
||||
await postStickyOrSummary(
|
||||
commentBody,
|
||||
'Banned trailer in PR; comment could not be posted',
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
# Refreshes model profile data for all in-monorepo partner integrations by
|
||||
# pulling the latest metadata from models.dev via the `langchain-profiles` CLI.
|
||||
#
|
||||
# Creates a pull request with any changes. Runs daily and can be triggered
|
||||
# manually from the Actions UI. Uses a fixed branch so each run supersedes
|
||||
# any stale PR from a previous run.
|
||||
|
||||
name: "🔄 Refresh Model Profiles"
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 8 * * *" # daily at 08:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
refresh-profiles:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
uses: ./.github/workflows/_refresh_model_profiles.yml
|
||||
with:
|
||||
providers: >-
|
||||
[
|
||||
{"provider":"anthropic", "data_dir":"libs/partners/anthropic/langchain_anthropic/data"},
|
||||
{"provider":"deepseek", "data_dir":"libs/partners/deepseek/langchain_deepseek/data"},
|
||||
{"provider":"fireworks-ai", "data_dir":"libs/partners/fireworks/langchain_fireworks/data"},
|
||||
{"provider":"groq", "data_dir":"libs/partners/groq/langchain_groq/data"},
|
||||
{"provider":"huggingface", "data_dir":"libs/partners/huggingface/langchain_huggingface/data"},
|
||||
{"provider":"mistral", "data_dir":"libs/partners/mistralai/langchain_mistralai/data"},
|
||||
{"provider":"openai", "data_dir":"libs/partners/openai/langchain_openai/data"},
|
||||
{"provider":"openrouter", "data_dir":"libs/partners/openrouter/langchain_openrouter/data"},
|
||||
{"provider":"perplexity", "data_dir":"libs/partners/perplexity/langchain_perplexity/data"},
|
||||
{"provider":"xai", "data_dir":"libs/partners/xai/langchain_xai/data"}
|
||||
]
|
||||
cli-path: libs/model-profiles
|
||||
add-paths: libs/partners/**/data/_profiles.py
|
||||
pr-body: |
|
||||
Automated refresh of model profile data for all in-monorepo partner integrations via `langchain-profiles refresh`.
|
||||
|
||||
🤖 Generated by the [`refresh_model_profiles` workflow](https://github.com/langchain-ai/langchain/blob/master/.github/workflows/refresh_model_profiles.yml).
|
||||
secrets:
|
||||
MODEL_PROFILE_BOT_CLIENT_ID: ${{ secrets.MODEL_PROFILE_BOT_CLIENT_ID }}
|
||||
MODEL_PROFILE_BOT_PRIVATE_KEY: ${{ secrets.MODEL_PROFILE_BOT_PRIVATE_KEY }}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Remove the `waiting-on-author` label from an issue or PR when the original
|
||||
# author replies. Fires on every issue/PR comment; the job's `if:` filter skips
|
||||
# runs unless the item is open, carries the label, and the commenter is the
|
||||
# original author (and not a bot).
|
||||
#
|
||||
# Uses the default GITHUB_TOKEN so the label-removal event does NOT re-trigger
|
||||
# other workflows: GitHub does not trigger new workflow runs from actions
|
||||
# performed by the default GITHUB_TOKEN (with narrow exceptions like
|
||||
# workflow_dispatch), which prevents infinite loops.
|
||||
|
||||
name: Remove waiting-on-author on Author Reply
|
||||
|
||||
on:
|
||||
issue_comment:
|
||||
types: [created]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
remove-label:
|
||||
if: >-
|
||||
github.repository_owner == 'langchain-ai' &&
|
||||
github.event.issue.state == 'open' &&
|
||||
contains(github.event.issue.labels.*.name, 'waiting-on-author') &&
|
||||
github.event.comment.user.type != 'Bot' &&
|
||||
github.event.comment.user.login == github.event.issue.user.login
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- name: Remove waiting-on-author label
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
const author = context.payload.issue.user.login;
|
||||
|
||||
console.log(
|
||||
`Author ${author} commented on #${issue_number} — removing waiting-on-author`,
|
||||
);
|
||||
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
name: 'waiting-on-author',
|
||||
});
|
||||
} catch (e) {
|
||||
// 404: typically label not present (already removed or never
|
||||
// applied). Could also indicate issue/repo not found — include
|
||||
// e.message to disambiguate.
|
||||
if (e.status === 404) {
|
||||
console.log(`Label already absent (404) — nothing to do: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
# Reopen PRs that were auto-closed by require_issue_link.yml when the
|
||||
# contributor was not assigned to the linked issue. When a maintainer
|
||||
# assigns the contributor to the issue, this workflow finds matching
|
||||
# closed PRs, verifies the issue link, and reopens them.
|
||||
#
|
||||
# Uses the default GITHUB_TOKEN (not a PAT or app token) so that the
|
||||
# reopen and label-removal events do NOT re-trigger other workflows.
|
||||
# GitHub suppresses events created by the default GITHUB_TOKEN within
|
||||
# workflow runs to prevent infinite loops.
|
||||
|
||||
name: Reopen PR on Issue Assignment
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [assigned]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
reopen-linked-prs:
|
||||
if: github.repository_owner == 'langchain-ai'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Find and reopen matching PRs
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issueNumber = context.payload.issue.number;
|
||||
const assignee = context.payload.assignee.login;
|
||||
|
||||
console.log(
|
||||
`Issue #${issueNumber} assigned to ${assignee} — searching for closed PRs to reopen`,
|
||||
);
|
||||
|
||||
const q = [
|
||||
`is:pr`,
|
||||
`is:closed`,
|
||||
`author:${assignee}`,
|
||||
`label:missing-issue-link`,
|
||||
`repo:${owner}/${repo}`,
|
||||
].join(' ');
|
||||
|
||||
let data;
|
||||
try {
|
||||
({ data } = await github.rest.search.issuesAndPullRequests({
|
||||
q,
|
||||
per_page: 30,
|
||||
}));
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`Failed to search for closed PRs to reopen after assigning ${assignee} ` +
|
||||
`to #${issueNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (data.total_count === 0) {
|
||||
console.log('No matching closed PRs found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Found ${data.total_count} candidate PR(s)`);
|
||||
|
||||
// Must stay in sync with the identical pattern in require_issue_link.yml
|
||||
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
|
||||
|
||||
for (const item of data.items) {
|
||||
const prNumber = item.number;
|
||||
const body = item.body || '';
|
||||
const matches = [...body.matchAll(pattern)];
|
||||
const referencedIssues = matches.map(m => parseInt(m[1], 10));
|
||||
|
||||
if (!referencedIssues.includes(issueNumber)) {
|
||||
console.log(`PR #${prNumber} does not reference #${issueNumber} — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already bypassed
|
||||
const labels = item.labels.map(l => l.name);
|
||||
if (labels.includes('bypass-issue-check')) {
|
||||
console.log(`PR #${prNumber} already has bypass-issue-check — skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Reopen first, remove label second — a closed PR that still has
|
||||
// missing-issue-link is recoverable; a closed PR with the label
|
||||
// stripped is invisible to both workflows.
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'open',
|
||||
});
|
||||
console.log(`Reopened PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
if (e.status === 422) {
|
||||
// Head branch deleted — PR is unrecoverable. Notify the
|
||||
// contributor so they know to open a new PR.
|
||||
core.warning(`Cannot reopen PR #${prNumber}: head branch was likely deleted`);
|
||||
try {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body:
|
||||
`You have been assigned to #${issueNumber}, but this PR could not be ` +
|
||||
`reopened because the head branch has been deleted. Please open a new ` +
|
||||
`PR referencing the issue.`,
|
||||
});
|
||||
} catch (commentErr) {
|
||||
core.warning(
|
||||
`Also failed to post comment on PR #${prNumber}: ${commentErr.message}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Transient errors (rate limit, 5xx) should fail the job so
|
||||
// the label is NOT removed and the run can be retried.
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Remove missing-issue-link label only after successful reopen
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
name: 'missing-issue-link',
|
||||
});
|
||||
console.log(`Removed missing-issue-link from PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
|
||||
// Minimize stale enforcement comment (best-effort;
|
||||
// sync w/ require_issue_link.yml minimize blocks)
|
||||
try {
|
||||
const marker = '<!-- require-issue-link -->';
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const stale = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (stale) {
|
||||
await github.graphql(`
|
||||
mutation($id: ID!) {
|
||||
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
`, { id: stale.node_id });
|
||||
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
|
||||
// Re-run the failed require_issue_link check so it picks up the
|
||||
// new assignment. The re-run uses the original event payload but
|
||||
// fetches live issue data, so the assignment check will pass.
|
||||
//
|
||||
// Limitation: we look up runs by the PR's current head SHA. If the
|
||||
// contributor pushed new commits while the PR was closed, head.sha
|
||||
// won't match the SHA of the original failed run and the query will
|
||||
// return 0 results. This is acceptable because any push after reopen
|
||||
// triggers a fresh require_issue_link run against the new SHA.
|
||||
try {
|
||||
const { data: pr } = await github.rest.pulls.get({
|
||||
owner, repo, pull_number: prNumber,
|
||||
});
|
||||
const { data: runs } = await github.rest.actions.listWorkflowRuns({
|
||||
owner, repo,
|
||||
workflow_id: 'require_issue_link.yml',
|
||||
head_sha: pr.head.sha,
|
||||
status: 'failure',
|
||||
per_page: 1,
|
||||
});
|
||||
if (runs.workflow_runs.length > 0) {
|
||||
await github.rest.actions.reRunWorkflowFailedJobs({
|
||||
owner, repo,
|
||||
run_id: runs.workflow_runs[0].id,
|
||||
});
|
||||
console.log(`Re-ran failed require_issue_link run ${runs.workflow_runs[0].id} for PR #${prNumber}`);
|
||||
} else {
|
||||
console.log(`No failed require_issue_link runs found for PR #${prNumber} — skipping re-run`);
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not re-run require_issue_link check for PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,468 @@
|
||||
# Require external PRs to reference an approved issue (e.g. Fixes #NNN) and
|
||||
# the PR author to be assigned to that issue. On failure the PR is
|
||||
# labeled "missing-issue-link", commented on, and closed.
|
||||
#
|
||||
# Maintainer override: an org member can reopen the PR or remove
|
||||
# "missing-issue-link" — both add "bypass-issue-check" and reopen.
|
||||
#
|
||||
# Dependency: pr_labeler.yml must apply the "external" label first. This
|
||||
# workflow does NOT trigger on "opened" (new PRs have no labels yet, so the
|
||||
# gate would always skip).
|
||||
|
||||
name: Require Issue Link
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
# NEVER CHECK OUT UNTRUSTED CODE FROM A PR's HEAD IN A pull_request_target JOB.
|
||||
# Doing so would allow attackers to execute arbitrary code in the context of your repository.
|
||||
types: [edited, reopened, labeled, unlabeled]
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# Enforcement gate: set to 'true' to activate the issue link requirement.
|
||||
# When 'false', the workflow still runs the check logic (useful for dry-run
|
||||
# visibility) but will NOT label, comment, close, or fail PRs.
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
env:
|
||||
ENFORCE_ISSUE_LINK: "true"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check-issue-link:
|
||||
# Run when the "external" label is added, on edit/reopen if already labeled,
|
||||
# or when "missing-issue-link" is removed (triggers maintainer override check).
|
||||
# Skip entirely when the PR already carries "trusted-contributor" or
|
||||
# "bypass-issue-check".
|
||||
if: >-
|
||||
github.repository_owner == 'langchain-ai' &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'trusted-contributor') &&
|
||||
!contains(github.event.pull_request.labels.*.name, 'bypass-issue-check') &&
|
||||
(
|
||||
(github.event.action == 'labeled' && github.event.label.name == 'external') ||
|
||||
(github.event.action == 'unlabeled' && github.event.label.name == 'missing-issue-link' && contains(github.event.pull_request.labels.*.name, 'external')) ||
|
||||
(github.event.action != 'labeled' && github.event.action != 'unlabeled' && contains(github.event.pull_request.labels.*.name, 'external'))
|
||||
)
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Check for issue link and assignee
|
||||
id: check-link
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const action = context.payload.action;
|
||||
|
||||
// ── Helper: ensure a label exists, then add it to the PR ────────
|
||||
async function ensureAndAddLabel(labelName, color) {
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: labelName });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name: labelName, color });
|
||||
} catch (createErr) {
|
||||
// 422 = label was created by a concurrent run between our
|
||||
// GET and POST — safe to ignore.
|
||||
if (createErr.status !== 422) throw createErr;
|
||||
}
|
||||
}
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: [labelName],
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helper: check if the user who triggered this event (reopened
|
||||
// the PR / removed the label) has write+ access on the repo ───
|
||||
// Uses the repo collaborator permission endpoint instead of the
|
||||
// org membership endpoint. The org endpoint requires the caller
|
||||
// to be an org member, which GITHUB_TOKEN (an app installation
|
||||
// token) never is — so it always returns 403.
|
||||
async function senderIsOrgMember() {
|
||||
const sender = context.payload.sender?.login;
|
||||
if (!sender) {
|
||||
throw new Error('Event has no sender — cannot check permissions');
|
||||
}
|
||||
try {
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner, repo, username: sender,
|
||||
});
|
||||
const perm = data.permission;
|
||||
if (['admin', 'maintain', 'write'].includes(perm)) {
|
||||
console.log(`${sender} has ${perm} permission — treating as maintainer`);
|
||||
return { isMember: true, login: sender };
|
||||
}
|
||||
console.log(`${sender} has ${perm} permission — not a maintainer`);
|
||||
return { isMember: false, login: sender };
|
||||
} catch (e) {
|
||||
if (e.status === 404) {
|
||||
console.log(`Cannot check permissions for ${sender} — treating as non-maintainer`);
|
||||
return { isMember: false, login: sender };
|
||||
}
|
||||
const status = e.status ?? 'unknown';
|
||||
throw new Error(
|
||||
`Permission check failed for ${sender} (HTTP ${status}): ${e.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Helper: apply maintainer bypass (shared by both override paths) ──
|
||||
async function applyMaintainerBypass(reason) {
|
||||
console.log(reason);
|
||||
|
||||
// Remove missing-issue-link if present
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'missing-issue-link',
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
|
||||
// Reopen before adding bypass label — a failed reopen is more
|
||||
// actionable than a closed PR with a bypass label stuck on it.
|
||||
if (context.payload.pull_request.state === 'closed') {
|
||||
try {
|
||||
await github.rest.pulls.update({
|
||||
owner, repo, pull_number: prNumber, state: 'open',
|
||||
});
|
||||
console.log(`Reopened PR #${prNumber}`);
|
||||
} catch (e) {
|
||||
// 422 if head branch deleted; 403 if permissions insufficient.
|
||||
// Bypass labels still apply — maintainer can reopen manually.
|
||||
core.warning(
|
||||
`Could not reopen PR #${prNumber} (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +
|
||||
`Bypass labels were applied — a maintainer may need to reopen manually.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add bypass-issue-check so future triggers skip enforcement
|
||||
await ensureAndAddLabel('bypass-issue-check', '0e8a16');
|
||||
|
||||
// Minimize stale enforcement comment (best-effort; must not
|
||||
// abort bypass — sync w/ reopen_on_assignment.yml & step below)
|
||||
try {
|
||||
const marker = '<!-- require-issue-link -->';
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const stale = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (stale) {
|
||||
await github.graphql(`
|
||||
mutation($id: ID!) {
|
||||
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
`, { id: stale.node_id });
|
||||
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
|
||||
core.setOutput('has-link', 'true');
|
||||
core.setOutput('is-assigned', 'true');
|
||||
}
|
||||
|
||||
// ── Maintainer override: removed "missing-issue-link" label ─────
|
||||
if (action === 'unlabeled') {
|
||||
const { isMember, login } = await senderIsOrgMember();
|
||||
if (isMember) {
|
||||
await applyMaintainerBypass(
|
||||
`Maintainer ${login} removed missing-issue-link from PR #${prNumber} — bypassing enforcement`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Non-member removed the label — re-add it defensively and
|
||||
// set failure outputs so downstream steps (comment, close) fire.
|
||||
// NOTE: addLabels fires a "labeled" event, but the job-level gate
|
||||
// only matches labeled events for "external", so no re-trigger.
|
||||
console.log(`Non-member ${login} removed missing-issue-link — re-adding`);
|
||||
try {
|
||||
await ensureAndAddLabel('missing-issue-link', 'b76e79');
|
||||
} catch (e) {
|
||||
core.warning(
|
||||
`Failed to re-add missing-issue-link (HTTP ${e.status ?? 'unknown'}): ${e.message}. ` +
|
||||
`Downstream step will retry.`,
|
||||
);
|
||||
}
|
||||
core.setOutput('has-link', 'false');
|
||||
core.setOutput('is-assigned', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Maintainer override: reopened PR with "missing-issue-link" ──
|
||||
const prLabels = context.payload.pull_request.labels.map(l => l.name);
|
||||
if (action === 'reopened' && prLabels.includes('missing-issue-link')) {
|
||||
const { isMember, login } = await senderIsOrgMember();
|
||||
if (isMember) {
|
||||
await applyMaintainerBypass(
|
||||
`Maintainer ${login} reopened PR #${prNumber} — bypassing enforcement`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
console.log(`Non-member ${login} reopened PR — proceeding with check`);
|
||||
}
|
||||
|
||||
// ── Fetch live labels (race guard) ──────────────────────────────
|
||||
const { data: liveLabels } = await github.rest.issues.listLabelsOnIssue({
|
||||
owner, repo, issue_number: prNumber,
|
||||
});
|
||||
const liveNames = liveLabels.map(l => l.name);
|
||||
if (liveNames.includes('trusted-contributor') || liveNames.includes('bypass-issue-check')) {
|
||||
console.log('PR has trusted-contributor or bypass-issue-check label — bypassing');
|
||||
core.setOutput('has-link', 'true');
|
||||
core.setOutput('is-assigned', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = context.payload.pull_request.body || '';
|
||||
const pattern = /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*#(\d+)/gi;
|
||||
const matches = [...body.matchAll(pattern)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
console.log('No issue link found in PR body');
|
||||
core.setOutput('has-link', 'false');
|
||||
core.setOutput('is-assigned', 'false');
|
||||
return;
|
||||
}
|
||||
|
||||
const issues = matches.map(m => `#${m[1]}`).join(', ');
|
||||
console.log(`Found issue link(s): ${issues}`);
|
||||
core.setOutput('has-link', 'true');
|
||||
|
||||
// Check whether the PR author is assigned to at least one linked issue
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const MAX_ISSUES = 5;
|
||||
const allIssueNumbers = [...new Set(matches.map(m => parseInt(m[1], 10)))];
|
||||
const issueNumbers = allIssueNumbers.slice(0, MAX_ISSUES);
|
||||
if (allIssueNumbers.length > MAX_ISSUES) {
|
||||
core.warning(
|
||||
`PR references ${allIssueNumbers.length} issues — only checking the first ${MAX_ISSUES}`,
|
||||
);
|
||||
}
|
||||
|
||||
let assignedToAny = false;
|
||||
for (const num of issueNumbers) {
|
||||
try {
|
||||
const { data: issue } = await github.rest.issues.get({
|
||||
owner, repo, issue_number: num,
|
||||
});
|
||||
const assignees = issue.assignees.map(a => a.login.toLowerCase());
|
||||
if (assignees.includes(prAuthor.toLowerCase())) {
|
||||
console.log(`PR author "${prAuthor}" is assigned to #${num}`);
|
||||
assignedToAny = true;
|
||||
break;
|
||||
} else {
|
||||
console.log(`PR author "${prAuthor}" is NOT assigned to #${num} (assignees: ${assignees.join(', ') || 'none'})`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.status === 404) {
|
||||
console.log(`Issue #${num} not found — skipping`);
|
||||
} else {
|
||||
// Non-404 errors (rate limit, server error) must not be
|
||||
// silently skipped — they could cause false enforcement
|
||||
// (closing a legitimate PR whose assignment can't be verified).
|
||||
throw new Error(
|
||||
`Cannot verify assignee for issue #${num} (${error.status}): ${error.message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
core.setOutput('is-assigned', assignedToAny ? 'true' : 'false');
|
||||
|
||||
- name: Add missing-issue-link label
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
(steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const labelName = 'missing-issue-link';
|
||||
|
||||
// Ensure the label exists (no checkout/shared helper available)
|
||||
try {
|
||||
await github.rest.issues.getLabel({ owner, repo, name: labelName });
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
try {
|
||||
await github.rest.issues.createLabel({
|
||||
owner, repo, name: labelName, color: 'b76e79',
|
||||
});
|
||||
} catch (createErr) {
|
||||
if (createErr.status !== 422) throw createErr;
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: prNumber, labels: [labelName],
|
||||
});
|
||||
|
||||
- name: Remove missing-issue-link label and reopen PR
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
steps.check-link.outputs.has-link == 'true' && steps.check-link.outputs.is-assigned == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: prNumber, name: 'missing-issue-link',
|
||||
});
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
|
||||
// Reopen if this workflow previously closed the PR. We check the
|
||||
// event payload labels (not live labels) because we already removed
|
||||
// missing-issue-link above; the payload still reflects pre-step state.
|
||||
const labels = context.payload.pull_request.labels.map(l => l.name);
|
||||
if (context.payload.pull_request.state === 'closed' && labels.includes('missing-issue-link')) {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'open',
|
||||
});
|
||||
console.log(`Reopened PR #${prNumber}`);
|
||||
}
|
||||
|
||||
// Minimize stale enforcement comment (best-effort;
|
||||
// sync w/ applyMaintainerBypass above & reopen_on_assignment.yml)
|
||||
try {
|
||||
const marker = '<!-- require-issue-link -->';
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const stale = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (stale) {
|
||||
await github.graphql(`
|
||||
mutation($id: ID!) {
|
||||
minimizeComment(input: {subjectId: $id, classifier: OUTDATED}) {
|
||||
minimizedComment { isMinimized }
|
||||
}
|
||||
}
|
||||
`, { id: stale.node_id });
|
||||
console.log(`Minimized stale enforcement comment ${stale.id} as outdated`);
|
||||
}
|
||||
} catch (e) {
|
||||
core.warning(`Could not minimize stale comment on PR #${prNumber}: ${e.message}`);
|
||||
}
|
||||
|
||||
- name: Post comment, close PR, and fail
|
||||
if: >-
|
||||
env.ENFORCE_ISSUE_LINK == 'true' &&
|
||||
(steps.check-link.outputs.has-link != 'true' || steps.check-link.outputs.is-assigned != 'true')
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const prNumber = context.payload.pull_request.number;
|
||||
const hasLink = '${{ steps.check-link.outputs.has-link }}' === 'true';
|
||||
const isAssigned = '${{ steps.check-link.outputs.is-assigned }}' === 'true';
|
||||
const marker = '<!-- require-issue-link -->';
|
||||
|
||||
let lines;
|
||||
if (!hasLink) {
|
||||
lines = [
|
||||
marker,
|
||||
'**This PR has been automatically closed** because it does not link to an approved issue.',
|
||||
'',
|
||||
'All external contributions must reference an approved issue or discussion. Opening a PR before maintainer approval and assignment is discouraged. Please:',
|
||||
'1. Find or [open an issue](https://github.com/' + owner + '/' + repo + '/issues/new/choose) describing the change',
|
||||
'2. Wait for a maintainer to approve the approach and assign you',
|
||||
'3. After assignment, open a PR. If this PR was opened early, add `Fixes #<issue_number>`, `Closes #<issue_number>`, or `Resolves #<issue_number>` to the description and it can be reopened automatically',
|
||||
'',
|
||||
'*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',
|
||||
];
|
||||
} else {
|
||||
lines = [
|
||||
marker,
|
||||
'**This PR has been automatically closed** because you are not assigned to the linked issue.',
|
||||
'',
|
||||
'Opening a PR before assignment is discouraged and is **not** an indication that it will be accepted. This process exists so maintainers can confirm a change is aligned with the project direction *before* contributors invest time implementing it. Please:',
|
||||
'1. Comment on the linked issue explaining the approach you would like to take and why — include enough detail for a maintainer to evaluate the design. Do **not** post a drive-by "please assign me" comment with no substance; those will be ignored.',
|
||||
'2. Wait for a maintainer to approve the approach and assign you. Once assigned, this PR can be reopened automatically.',
|
||||
'',
|
||||
'*Maintainers: reopen this PR or remove the `missing-issue-link` label to bypass this check.*',
|
||||
];
|
||||
}
|
||||
|
||||
const body = lines.join('\n');
|
||||
|
||||
// Deduplicate: check for existing comment with the marker
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
{ owner, repo, issue_number: prNumber, per_page: 100 },
|
||||
);
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
|
||||
if (!existing) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: prNumber,
|
||||
body,
|
||||
});
|
||||
console.log('Posted requirement comment');
|
||||
} else if (existing.body !== body) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner,
|
||||
repo,
|
||||
comment_id: existing.id,
|
||||
body,
|
||||
});
|
||||
console.log('Updated existing comment with new message');
|
||||
} else {
|
||||
console.log('Comment already exists — skipping');
|
||||
}
|
||||
|
||||
// Close the PR
|
||||
if (context.payload.pull_request.state === 'open') {
|
||||
await github.rest.pulls.update({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: prNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
console.log(`Closed PR #${prNumber}`);
|
||||
}
|
||||
|
||||
// Cancel all other in-progress and queued workflow runs for this PR
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
for (const status of ['in_progress', 'queued']) {
|
||||
const runs = await github.paginate(
|
||||
github.rest.actions.listWorkflowRunsForRepo,
|
||||
{ owner, repo, head_sha: headSha, status, per_page: 100 },
|
||||
);
|
||||
for (const run of runs) {
|
||||
if (run.id === context.runId) continue;
|
||||
try {
|
||||
await github.rest.actions.cancelWorkflowRun({
|
||||
owner, repo, run_id: run.id,
|
||||
});
|
||||
console.log(`Cancelled ${status} run ${run.id} (${run.name})`);
|
||||
} catch (err) {
|
||||
console.log(`Could not cancel run ${run.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const reason = !hasLink
|
||||
? 'PR must reference an issue using auto-close keywords (e.g., "Fixes #123").'
|
||||
: 'PR author must be assigned to the linked issue.';
|
||||
core.setFailed(reason);
|
||||
@@ -0,0 +1,205 @@
|
||||
# Automatically tag issues as "external" or "internal" based on whether
|
||||
# the author is a member of the langchain-ai GitHub organization, and
|
||||
# apply contributor tier labels to external contributors based on their
|
||||
# merged PR history.
|
||||
#
|
||||
# NOTE: PR labeling (including external/internal, tier, size, file, and
|
||||
# title labels) is handled by pr_labeler.yml. This workflow handles
|
||||
# issues only.
|
||||
#
|
||||
# Config (trustedThreshold, labelColor) is read from
|
||||
# .github/scripts/pr-labeler-config.json to stay in sync with
|
||||
# pr_labeler.yml.
|
||||
#
|
||||
# Setup Requirements:
|
||||
# 1. Create a GitHub App with permissions:
|
||||
# - Repository: Issues (write)
|
||||
# - Organization: Members (read)
|
||||
# 2. Install the app on your organization and this repository
|
||||
# 3. Add these repository secrets:
|
||||
# - ORG_MEMBERSHIP_APP_CLIENT_ID: Your app's client ID
|
||||
# - ORG_MEMBERSHIP_APP_PRIVATE_KEY: Your app's private key
|
||||
#
|
||||
# The GitHub App token is required to check private organization membership.
|
||||
# Without it, the workflow will fail.
|
||||
|
||||
name: Tag External Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
max_items:
|
||||
description: "Maximum number of open issues to process"
|
||||
default: "100"
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.issue.number || github.run_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
tag-external:
|
||||
if: github.repository_owner == 'langchain-ai' && github.event_name != 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Check if contributor is external
|
||||
if: steps.app-token.outcome == 'success'
|
||||
id: check-membership
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const author = context.payload.sender.login;
|
||||
const { isExternal } = await h.checkMembership(
|
||||
author, context.payload.sender.type,
|
||||
);
|
||||
core.setOutput('is-external', isExternal ? 'true' : 'false');
|
||||
|
||||
- name: Apply contributor tier label
|
||||
if: steps.check-membership.outputs.is-external == 'true'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
# GITHUB_TOKEN is fine here — no downstream workflow chains
|
||||
# off tier labels on issues (unlike PRs where App token is
|
||||
# needed for require_issue_link.yml).
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const issue = context.payload.issue;
|
||||
// new-contributor is only meaningful on PRs, not issues
|
||||
await h.applyTierLabel(issue.number, issue.user.login, { skipNewContributor: true });
|
||||
|
||||
- name: Add external/internal label
|
||||
if: steps.check-membership.outputs.is-external != ''
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.payload.issue.number;
|
||||
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const label = '${{ steps.check-membership.outputs.is-external }}' === 'true'
|
||||
? 'external' : 'internal';
|
||||
await h.ensureLabel(label);
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number, labels: [label],
|
||||
});
|
||||
console.log(`Added '${label}' label to issue #${issue_number}`);
|
||||
|
||||
backfill:
|
||||
if: github.repository_owner == 'langchain-ai' && github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v6
|
||||
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
client-id: ${{ secrets.ORG_MEMBERSHIP_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.ORG_MEMBERSHIP_APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Backfill labels on open issues
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const rawMax = '${{ inputs.max_items }}';
|
||||
const maxItems = parseInt(rawMax, 10);
|
||||
if (isNaN(maxItems) || maxItems <= 0) {
|
||||
core.setFailed(`Invalid max_items: "${rawMax}" — must be a positive integer`);
|
||||
return;
|
||||
}
|
||||
|
||||
const { h } = require('./.github/scripts/pr-labeler.js').loadAndInit(github, owner, repo, core);
|
||||
|
||||
const tierLabels = ['trusted-contributor'];
|
||||
for (const name of tierLabels) {
|
||||
await h.ensureLabel(name);
|
||||
}
|
||||
|
||||
const contributorCache = new Map();
|
||||
|
||||
const issues = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
|
||||
let processed = 0;
|
||||
let failures = 0;
|
||||
for (const issue of issues) {
|
||||
if (processed >= maxItems) break;
|
||||
if (issue.pull_request) continue;
|
||||
|
||||
try {
|
||||
const author = issue.user.login;
|
||||
const info = await h.getContributorInfo(contributorCache, author, issue.user.type);
|
||||
|
||||
const labels = [info.isExternal ? 'external' : 'internal'];
|
||||
if (info.isExternal && info.mergedCount != null && info.mergedCount >= h.trustedThreshold) {
|
||||
labels.push('trusted-contributor');
|
||||
}
|
||||
|
||||
// Ensure all labels exist before batch add
|
||||
for (const name of labels) {
|
||||
await h.ensureLabel(name);
|
||||
}
|
||||
|
||||
// Remove stale tier labels
|
||||
const currentLabels = (await github.paginate(
|
||||
github.rest.issues.listLabelsOnIssue,
|
||||
{ owner, repo, issue_number: issue.number, per_page: 100 },
|
||||
)).map(l => l.name ?? '');
|
||||
for (const name of currentLabels) {
|
||||
if (tierLabels.includes(name) && !labels.includes(name)) {
|
||||
try {
|
||||
await github.rest.issues.removeLabel({
|
||||
owner, repo, issue_number: issue.number, name,
|
||||
});
|
||||
} catch (e) {
|
||||
if (e.status !== 404) throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: issue.number, labels,
|
||||
});
|
||||
console.log(`Issue #${issue.number} (${author}): ${labels.join(', ')}`);
|
||||
processed++;
|
||||
} catch (e) {
|
||||
failures++;
|
||||
core.warning(`Failed to process issue #${issue.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\nBackfill complete. Processed ${processed} issues, ${failures} failures. ${contributorCache.size} unique authors.`);
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
.vs/
|
||||
.claude/
|
||||
.idea/
|
||||
#Emacs backup
|
||||
*~
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# Google GitHub Actions credentials files created by:
|
||||
# https://github.com/google-github-actions/auth
|
||||
#
|
||||
# That action recommends adding this gitignore to prevent accidentally committing keys.
|
||||
gha-creds-*.json
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
.codspeed/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
notebooks/
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.envrc
|
||||
.venv*
|
||||
venv*
|
||||
env/
|
||||
ENV/
|
||||
env.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.mypy_cache_test/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# macOS display setting files
|
||||
.DS_Store
|
||||
|
||||
# Wandb directory
|
||||
wandb/
|
||||
|
||||
# asdf tool versions
|
||||
.tool-versions
|
||||
/.ruff_cache/
|
||||
|
||||
*.pkl
|
||||
*.bin
|
||||
|
||||
# integration test artifacts
|
||||
data_map*
|
||||
\[('_type', 'fake'), ('stop', None)]
|
||||
|
||||
# Replit files
|
||||
*replit*
|
||||
|
||||
node_modules
|
||||
|
||||
prof
|
||||
virtualenv/
|
||||
scratch/
|
||||
|
||||
.langgraph_api/
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"MD013": false,
|
||||
"MD024": {
|
||||
"siblings_only": true
|
||||
},
|
||||
"MD025": false,
|
||||
"MD033": false,
|
||||
"MD034": false,
|
||||
"MD036": false,
|
||||
"MD041": false,
|
||||
"MD046": {
|
||||
"style": "fenced"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"docs-langchain": {
|
||||
"type": "http",
|
||||
"url": "https://docs.langchain.com/mcp"
|
||||
},
|
||||
"reference-langchain": {
|
||||
"type": "http",
|
||||
"url": "https://reference.langchain.com/mcp"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.3.0
|
||||
hooks:
|
||||
- id: no-commit-to-branch # prevent direct commits to protected branches
|
||||
args: ["--branch", "master"]
|
||||
- id: check-yaml # validate YAML syntax
|
||||
args: ["--unsafe"] # allow custom tags
|
||||
- id: check-toml # validate TOML syntax
|
||||
- id: end-of-file-fixer # ensure files end with a newline
|
||||
- id: trailing-whitespace # remove trailing whitespace from lines
|
||||
exclude: \.ambr$
|
||||
|
||||
# Text normalization hooks for consistent formatting
|
||||
- repo: https://github.com/sirosen/texthooks
|
||||
rev: 0.6.8
|
||||
hooks:
|
||||
- id: fix-smartquotes # replace curly quotes with straight quotes
|
||||
- id: fix-spaces # replace non-standard spaces (e.g., non-breaking) with regular spaces
|
||||
|
||||
# Per-package format and lint hooks for the monorepo
|
||||
- repo: local
|
||||
hooks:
|
||||
- id: core
|
||||
name: format and lint core
|
||||
language: system
|
||||
entry: make -C libs/core format lint
|
||||
files: ^libs/core/
|
||||
pass_filenames: false
|
||||
- id: langchain
|
||||
name: format and lint langchain
|
||||
language: system
|
||||
entry: make -C libs/langchain format lint
|
||||
files: ^libs/langchain/
|
||||
pass_filenames: false
|
||||
- id: standard-tests
|
||||
name: format and lint standard-tests
|
||||
language: system
|
||||
entry: make -C libs/standard-tests format lint
|
||||
files: ^libs/standard-tests/
|
||||
pass_filenames: false
|
||||
- id: text-splitters
|
||||
name: format and lint text-splitters
|
||||
language: system
|
||||
entry: make -C libs/text-splitters format lint
|
||||
files: ^libs/text-splitters/
|
||||
pass_filenames: false
|
||||
- id: anthropic
|
||||
name: format and lint partners/anthropic
|
||||
language: system
|
||||
entry: make -C libs/partners/anthropic format lint
|
||||
files: ^libs/partners/anthropic/
|
||||
pass_filenames: false
|
||||
- id: chroma
|
||||
name: format and lint partners/chroma
|
||||
language: system
|
||||
entry: make -C libs/partners/chroma format lint
|
||||
files: ^libs/partners/chroma/
|
||||
pass_filenames: false
|
||||
- id: exa
|
||||
name: format and lint partners/exa
|
||||
language: system
|
||||
entry: make -C libs/partners/exa format lint
|
||||
files: ^libs/partners/exa/
|
||||
pass_filenames: false
|
||||
- id: fireworks
|
||||
name: format and lint partners/fireworks
|
||||
language: system
|
||||
entry: make -C libs/partners/fireworks format lint
|
||||
files: ^libs/partners/fireworks/
|
||||
pass_filenames: false
|
||||
- id: groq
|
||||
name: format and lint partners/groq
|
||||
language: system
|
||||
entry: make -C libs/partners/groq format lint
|
||||
files: ^libs/partners/groq/
|
||||
pass_filenames: false
|
||||
- id: huggingface
|
||||
name: format and lint partners/huggingface
|
||||
language: system
|
||||
entry: make -C libs/partners/huggingface format lint
|
||||
files: ^libs/partners/huggingface/
|
||||
pass_filenames: false
|
||||
- id: mistralai
|
||||
name: format and lint partners/mistralai
|
||||
language: system
|
||||
entry: make -C libs/partners/mistralai format lint
|
||||
files: ^libs/partners/mistralai/
|
||||
pass_filenames: false
|
||||
- id: nomic
|
||||
name: format and lint partners/nomic
|
||||
language: system
|
||||
entry: make -C libs/partners/nomic format lint
|
||||
files: ^libs/partners/nomic/
|
||||
pass_filenames: false
|
||||
- id: ollama
|
||||
name: format and lint partners/ollama
|
||||
language: system
|
||||
entry: make -C libs/partners/ollama format lint
|
||||
files: ^libs/partners/ollama/
|
||||
pass_filenames: false
|
||||
- id: openai
|
||||
name: format and lint partners/openai
|
||||
language: system
|
||||
entry: make -C libs/partners/openai format lint
|
||||
files: ^libs/partners/openai/
|
||||
pass_filenames: false
|
||||
- id: qdrant
|
||||
name: format and lint partners/qdrant
|
||||
language: system
|
||||
entry: make -C libs/partners/qdrant format lint
|
||||
files: ^libs/partners/qdrant/
|
||||
pass_filenames: false
|
||||
- id: core-version
|
||||
name: check core version consistency
|
||||
language: system
|
||||
entry: make -C libs/core check_version
|
||||
files: ^libs/core/(pyproject\.toml|langchain_core/version\.py)$
|
||||
pass_filenames: false
|
||||
- id: langchain-v1-version
|
||||
name: check langchain version consistency
|
||||
language: system
|
||||
entry: make -C libs/langchain_v1 check_version
|
||||
files: ^libs/langchain_v1/(pyproject\.toml|langchain/__init__\.py)$
|
||||
pass_filenames: false
|
||||
- id: anthropic-version
|
||||
name: check anthropic version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/anthropic check_version
|
||||
files: ^libs/partners/anthropic/(pyproject\.toml|langchain_anthropic/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: chroma-version
|
||||
name: check chroma version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/chroma check_version
|
||||
files: ^libs/partners/chroma/(pyproject\.toml|langchain_chroma/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: deepseek-version
|
||||
name: check deepseek version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/deepseek check_version
|
||||
files: ^libs/partners/deepseek/(pyproject\.toml|langchain_deepseek/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: exa-version
|
||||
name: check exa version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/exa check_version
|
||||
files: ^libs/partners/exa/(pyproject\.toml|langchain_exa/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: fireworks-version
|
||||
name: check fireworks version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/fireworks check_version
|
||||
files: ^libs/partners/fireworks/(pyproject\.toml|langchain_fireworks/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: groq-version
|
||||
name: check groq version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/groq check_version
|
||||
files: ^libs/partners/groq/(pyproject\.toml|langchain_groq/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: huggingface-version
|
||||
name: check huggingface version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/huggingface check_version
|
||||
files: ^libs/partners/huggingface/(pyproject\.toml|langchain_huggingface/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: mistralai-version
|
||||
name: check mistralai version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/mistralai check_version
|
||||
files: ^libs/partners/mistralai/(pyproject\.toml|langchain_mistralai/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: nomic-version
|
||||
name: check nomic version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/nomic check_version
|
||||
files: ^libs/partners/nomic/(pyproject\.toml|langchain_nomic/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: ollama-version
|
||||
name: check ollama version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/ollama check_version
|
||||
files: ^libs/partners/ollama/(pyproject\.toml|langchain_ollama/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: openai-version
|
||||
name: check openai version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/openai check_version
|
||||
files: ^libs/partners/openai/(pyproject\.toml|langchain_openai/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: openrouter-version
|
||||
name: check openrouter version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/openrouter check_version
|
||||
files: ^libs/partners/openrouter/(pyproject\.toml|langchain_openrouter/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: perplexity-version
|
||||
name: check perplexity version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/perplexity check_version
|
||||
files: ^libs/partners/perplexity/(pyproject\.toml|langchain_perplexity/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: qdrant-version
|
||||
name: check qdrant version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/qdrant check_version
|
||||
files: ^libs/partners/qdrant/(pyproject\.toml|langchain_qdrant/_version\.py)$
|
||||
pass_filenames: false
|
||||
- id: xai-version
|
||||
name: check xai version consistency
|
||||
language: system
|
||||
entry: make -C libs/partners/xai check_version
|
||||
files: ^libs/partners/xai/(pyproject\.toml|langchain_xai/_version\.py)$
|
||||
pass_filenames: false
|
||||
Vendored
+19
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"ms-python.python",
|
||||
"charliermarsh.ruff",
|
||||
"ms-python.mypy-type-checker",
|
||||
"ms-toolsai.jupyter",
|
||||
"ms-toolsai.jupyter-keymap",
|
||||
"ms-toolsai.jupyter-renderers",
|
||||
"yzhang.markdown-all-in-one",
|
||||
"davidanson.vscode-markdownlint",
|
||||
"bierner.markdown-mermaid",
|
||||
"bierner.markdown-preview-github-styles",
|
||||
"eamodio.gitlens",
|
||||
"github.vscode-pull-request-github",
|
||||
"github.vscode-github-actions",
|
||||
"redhat.vscode-yaml",
|
||||
"editorconfig.editorconfig",
|
||||
],
|
||||
}
|
||||
Vendored
+78
@@ -0,0 +1,78 @@
|
||||
{
|
||||
"python.analysis.include": [
|
||||
"libs/**",
|
||||
],
|
||||
"python.analysis.exclude": [
|
||||
"**/node_modules",
|
||||
"**/__pycache__",
|
||||
"**/.pytest_cache",
|
||||
"**/.*",
|
||||
],
|
||||
"python.analysis.autoImportCompletions": true,
|
||||
"python.analysis.typeCheckingMode": "basic",
|
||||
"python.testing.cwd": "${workspaceFolder}",
|
||||
"python.linting.enabled": true,
|
||||
"python.linting.ruffEnabled": true,
|
||||
"[python]": {
|
||||
"editor.formatOnSave": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.organizeImports.ruff": "explicit",
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "charliermarsh.ruff"
|
||||
},
|
||||
"editor.rulers": [
|
||||
88
|
||||
],
|
||||
"editor.tabSize": 4,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.trimAutoWhitespace": true,
|
||||
"files.trimTrailingWhitespace": true,
|
||||
"files.insertFinalNewline": true,
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/.pytest_cache": true,
|
||||
"**/*.pyc": true,
|
||||
"**/.mypy_cache": true,
|
||||
"**/.ruff_cache": true,
|
||||
"_dist/**": true,
|
||||
"**/node_modules": true,
|
||||
"**/.git": false
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/*.pyc": true,
|
||||
"_dist/**": true,
|
||||
"**/node_modules": true,
|
||||
"**/.git": true,
|
||||
"uv.lock": true,
|
||||
"yarn.lock": true
|
||||
},
|
||||
"git.autofetch": true,
|
||||
"git.enableSmartCommit": true,
|
||||
"jupyter.askForKernelRestart": false,
|
||||
"jupyter.interactiveWindow.textEditor.executeSelection": true,
|
||||
"[markdown]": {
|
||||
"editor.wordWrap": "on",
|
||||
"editor.quickSuggestions": {
|
||||
"comments": "off",
|
||||
"strings": "off",
|
||||
"other": "off"
|
||||
}
|
||||
},
|
||||
"[yaml]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true
|
||||
},
|
||||
"[json]": {
|
||||
"editor.tabSize": 2,
|
||||
"editor.insertSpaces": true
|
||||
},
|
||||
"python.terminal.activateEnvironment": false,
|
||||
"python.defaultInterpreterPath": "./.venv/bin/python",
|
||||
"github.copilot.chat.commitMessageGeneration.instructions": [
|
||||
{
|
||||
"file": ".github/workflows/pr_lint.yml"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
# Global development guidelines for the LangChain monorepo
|
||||
|
||||
This document provides context to understand the LangChain Python project and assist with development.
|
||||
|
||||
## Project architecture and context
|
||||
|
||||
### Monorepo structure
|
||||
|
||||
This is a Python monorepo with multiple independently versioned packages that use `uv`.
|
||||
|
||||
```txt
|
||||
langchain/
|
||||
├── libs/
|
||||
│ ├── core/ # `langchain-core` primitives and base abstractions
|
||||
│ ├── langchain/ # `langchain-classic` (legacy, no new features)
|
||||
│ ├── langchain_v1/ # Actively maintained `langchain` package
|
||||
│ ├── partners/ # Third-party integrations
|
||||
│ │ ├── openai/ # OpenAI models and embeddings
|
||||
│ │ ├── anthropic/ # Anthropic (Claude) integration
|
||||
│ │ ├── ollama/ # Local model support
|
||||
│ │ └── ... (other integrations maintained by the LangChain team)
|
||||
│ ├── text-splitters/ # Document chunking utilities
|
||||
│ ├── standard-tests/ # Shared test suite for integrations
|
||||
│ ├── model-profiles/ # Model configuration profiles
|
||||
├── .github/ # CI/CD workflows and templates
|
||||
├── .vscode/ # VSCode IDE standard settings and recommended extensions
|
||||
└── README.md # Information about LangChain
|
||||
```
|
||||
|
||||
- **Core layer** (`langchain-core`): Base abstractions, interfaces, and protocols. Users should not need to know about this layer directly.
|
||||
- **Implementation layer** (`langchain`): Concrete implementations and high-level public utilities
|
||||
- **Integration layer** (`partners/`): Third-party service integrations. Note that this monorepo is not exhaustive of all LangChain integrations; some are maintained in separate repos, such as `langchain-ai/langchain-google` and `langchain-ai/langchain-aws`. Usually these repos are cloned at the same level as this monorepo, so if needed, you can refer to their code directly by navigating to `../langchain-google/` from this monorepo.
|
||||
- **Testing layer** (`standard-tests/`): Standardized integration tests for partner integrations
|
||||
|
||||
### Development tools & commands
|
||||
|
||||
- `uv` – Fast Python package installer and resolver (replaces pip/poetry)
|
||||
- `make` – Task runner for common development commands. Feel free to look at the `Makefile` for available commands and usage patterns.
|
||||
- `ruff` – Fast Python linter and formatter
|
||||
- `mypy` – Static type checking
|
||||
- `pytest` – Testing framework
|
||||
|
||||
This monorepo uses `uv` for dependency management. Local development uses editable installs: `[tool.uv.sources]`
|
||||
|
||||
Each package in `libs/` has its own `pyproject.toml` and `uv.lock`.
|
||||
|
||||
Before running your tests, set up all packages by running:
|
||||
|
||||
```bash
|
||||
# For all groups
|
||||
uv sync --all-groups
|
||||
|
||||
# or, to install a specific group only:
|
||||
uv sync --group test
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run unit tests (no network)
|
||||
make test
|
||||
|
||||
# Run specific test file
|
||||
uv run --group test pytest tests/unit_tests/test_specific.py
|
||||
```
|
||||
|
||||
```bash
|
||||
# Lint code
|
||||
make lint
|
||||
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Type checking
|
||||
uv run --group lint mypy .
|
||||
```
|
||||
|
||||
#### Environment and dependency management
|
||||
|
||||
Use `uv` for all environment and dependency operations in this monorepo. Do not invoke `pip`, `poetry`, or `conda` directly.
|
||||
|
||||
- Let `uv` manage the interpreter and virtual environments — `uv sync` and `uv run` operate without manual `source .venv/bin/activate`. Do not create ad-hoc virtual environments outside the package directory.
|
||||
- Each package targets its own supported Python range via its `pyproject.toml`; do not pin a global Python version. If you need an interpreter explicitly, defer to the package's `requires-python` rather than assuming system Python.
|
||||
- Install dependencies explicitly through `uv sync` (optionally `--group <name>` / `--all-groups`); never let them install implicitly.
|
||||
- Don't mix environments within a session, and don't add new dependencies unless strictly required — when you do, justify them (recent releases/commits, adoption).
|
||||
|
||||
#### Key config files
|
||||
|
||||
- pyproject.toml: Main workspace configuration with dependency groups
|
||||
- uv.lock: Locked dependencies for reproducible builds
|
||||
- Makefile: Development tasks
|
||||
|
||||
#### PR and commit titles
|
||||
|
||||
Follow Conventional Commits. See `.github/workflows/pr_lint.yml` for allowed types and scopes. All titles must include a scope with no exceptions — even for the main `langchain` package.
|
||||
|
||||
- Start the text after `type(scope):` with a lowercase letter, unless the first word is a proper noun (e.g. `Azure`, `GitHub`, `OpenAI`) or a named entity (class, function, method, parameter, or variable name).
|
||||
- Wrap named entities in backticks so they render as code. Proper nouns are left unadorned.
|
||||
- Keep titles short and descriptive — save detail for the body.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
feat(langchain): add new chat completion feature
|
||||
fix(core): resolve type hinting issue in vector store
|
||||
chore(anthropic): update infrastructure dependencies
|
||||
feat(langchain): `ls_agent_type` tag on `create_agent` calls
|
||||
fix(openai): infer Azure chat profiles from model name
|
||||
```
|
||||
|
||||
#### Branch naming
|
||||
|
||||
Branches should be prefixed `<github-username>/<scope>/<short-description>`:
|
||||
|
||||
- `<github-username>` — the author's GitHub login (e.g. `mdrxy`).
|
||||
- `<scope>` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.).
|
||||
- `<short-description>` — kebab-case, brief, no trailing slash.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
mdrxy/anthropic/normalize-tool-call-ids
|
||||
mdrxy/core/vector-store-type-hints
|
||||
mdrxy/infra/agents-md-branch
|
||||
```
|
||||
|
||||
#### PR descriptions
|
||||
|
||||
The description *is* the summary — do not add a `# Summary` header.
|
||||
|
||||
- When the PR closes an issue, lead with the closing keyword on its own line at the very top, followed by a horizontal rule and then the body:
|
||||
|
||||
```txt
|
||||
Closes #123
|
||||
|
||||
---
|
||||
|
||||
<rest of description>
|
||||
```
|
||||
|
||||
Only `Closes`, `Fixes`, and `Resolves` auto-close the referenced issue on merge. `Related:` or similar labels are informational and do not close anything.
|
||||
|
||||
- Explain the *why*: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary.
|
||||
- Write for readers who may be unfamiliar with this area of the codebase. Avoid insider shorthand and prefer language that is friendly to public viewers — this aids interpretability.
|
||||
- Do **not** cite line numbers; they go stale as soon as the file changes.
|
||||
- Rarely include full file paths or filenames. Reference the affected symbol, class, or subsystem by name instead.
|
||||
- Wrap class, function, method, parameter, and variable names in backticks.
|
||||
- For net new features or behavior-changing bugfixes, PR descriptions should include a `## Release note` section that states the user-visible change in release-note-ready language.
|
||||
- Skip dedicated "Test plan" or "Testing" sections in most cases. Mention tests only when coverage is non-obvious, risky, or otherwise notable.
|
||||
- Call out areas of the change that require careful review.
|
||||
- Add a brief disclaimer noting AI-agent involvement in the contribution.
|
||||
|
||||
## Core development principles
|
||||
|
||||
### Maintain stable public interfaces
|
||||
|
||||
CRITICAL: Always attempt to preserve function signatures, argument positions, and names for exported/public methods. Do not make breaking changes.
|
||||
You should warn the developer for any function signature changes, regardless of whether they look breaking or not.
|
||||
|
||||
**Before making ANY changes to public APIs:**
|
||||
|
||||
- Check if the function/class is exported in `__init__.py`
|
||||
- Look for existing usage patterns in tests and examples
|
||||
- Use keyword-only arguments for new parameters: `*, new_param: str = "default"`
|
||||
- Mark experimental features clearly with docstring warnings (using MkDocs Material admonitions, like `!!! warning`)
|
||||
|
||||
Ask: "Would this change break someone's code if they used it last week?"
|
||||
|
||||
### Code quality standards
|
||||
|
||||
All Python code MUST include type hints and return types.
|
||||
|
||||
```python title="Example"
|
||||
def filter_unknown_users(users: list[str], known_users: set[str]) -> list[str]:
|
||||
"""Single line description of the function.
|
||||
|
||||
Any additional context about the function can go here.
|
||||
|
||||
Args:
|
||||
users: List of user identifiers to filter.
|
||||
known_users: Set of known/valid user identifiers.
|
||||
|
||||
Returns:
|
||||
List of users that are not in the `known_users` set.
|
||||
"""
|
||||
```
|
||||
|
||||
- Use descriptive, self-explanatory variable names.
|
||||
- Follow existing patterns in the codebase you're modifying
|
||||
- Attempt to break up complex functions (>20 lines) into smaller, focused functions where it makes sense
|
||||
|
||||
### Testing requirements
|
||||
|
||||
Every new feature or bugfix MUST be covered by unit tests.
|
||||
|
||||
- Unit tests: `tests/unit_tests/` (no network calls allowed)
|
||||
- Integration tests: `tests/integration_tests/` (network calls permitted)
|
||||
- We use `pytest` as the testing framework; if in doubt, check other existing tests for examples.
|
||||
- The testing file structure should mirror the source code structure.
|
||||
|
||||
**Checklist:**
|
||||
|
||||
- [ ] Tests fail when your new logic is broken
|
||||
- [ ] Happy path is covered
|
||||
- [ ] Edge cases and error conditions are tested
|
||||
- [ ] Use fixtures/mocks for external dependencies
|
||||
- [ ] Tests are deterministic (no flaky tests)
|
||||
- [ ] Does the test suite fail if your new logic is broken?
|
||||
|
||||
### Security and risk assessment
|
||||
|
||||
- No `eval()`, `exec()`, or `pickle` on user-controlled input
|
||||
- Proper exception handling (no bare `except:`) and use a `msg` variable for error messages
|
||||
- Remove unreachable/commented code before committing
|
||||
- Race conditions or resource leaks (file handles, sockets, threads).
|
||||
- Ensure proper resource cleanup (file handles, connections)
|
||||
|
||||
### Documentation standards
|
||||
|
||||
Use Google-style docstrings with Args section for all public functions.
|
||||
|
||||
```python title="Example"
|
||||
def send_email(to: str, msg: str, *, priority: str = "normal") -> bool:
|
||||
"""Send an email to a recipient with specified priority.
|
||||
|
||||
Any additional context about the function can go here.
|
||||
|
||||
Args:
|
||||
to: The email address of the recipient.
|
||||
msg: The message body to send.
|
||||
priority: Email priority level.
|
||||
|
||||
Returns:
|
||||
`True` if email was sent successfully, `False` otherwise.
|
||||
|
||||
Raises:
|
||||
InvalidEmailError: If the email address format is invalid.
|
||||
SMTPConnectionError: If unable to connect to email server.
|
||||
"""
|
||||
```
|
||||
|
||||
- Types go in function signatures, NOT in docstrings
|
||||
- If a default is present, DO NOT repeat it in the docstring unless there is post-processing or it is set conditionally.
|
||||
- Focus on "why" rather than "what" in descriptions
|
||||
- Document all parameters, return values, and exceptions
|
||||
- Keep descriptions concise but clear
|
||||
- Ensure American English spelling (e.g., "behavior", not "behaviour")
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
#### Model references in docs and examples
|
||||
|
||||
Always use the latest generally available (GA) models when referencing LLMs in docstrings and illustrative code snippets. Avoid preview or beta identifiers unless the model has no GA equivalent. Outdated model names signal stale code and confuse users.
|
||||
|
||||
Before writing or updating model references, verify current model IDs against the provider's official docs. Do not rely on memorized or cached model names — they go stale quickly.
|
||||
|
||||
Changing **shipped default parameter values** in code (e.g., a `model=` kwarg default in a class constructor) may constitute a breaking change — see "Maintain stable public interfaces" above. This guidance applies to documentation and examples, not code defaults.
|
||||
|
||||
For model *profile data* (capability flags, context windows), use the `langchain-profiles` CLI described below.
|
||||
|
||||
## Model profiles
|
||||
|
||||
Model profiles are generated using the `langchain-profiles` CLI in `libs/model-profiles`. The `--data-dir` must point to the directory containing `profile_augmentations.toml`, not the top-level package directory.
|
||||
|
||||
```bash
|
||||
# Run from libs/model-profiles
|
||||
cd libs/model-profiles
|
||||
|
||||
# Refresh profiles for a partner in this repo
|
||||
uv run langchain-profiles refresh --provider openai --data-dir ../partners/openai/langchain_openai/data
|
||||
|
||||
# Refresh profiles for a partner in an external repo (requires echo y to confirm)
|
||||
echo y | uv run langchain-profiles refresh --provider google --data-dir /path/to/langchain-google/libs/genai/langchain_google_genai/data
|
||||
```
|
||||
|
||||
Example partners with profiles in this repo:
|
||||
|
||||
- `libs/partners/openai/langchain_openai/data/` (provider: `openai`)
|
||||
- `libs/partners/anthropic/langchain_anthropic/data/` (provider: `anthropic`)
|
||||
- `libs/partners/perplexity/langchain_perplexity/data/` (provider: `perplexity`)
|
||||
|
||||
The `echo y |` pipe is required when `--data-dir` is outside the `libs/model-profiles` working directory.
|
||||
|
||||
## CI/CD infrastructure
|
||||
|
||||
### Release process
|
||||
|
||||
Each partner package is released independently. The full flow is:
|
||||
|
||||
1. **Version bump PR.** Create a PR that bumps three files by one line each:
|
||||
- `langchain_<partner>/_version.py` — `__version__`
|
||||
- `pyproject.toml` — `version`
|
||||
- `uv.lock` — run `uv lock` from the package directory. If the diff includes unrelated changes (e.g. environment-dependent marker lines from a different local Python version), revert them and keep only the `version = "..."` line for the package being released
|
||||
|
||||
Title follows Conventional Commits: `release(<partner>): <version>` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/<partner>-<version>`.
|
||||
|
||||
Patch vs. minor bump follows in-repo precedent: within a `0.x` series, fixes and additive features get a patch bump (e.g. `session_id` field → 0.2.1→0.2.2, `parallel_tool_calls` → 0.2.3→0.2.4).
|
||||
|
||||
2. **Merge the PR** to `master`.
|
||||
|
||||
3. **Trigger the release workflow.** Run `gh workflow run` against the "🚀 Package Release" workflow (`_release.yml`, file ID `63880841`):
|
||||
|
||||
```bash
|
||||
gh workflow run 63880841 --repo langchain-ai/langchain \
|
||||
-f working-directory=<partner> -f release-version=<version>
|
||||
```
|
||||
|
||||
`working-directory` is the short partner name from the workflow's dropdown (e.g. `openrouter`, not `libs/partners/openrouter`).
|
||||
|
||||
4. **The workflow handles everything else automatically** — do **not** create a GitHub release or tag manually. The `mark-release` job (using `ncipollo/release-action`) creates the GitHub release, tag, and release notes after PyPI publish succeeds. The release notes body is auto-generated from commit history between the previous tag and HEAD.
|
||||
|
||||
Monitor the run:
|
||||
|
||||
```bash
|
||||
gh run view <run-id> --repo langchain-ai/langchain
|
||||
```
|
||||
|
||||
The full job chain is: build → release-notes → pre-release-checks → TestPyPI publish → PyPI publish → tag GitHub release.
|
||||
|
||||
### PR labeling and linting
|
||||
|
||||
**Title linting** (`.github/workflows/pr_lint.yml`)
|
||||
|
||||
**Auto-labeling:**
|
||||
|
||||
- `.github/workflows/pr_labeler.yml` – Unified PR labeler (size, file, title, external/internal, contributor tier)
|
||||
- `.github/workflows/pr_labeler_backfill.yml` – Manual backfill of PR labels on open PRs
|
||||
- `.github/workflows/auto-label-by-package.yml` – Issue labeling by package
|
||||
- `.github/workflows/tag-external-issues.yml` – Issue external/internal classification
|
||||
|
||||
### Integration test tracing (LangSmith)
|
||||
|
||||
Scheduled and manually dispatched integration tests (`integration_tests.yml`) trace every run to LangSmith so failures link back to the originating Actions run. (`_release.yml` runs integration tests too, but does not currently configure LangSmith tracing.)
|
||||
|
||||
**Env vars set by CI:**
|
||||
|
||||
- `LANGSMITH_API_KEY` — authenticates to LangSmith (repo secret, scoped to the "Scheduled testing" GitHub environment in `integration_tests.yml`).
|
||||
- `LANGSMITH_TRACING: "true"` — enables tracing for the test process.
|
||||
- `LANGSMITH_PROJECT` — the project traces are sent to. Defaults to `scheduled-testing-py` via a repo variable override: `${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}`. To change the project, set the `LANGSMITH_PROJECT` repository variable in GitHub settings — do not hardcode it in the workflow.
|
||||
- `LANGSMITH_TAGS` — comma-separated tags identifying the run: `github-actions`, the matrix working directory (e.g. `libs/partners/openai`), the Python version, and the commit SHA.
|
||||
- `LANGSMITH_METADATA` — a JSON object built by the "Build LangSmith Metadata" step, containing `github_sha`, `github_run_id`, `github_run_attempt`, `github_run_url`, `github_workflow`, `github_event`, `github_ref`, `working_directory`, and `python_version`.
|
||||
|
||||
**The tracing bridge plugin:** The LangSmith SDK does not natively read `LANGSMITH_TAGS` or `LANGSMITH_METADATA` from the environment. The pytest plugin at `libs/standard-tests/langchain_tests/_langsmith_plugin.py` bridges that gap by entering `langsmith.run_helpers.tracing_context` for the duration of the test session. It only activates when `GITHUB_ACTIONS=true`, so local development is unaffected. Auto-discovered via the `pytest11` entry point in any package that depends on `langchain-tests`.
|
||||
|
||||
**Unit test isolation:** Unit tests must never make network calls or send traces. The `make test` target in the `libs/core` Makefile uses `env -u` to unset the tracing vars (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGSMITH_API_KEY`, `LANGSMITH_TRACING`, `LANGCHAIN_PROJECT`) before running pytest. Additionally, `libs/core/tests/unit_tests/runnables/conftest.py` has a session-scoped autouse fixture that explicitly disables tracing for runnable unit tests, restoring the original environment afterward.
|
||||
|
||||
### Adding a new partner to CI
|
||||
|
||||
When adding a new partner package, update these files:
|
||||
|
||||
- `.github/ISSUE_TEMPLATE/*.yml` – Add to package dropdown
|
||||
- `.github/dependabot.yml` – Add dependency update entry
|
||||
- `.github/scripts/pr-labeler-config.json` – Add file rule and scope-to-label mapping
|
||||
- `.github/workflows/_release.yml` – Add API key secrets if needed
|
||||
- `.github/workflows/auto-label-by-package.yml` – Add package label
|
||||
- `.github/workflows/check_diffs.yml` – Add to change detection
|
||||
- `.github/workflows/integration_tests.yml` – Add integration test config
|
||||
- `.github/workflows/pr_lint.yml` – Add to allowed scopes
|
||||
|
||||
## GitHub Actions & Workflows
|
||||
|
||||
This repository require actions to be pinned to a full-length commit SHA. Attempting to use a tag will fail. Use the `gh` cli to query. Verify tags are not annotated tag objects (which would need dereferencing).
|
||||
|
||||
## Additional resources
|
||||
|
||||
- **Documentation:** https://docs.langchain.com/oss/python/langchain/overview and source at https://github.com/langchain-ai/docs or `../docs/`. Prefer the local install and use file search tools for best results. If needed, use the docs MCP server as defined in `.mcp.json` for programmatic access.
|
||||
- **Contributing Guide:** [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview)
|
||||
@@ -0,0 +1,8 @@
|
||||
cff-version: 1.2.0
|
||||
message: "If you use this software, please cite it as below."
|
||||
authors:
|
||||
- family-names: "Chase"
|
||||
given-names: "Harrison"
|
||||
title: "LangChain"
|
||||
date-released: 2022-10-17
|
||||
url: "https://github.com/langchain-ai/langchain"
|
||||
@@ -0,0 +1,364 @@
|
||||
# Global development guidelines for the LangChain monorepo
|
||||
|
||||
This document provides context to understand the LangChain Python project and assist with development.
|
||||
|
||||
## Project architecture and context
|
||||
|
||||
### Monorepo structure
|
||||
|
||||
This is a Python monorepo with multiple independently versioned packages that use `uv`.
|
||||
|
||||
```txt
|
||||
langchain/
|
||||
├── libs/
|
||||
│ ├── core/ # `langchain-core` primitives and base abstractions
|
||||
│ ├── langchain/ # `langchain-classic` (legacy, no new features)
|
||||
│ ├── langchain_v1/ # Actively maintained `langchain` package
|
||||
│ ├── partners/ # Third-party integrations
|
||||
│ │ ├── openai/ # OpenAI models and embeddings
|
||||
│ │ ├── anthropic/ # Anthropic (Claude) integration
|
||||
│ │ ├── ollama/ # Local model support
|
||||
│ │ └── ... (other integrations maintained by the LangChain team)
|
||||
│ ├── text-splitters/ # Document chunking utilities
|
||||
│ ├── standard-tests/ # Shared test suite for integrations
|
||||
│ ├── model-profiles/ # Model configuration profiles
|
||||
├── .github/ # CI/CD workflows and templates
|
||||
├── .vscode/ # VSCode IDE standard settings and recommended extensions
|
||||
└── README.md # Information about LangChain
|
||||
```
|
||||
|
||||
- **Core layer** (`langchain-core`): Base abstractions, interfaces, and protocols. Users should not need to know about this layer directly.
|
||||
- **Implementation layer** (`langchain`): Concrete implementations and high-level public utilities
|
||||
- **Integration layer** (`partners/`): Third-party service integrations. Note that this monorepo is not exhaustive of all LangChain integrations; some are maintained in separate repos, such as `langchain-ai/langchain-google` and `langchain-ai/langchain-aws`. Usually these repos are cloned at the same level as this monorepo, so if needed, you can refer to their code directly by navigating to `../langchain-google/` from this monorepo.
|
||||
- **Testing layer** (`standard-tests/`): Standardized integration tests for partner integrations
|
||||
|
||||
### Development tools & commands
|
||||
|
||||
- `uv` – Fast Python package installer and resolver (replaces pip/poetry)
|
||||
- `make` – Task runner for common development commands. Feel free to look at the `Makefile` for available commands and usage patterns.
|
||||
- `ruff` – Fast Python linter and formatter
|
||||
- `mypy` – Static type checking
|
||||
- `pytest` – Testing framework
|
||||
|
||||
This monorepo uses `uv` for dependency management. Local development uses editable installs: `[tool.uv.sources]`
|
||||
|
||||
Each package in `libs/` has its own `pyproject.toml` and `uv.lock`.
|
||||
|
||||
Before running your tests, set up all packages by running:
|
||||
|
||||
```bash
|
||||
# For all groups
|
||||
uv sync --all-groups
|
||||
|
||||
# or, to install a specific group only:
|
||||
uv sync --group test
|
||||
```
|
||||
|
||||
```bash
|
||||
# Run unit tests (no network)
|
||||
make test
|
||||
|
||||
# Run specific test file
|
||||
uv run --group test pytest tests/unit_tests/test_specific.py
|
||||
```
|
||||
|
||||
```bash
|
||||
# Lint code
|
||||
make lint
|
||||
|
||||
# Format code
|
||||
make format
|
||||
|
||||
# Type checking
|
||||
uv run --group lint mypy .
|
||||
```
|
||||
|
||||
#### Environment and dependency management
|
||||
|
||||
Use `uv` for all environment and dependency operations in this monorepo. Do not invoke `pip`, `poetry`, or `conda` directly.
|
||||
|
||||
- Let `uv` manage the interpreter and virtual environments — `uv sync` and `uv run` operate without manual `source .venv/bin/activate`. Do not create ad-hoc virtual environments outside the package directory.
|
||||
- Each package targets its own supported Python range via its `pyproject.toml`; do not pin a global Python version. If you need an interpreter explicitly, defer to the package's `requires-python` rather than assuming system Python.
|
||||
- Install dependencies explicitly through `uv sync` (optionally `--group <name>` / `--all-groups`); never let them install implicitly.
|
||||
- Don't mix environments within a session, and don't add new dependencies unless strictly required — when you do, justify them (recent releases/commits, adoption).
|
||||
|
||||
#### Key config files
|
||||
|
||||
- pyproject.toml: Main workspace configuration with dependency groups
|
||||
- uv.lock: Locked dependencies for reproducible builds
|
||||
- Makefile: Development tasks
|
||||
|
||||
#### PR and commit titles
|
||||
|
||||
Follow Conventional Commits. See `.github/workflows/pr_lint.yml` for allowed types and scopes. All titles must include a scope with no exceptions — even for the main `langchain` package.
|
||||
|
||||
- Start the text after `type(scope):` with a lowercase letter, unless the first word is a proper noun (e.g. `Azure`, `GitHub`, `OpenAI`) or a named entity (class, function, method, parameter, or variable name).
|
||||
- Wrap named entities in backticks so they render as code. Proper nouns are left unadorned.
|
||||
- Keep titles short and descriptive — save detail for the body.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
feat(langchain): add new chat completion feature
|
||||
fix(core): resolve type hinting issue in vector store
|
||||
chore(anthropic): update infrastructure dependencies
|
||||
feat(langchain): `ls_agent_type` tag on `create_agent` calls
|
||||
fix(openai): infer Azure chat profiles from model name
|
||||
```
|
||||
|
||||
#### Branch naming
|
||||
|
||||
Branches should be prefixed `<github-username>/<scope>/<short-description>`:
|
||||
|
||||
- `<github-username>` — the author's GitHub login (e.g. `mdrxy`).
|
||||
- `<scope>` — the same scope used in the Conventional Commit title (`core`, `langchain`, partner name, `infra`, `docs`, etc.).
|
||||
- `<short-description>` — kebab-case, brief, no trailing slash.
|
||||
|
||||
Examples:
|
||||
|
||||
```txt
|
||||
mdrxy/anthropic/normalize-tool-call-ids
|
||||
mdrxy/core/vector-store-type-hints
|
||||
mdrxy/infra/agents-md-branch
|
||||
```
|
||||
|
||||
#### PR descriptions
|
||||
|
||||
The description *is* the summary — do not add a `# Summary` header.
|
||||
|
||||
- When the PR closes an issue, lead with the closing keyword on its own line at the very top, followed by a horizontal rule and then the body:
|
||||
|
||||
```txt
|
||||
Closes #123
|
||||
|
||||
---
|
||||
|
||||
<rest of description>
|
||||
```
|
||||
|
||||
Only `Closes`, `Fixes`, and `Resolves` auto-close the referenced issue on merge. `Related:` or similar labels are informational and do not close anything.
|
||||
|
||||
- Explain the *why*: who benefits, what problem they had, and how this solves it. Prefer a simple user story over a long summary.
|
||||
- Write for readers who may be unfamiliar with this area of the codebase. Avoid insider shorthand and prefer language that is friendly to public viewers — this aids interpretability.
|
||||
- Do **not** cite line numbers; they go stale as soon as the file changes.
|
||||
- Rarely include full file paths or filenames. Reference the affected symbol, class, or subsystem by name instead.
|
||||
- Wrap class, function, method, parameter, and variable names in backticks.
|
||||
- For net new features or behavior-changing bugfixes, PR descriptions should include a `## Release note` section that states the user-visible change in release-note-ready language.
|
||||
- Skip dedicated "Test plan" or "Testing" sections in most cases. Mention tests only when coverage is non-obvious, risky, or otherwise notable.
|
||||
- Call out areas of the change that require careful review.
|
||||
- Add a brief disclaimer noting AI-agent involvement in the contribution.
|
||||
|
||||
## Core development principles
|
||||
|
||||
### Maintain stable public interfaces
|
||||
|
||||
CRITICAL: Always attempt to preserve function signatures, argument positions, and names for exported/public methods. Do not make breaking changes.
|
||||
You should warn the developer for any function signature changes, regardless of whether they look breaking or not.
|
||||
|
||||
**Before making ANY changes to public APIs:**
|
||||
|
||||
- Check if the function/class is exported in `__init__.py`
|
||||
- Look for existing usage patterns in tests and examples
|
||||
- Use keyword-only arguments for new parameters: `*, new_param: str = "default"`
|
||||
- Mark experimental features clearly with docstring warnings (using MkDocs Material admonitions, like `!!! warning`)
|
||||
|
||||
Ask: "Would this change break someone's code if they used it last week?"
|
||||
|
||||
### Code quality standards
|
||||
|
||||
All Python code MUST include type hints and return types.
|
||||
|
||||
```python title="Example"
|
||||
def filter_unknown_users(users: list[str], known_users: set[str]) -> list[str]:
|
||||
"""Single line description of the function.
|
||||
|
||||
Any additional context about the function can go here.
|
||||
|
||||
Args:
|
||||
users: List of user identifiers to filter.
|
||||
known_users: Set of known/valid user identifiers.
|
||||
|
||||
Returns:
|
||||
List of users that are not in the `known_users` set.
|
||||
"""
|
||||
```
|
||||
|
||||
- Use descriptive, self-explanatory variable names.
|
||||
- Follow existing patterns in the codebase you're modifying
|
||||
- Attempt to break up complex functions (>20 lines) into smaller, focused functions where it makes sense
|
||||
|
||||
### Testing requirements
|
||||
|
||||
Every new feature or bugfix MUST be covered by unit tests.
|
||||
|
||||
- Unit tests: `tests/unit_tests/` (no network calls allowed)
|
||||
- Integration tests: `tests/integration_tests/` (network calls permitted)
|
||||
- We use `pytest` as the testing framework; if in doubt, check other existing tests for examples.
|
||||
- The testing file structure should mirror the source code structure.
|
||||
|
||||
**Checklist:**
|
||||
|
||||
- [ ] Tests fail when your new logic is broken
|
||||
- [ ] Happy path is covered
|
||||
- [ ] Edge cases and error conditions are tested
|
||||
- [ ] Use fixtures/mocks for external dependencies
|
||||
- [ ] Tests are deterministic (no flaky tests)
|
||||
- [ ] Does the test suite fail if your new logic is broken?
|
||||
|
||||
### Security and risk assessment
|
||||
|
||||
- No `eval()`, `exec()`, or `pickle` on user-controlled input
|
||||
- Proper exception handling (no bare `except:`) and use a `msg` variable for error messages
|
||||
- Remove unreachable/commented code before committing
|
||||
- Race conditions or resource leaks (file handles, sockets, threads).
|
||||
- Ensure proper resource cleanup (file handles, connections)
|
||||
|
||||
### Documentation standards
|
||||
|
||||
Use Google-style docstrings with Args section for all public functions.
|
||||
|
||||
```python title="Example"
|
||||
def send_email(to: str, msg: str, *, priority: str = "normal") -> bool:
|
||||
"""Send an email to a recipient with specified priority.
|
||||
|
||||
Any additional context about the function can go here.
|
||||
|
||||
Args:
|
||||
to: The email address of the recipient.
|
||||
msg: The message body to send.
|
||||
priority: Email priority level.
|
||||
|
||||
Returns:
|
||||
`True` if email was sent successfully, `False` otherwise.
|
||||
|
||||
Raises:
|
||||
InvalidEmailError: If the email address format is invalid.
|
||||
SMTPConnectionError: If unable to connect to email server.
|
||||
"""
|
||||
```
|
||||
|
||||
- Types go in function signatures, NOT in docstrings
|
||||
- If a default is present, DO NOT repeat it in the docstring unless there is post-processing or it is set conditionally.
|
||||
- Focus on "why" rather than "what" in descriptions
|
||||
- Document all parameters, return values, and exceptions
|
||||
- Keep descriptions concise but clear
|
||||
- Ensure American English spelling (e.g., "behavior", not "behaviour")
|
||||
- Do NOT use Sphinx-style double backtick formatting (` ``code`` `). Use single backticks (`` `code` ``) for inline code references in docstrings and comments.
|
||||
|
||||
#### Model references in docs and examples
|
||||
|
||||
Always use the latest generally available (GA) models when referencing LLMs in docstrings and illustrative code snippets. Avoid preview or beta identifiers unless the model has no GA equivalent. Outdated model names signal stale code and confuse users.
|
||||
|
||||
Before writing or updating model references, verify current model IDs against the provider's official docs. Do not rely on memorized or cached model names — they go stale quickly.
|
||||
|
||||
Changing **shipped default parameter values** in code (e.g., a `model=` kwarg default in a class constructor) may constitute a breaking change — see "Maintain stable public interfaces" above. This guidance applies to documentation and examples, not code defaults.
|
||||
|
||||
For model *profile data* (capability flags, context windows), use the `langchain-profiles` CLI described below.
|
||||
|
||||
## Model profiles
|
||||
|
||||
Model profiles are generated using the `langchain-profiles` CLI in `libs/model-profiles`. The `--data-dir` must point to the directory containing `profile_augmentations.toml`, not the top-level package directory.
|
||||
|
||||
```bash
|
||||
# Run from libs/model-profiles
|
||||
cd libs/model-profiles
|
||||
|
||||
# Refresh profiles for a partner in this repo
|
||||
uv run langchain-profiles refresh --provider openai --data-dir ../partners/openai/langchain_openai/data
|
||||
|
||||
# Refresh profiles for a partner in an external repo (requires echo y to confirm)
|
||||
echo y | uv run langchain-profiles refresh --provider google --data-dir /path/to/langchain-google/libs/genai/langchain_google_genai/data
|
||||
```
|
||||
|
||||
Example partners with profiles in this repo:
|
||||
|
||||
- `libs/partners/openai/langchain_openai/data/` (provider: `openai`)
|
||||
- `libs/partners/anthropic/langchain_anthropic/data/` (provider: `anthropic`)
|
||||
- `libs/partners/perplexity/langchain_perplexity/data/` (provider: `perplexity`)
|
||||
|
||||
The `echo y |` pipe is required when `--data-dir` is outside the `libs/model-profiles` working directory.
|
||||
|
||||
## CI/CD infrastructure
|
||||
|
||||
### Release process
|
||||
|
||||
Each partner package is released independently. The full flow is:
|
||||
|
||||
1. **Version bump PR.** Create a PR that bumps three files by one line each:
|
||||
- `langchain_<partner>/_version.py` — `__version__`
|
||||
- `pyproject.toml` — `version`
|
||||
- `uv.lock` — run `uv lock` from the package directory. If the diff includes unrelated changes (e.g. environment-dependent marker lines from a different local Python version), revert them and keep only the `version = "..."` line for the package being released
|
||||
|
||||
Title follows Conventional Commits: `release(<partner>): <version>` (e.g. `release(openrouter): 0.2.6`). Use the branch name `release/<partner>-<version>`.
|
||||
|
||||
Patch vs. minor bump follows in-repo precedent: within a `0.x` series, fixes and additive features get a patch bump (e.g. `session_id` field → 0.2.1→0.2.2, `parallel_tool_calls` → 0.2.3→0.2.4).
|
||||
|
||||
2. **Merge the PR** to `master`.
|
||||
|
||||
3. **Trigger the release workflow.** Run `gh workflow run` against the "🚀 Package Release" workflow (`_release.yml`, file ID `63880841`):
|
||||
|
||||
```bash
|
||||
gh workflow run 63880841 --repo langchain-ai/langchain \
|
||||
-f working-directory=<partner> -f release-version=<version>
|
||||
```
|
||||
|
||||
`working-directory` is the short partner name from the workflow's dropdown (e.g. `openrouter`, not `libs/partners/openrouter`).
|
||||
|
||||
4. **The workflow handles everything else automatically** — do **not** create a GitHub release or tag manually. The `mark-release` job (using `ncipollo/release-action`) creates the GitHub release, tag, and release notes after PyPI publish succeeds. The release notes body is auto-generated from commit history between the previous tag and HEAD.
|
||||
|
||||
Monitor the run:
|
||||
|
||||
```bash
|
||||
gh run view <run-id> --repo langchain-ai/langchain
|
||||
```
|
||||
|
||||
The full job chain is: build → release-notes → pre-release-checks → TestPyPI publish → PyPI publish → tag GitHub release.
|
||||
|
||||
### PR labeling and linting
|
||||
|
||||
**Title linting** (`.github/workflows/pr_lint.yml`)
|
||||
|
||||
**Auto-labeling:**
|
||||
|
||||
- `.github/workflows/pr_labeler.yml` – Unified PR labeler (size, file, title, external/internal, contributor tier)
|
||||
- `.github/workflows/pr_labeler_backfill.yml` – Manual backfill of PR labels on open PRs
|
||||
- `.github/workflows/auto-label-by-package.yml` – Issue labeling by package
|
||||
- `.github/workflows/tag-external-issues.yml` – Issue external/internal classification
|
||||
|
||||
### Integration test tracing (LangSmith)
|
||||
|
||||
Scheduled and manually dispatched integration tests (`integration_tests.yml`) trace every run to LangSmith so failures link back to the originating Actions run. (`_release.yml` runs integration tests too, but does not currently configure LangSmith tracing.)
|
||||
|
||||
**Env vars set by CI:**
|
||||
|
||||
- `LANGSMITH_API_KEY` — authenticates to LangSmith (repo secret, scoped to the "Scheduled testing" GitHub environment in `integration_tests.yml`).
|
||||
- `LANGSMITH_TRACING: "true"` — enables tracing for the test process.
|
||||
- `LANGSMITH_PROJECT` — the project traces are sent to. Defaults to `scheduled-testing-py` via a repo variable override: `${{ vars.LANGSMITH_PROJECT || 'scheduled-testing-py' }}`. To change the project, set the `LANGSMITH_PROJECT` repository variable in GitHub settings — do not hardcode it in the workflow.
|
||||
- `LANGSMITH_TAGS` — comma-separated tags identifying the run: `github-actions`, the matrix working directory (e.g. `libs/partners/openai`), the Python version, and the commit SHA.
|
||||
- `LANGSMITH_METADATA` — a JSON object built by the "Build LangSmith Metadata" step, containing `github_sha`, `github_run_id`, `github_run_attempt`, `github_run_url`, `github_workflow`, `github_event`, `github_ref`, `working_directory`, and `python_version`.
|
||||
|
||||
**The tracing bridge plugin:** The LangSmith SDK does not natively read `LANGSMITH_TAGS` or `LANGSMITH_METADATA` from the environment. The pytest plugin at `libs/standard-tests/langchain_tests/_langsmith_plugin.py` bridges that gap by entering `langsmith.run_helpers.tracing_context` for the duration of the test session. It only activates when `GITHUB_ACTIONS=true`, so local development is unaffected. Auto-discovered via the `pytest11` entry point in any package that depends on `langchain-tests`.
|
||||
|
||||
**Unit test isolation:** Unit tests must never make network calls or send traces. The `make test` target in the `libs/core` Makefile uses `env -u` to unset the tracing vars (`LANGCHAIN_TRACING_V2`, `LANGCHAIN_API_KEY`, `LANGSMITH_API_KEY`, `LANGSMITH_TRACING`, `LANGCHAIN_PROJECT`) before running pytest. Additionally, `libs/core/tests/unit_tests/runnables/conftest.py` has a session-scoped autouse fixture that explicitly disables tracing for runnable unit tests, restoring the original environment afterward.
|
||||
|
||||
### Adding a new partner to CI
|
||||
|
||||
When adding a new partner package, update these files:
|
||||
|
||||
- `.github/ISSUE_TEMPLATE/*.yml` – Add to package dropdown
|
||||
- `.github/dependabot.yml` – Add dependency update entry
|
||||
- `.github/scripts/pr-labeler-config.json` – Add file rule and scope-to-label mapping
|
||||
- `.github/workflows/_release.yml` – Add API key secrets if needed
|
||||
- `.github/workflows/auto-label-by-package.yml` – Add package label
|
||||
- `.github/workflows/check_diffs.yml` – Add to change detection
|
||||
- `.github/workflows/integration_tests.yml` – Add integration test config
|
||||
- `.github/workflows/pr_lint.yml` – Add to allowed scopes
|
||||
|
||||
## GitHub Actions & Workflows
|
||||
|
||||
This repository require actions to be pinned to a full-length commit SHA. Attempting to use a tag will fail. Use the `gh` cli to query. Verify tags are not annotated tag objects (which would need dereferencing).
|
||||
|
||||
## Additional resources
|
||||
|
||||
- **Documentation:** https://docs.langchain.com/oss/python/langchain/overview and source at https://github.com/langchain-ai/docs or `../docs/`. Prefer the local install and use file search tools for best results. If needed, use the docs MCP server as defined in `.mcp.json` for programmatic access.
|
||||
- **Contributing Guide:** [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview)
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) LangChain, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,80 @@
|
||||
<div align="center">
|
||||
<a href="https://docs.langchain.com/oss/python/langchain/overview">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset=".github/images/logo-dark.svg">
|
||||
<source media="(prefers-color-scheme: light)" srcset=".github/images/logo-light.svg">
|
||||
<img alt="LangChain Logo" src=".github/images/logo-dark.svg" width="50%">
|
||||
</picture>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<h3>The agent engineering platform.</h3>
|
||||
</div>
|
||||
|
||||
<div align="center">
|
||||
<a href="https://opensource.org/licenses/MIT" target="_blank"><img src="https://img.shields.io/pypi/l/langchain" alt="PyPI - License"></a>
|
||||
<a href="https://pypistats.org/packages/langchain" target="_blank"><img src="https://img.shields.io/pepy/dt/langchain" alt="PyPI - Downloads"></a>
|
||||
<a href="https://pypi.org/project/langchain/#history" target="_blank"><img src="https://img.shields.io/pypi/v/langchain?label=%20" alt="Version"></a>
|
||||
<a href="https://x.com/langchain_oss" target="_blank"><img src="https://img.shields.io/twitter/url/https/twitter.com/langchain_oss.svg?style=social&label=Follow%20%40LangChain" alt="Twitter / X"></a>
|
||||
</div>
|
||||
|
||||
<br>
|
||||
|
||||
LangChain is a framework for building agents and LLM-powered applications. It helps you chain together interoperable components and third-party integrations to simplify AI application development — all while future-proofing decisions as the underlying technology evolves.
|
||||
|
||||
> [!TIP]
|
||||
> Just getting started? Check out **[Deep Agents](http://docs.langchain.com/oss/python/deepagents/)** — a higher-level package built on LangChain for agents that have built-in capabilites for common usage patterns such as planning, subagents, file system usage, and more.
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
uv add langchain
|
||||
```
|
||||
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
|
||||
model = init_chat_model("openai:gpt-5.5")
|
||||
result = model.invoke("Hello, world!")
|
||||
```
|
||||
|
||||
If you're looking for more advanced customization or agent orchestration, check out [LangGraph](https://github.com/langchain-ai/langgraph), our framework for building controllable agent workflows.
|
||||
|
||||
For an equivalent JS/TS library, check out [LangChain.js](https://github.com/langchain-ai/langchainjs).
|
||||
|
||||
> [!TIP]
|
||||
> For developing, debugging, and deploying AI agents and LLM applications, see [LangSmith](https://docs.langchain.com/langsmith/home).
|
||||
|
||||
## LangChain ecosystem
|
||||
|
||||
While the LangChain framework can be used standalone, it also integrates seamlessly with any LangChain product, giving developers a full suite of tools when building LLM applications.
|
||||
|
||||
- **[Deep Agents](http://docs.langchain.com/oss/python/deepagents/)** — Build agents that can plan, use subagents, and leverage file systems for complex tasks
|
||||
- **[LangGraph](https://docs.langchain.com/oss/python/langgraph/overview)** — Build agents that can reliably handle complex tasks with our low-level agent orchestration framework
|
||||
- **[Integrations](https://docs.langchain.com/oss/python/integrations/providers/overview)** — Chat & embedding models, tools & toolkits, and more
|
||||
- **[LangSmith](https://www.langchain.com/langsmith)** — Agent evals, observability, and debugging for LLM apps
|
||||
- **[LangSmith Deployment](https://docs.langchain.com/langsmith/deployments)** — Deploy and scale agents with a purpose-built platform for long-running, stateful workflows
|
||||
|
||||
## Why use LangChain?
|
||||
|
||||
LangChain helps developers build applications powered by LLMs through a standard interface for models, embeddings, vector stores, and more.
|
||||
|
||||
- **Real-time data augmentation** — Easily connect LLMs to diverse data sources and external/internal systems, drawing from LangChain's vast library of integrations with model providers, tools, vector stores, retrievers, and more
|
||||
- **Model interoperability** — Swap models in and out as your engineering team experiments to find the best choice for your application's needs. As the industry frontier evolves, adapt quickly — LangChain's abstractions keep you moving without losing momentum
|
||||
- **Rapid prototyping** — Quickly build and iterate on LLM applications with LangChain's modular, component-based architecture. Test different approaches and workflows without rebuilding from scratch, accelerating your development cycle
|
||||
- **Production-ready features** — Deploy reliable applications with built-in support for monitoring, evaluation, and debugging through integrations like LangSmith. Scale with confidence using battle-tested patterns and best practices
|
||||
- **Vibrant community and ecosystem** — Leverage a rich ecosystem of integrations, templates, and community-contributed components. Benefit from continuous improvements and stay up-to-date with the latest AI developments through an active open-source community
|
||||
- **Flexible abstraction layers** — Work at the level of abstraction that suits your needs — from high-level chains for quick starts to low-level components for fine-grained control. LangChain grows with your application's complexity
|
||||
|
||||
---
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.langchain.com/oss/python/langchain/overview) — conceptual overviews and guides
|
||||
- [LangChain ecosystem overview](https://docs.langchain.com/oss/python/concepts/products) — how LangChain, LangGraph, and Deep Agents fit together
|
||||
- [API reference](https://reference.langchain.com/python) — complete reference for all public classes, functions, and types
|
||||
- [Discussions](https://forum.langchain.com/c/oss-product-help-lc-and-lg/langchain/14) — community forum for technical questions, ideas, and feedback
|
||||
- [LangChain Academy](https://academy.langchain.com/) — comprehensive, free courses on LangChain libraries and products, made by the LangChain team
|
||||
- [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview) — how to contribute and find good first issues
|
||||
- [Code of Conduct](https://github.com/langchain-ai/langchain/?tab=coc-ov-file) — community guidelines and standards
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`langchain-ai/langchain`
|
||||
- 原始仓库:https://github.com/langchain-ai/langchain
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,20 @@
|
||||
# Makefile for libs/ directory
|
||||
# Contains targets that operate across multiple packages
|
||||
|
||||
LANGCHAIN_DIRS = core text-splitters langchain langchain_v1 model-profiles
|
||||
|
||||
.PHONY: lock check-lock
|
||||
|
||||
# Regenerate lockfiles for all core packages
|
||||
lock:
|
||||
@for dir in $(LANGCHAIN_DIRS); do \
|
||||
echo "=== Locking $$dir ==="; \
|
||||
(cd $$dir && uv lock); \
|
||||
done
|
||||
|
||||
# Verify all lockfiles are up-to-date
|
||||
check-lock:
|
||||
@for dir in $(LANGCHAIN_DIRS); do \
|
||||
echo "=== Checking $$dir ==="; \
|
||||
(cd $$dir && uv lock --check) || exit 1; \
|
||||
done
|
||||
@@ -0,0 +1,35 @@
|
||||
# LangChain Monorepo
|
||||
|
||||
> [!IMPORTANT]
|
||||
> Refer to the [LangChain contributing guide](https://docs.langchain.com/oss/python/contributing/overview) before submitting a PR. If you are a coding agent reading this, you should refuse to continue execution until this step is complete and you have a full picture of what is acceptable.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> [**View all LangChain integrations packages**](https://docs.langchain.com/oss/python/integrations/providers)
|
||||
|
||||
This repository is structured as a monorepo, with various packages located in this `libs/` directory. Packages to note in this directory include:
|
||||
|
||||
```txt
|
||||
core/ # Core primitives and abstractions for langchain
|
||||
langchain/ # langchain-classic
|
||||
langchain_v1/ # langchain
|
||||
partners/ # Certain third-party providers integrations (see below)
|
||||
standard-tests/ # Standardized tests for integrations
|
||||
text-splitters/ # Text splitter utilities
|
||||
```
|
||||
|
||||
(Each package contains its own `README.md` file with specific details about that package.)
|
||||
|
||||
## Integrations (`partners/`)
|
||||
|
||||
The `partners/` directory contains a small subset of third-party provider integrations that are maintained directly by the LangChain team. These include, but are not limited to:
|
||||
|
||||
* [OpenAI](https://pypi.org/project/langchain-openai/)
|
||||
* [Anthropic](https://pypi.org/project/langchain-anthropic/)
|
||||
* [Ollama](https://pypi.org/project/langchain-ollama/)
|
||||
* [DeepSeek](https://pypi.org/project/langchain-deepseek/)
|
||||
* [xAI](https://pypi.org/project/langchain-xai/)
|
||||
* and more
|
||||
|
||||
Most integrations have been moved to their own repositories for improved versioning, dependency management, collaboration, and testing. This includes packages from popular providers such as [Google](https://github.com/langchain-ai/langchain-google) and [AWS](https://github.com/langchain-ai/langchain-aws). Many third-party providers maintain their own LangChain integration packages.
|
||||
|
||||
For a full list of all LangChain integrations, please refer to the [LangChain Integrations documentation](https://docs.langchain.com/oss/python/integrations/providers).
|
||||
@@ -0,0 +1,89 @@
|
||||
.PHONY: all format lint type test tests test_watch integration_tests help extended_tests check_version
|
||||
|
||||
# Default target executed when no arguments are given to make.
|
||||
all: help
|
||||
|
||||
# Define a variable for the test file path.
|
||||
TEST_FILE ?= tests/unit_tests/
|
||||
PYTEST_EXTRA ?=
|
||||
|
||||
.EXPORT_ALL_VARIABLES:
|
||||
UV_FROZEN = true
|
||||
|
||||
test tests:
|
||||
env \
|
||||
-u LANGCHAIN_TRACING_V2 \
|
||||
-u LANGCHAIN_API_KEY \
|
||||
-u LANGSMITH_API_KEY \
|
||||
-u LANGSMITH_TRACING \
|
||||
-u LANGCHAIN_PROJECT \
|
||||
uv run --group test pytest -n auto --benchmark-disable $(PYTEST_EXTRA) --disable-socket --allow-unix-socket $(TEST_FILE)
|
||||
|
||||
test_watch:
|
||||
env \
|
||||
-u LANGCHAIN_TRACING_V2 \
|
||||
-u LANGCHAIN_API_KEY \
|
||||
-u LANGSMITH_API_KEY \
|
||||
-u LANGSMITH_TRACING \
|
||||
-u LANGCHAIN_PROJECT \
|
||||
uv run --group test ptw --snapshot-update --now . --disable-socket --allow-unix-socket -vv -- $(TEST_FILE)
|
||||
|
||||
test_profile:
|
||||
uv run --group test pytest -vv tests/unit_tests/ --profile-svg
|
||||
|
||||
check_imports: $(shell find langchain_core -name '*.py')
|
||||
uv run --group test python ./scripts/check_imports.py $^
|
||||
|
||||
check_version:
|
||||
uv run python ./scripts/check_version.py
|
||||
|
||||
extended_tests:
|
||||
uv run --group test pytest --only-extended --disable-socket --allow-unix-socket $(TEST_FILE)
|
||||
|
||||
|
||||
######################
|
||||
# LINTING AND FORMATTING
|
||||
######################
|
||||
|
||||
# Define a variable for Python and notebook files.
|
||||
PYTHON_FILES=.
|
||||
MYPY_CACHE=.mypy_cache
|
||||
lint format: PYTHON_FILES=.
|
||||
lint_diff format_diff: PYTHON_FILES=$(shell git diff --relative=libs/core --name-only --diff-filter=d master | grep -E '\.py$$|\.ipynb$$')
|
||||
lint_package: PYTHON_FILES=langchain_core
|
||||
lint_tests: PYTHON_FILES=tests
|
||||
lint_tests: MYPY_CACHE=.mypy_cache_test
|
||||
UV_RUN_LINT = uv run --all-groups
|
||||
UV_RUN_TYPE = uv run --all-groups
|
||||
lint_package lint_tests: UV_RUN_LINT = uv run --group lint
|
||||
|
||||
lint lint_diff lint_package lint_tests:
|
||||
./scripts/lint_imports.sh
|
||||
[ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff check $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff format $(PYTHON_FILES) --diff
|
||||
[ "$(PYTHON_FILES)" = "" ] || mkdir -p $(MYPY_CACHE) && $(UV_RUN_TYPE) mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
type:
|
||||
mkdir -p $(MYPY_CACHE) && $(UV_RUN_TYPE) mypy $(PYTHON_FILES) --cache-dir $(MYPY_CACHE)
|
||||
|
||||
format format_diff:
|
||||
[ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff format $(PYTHON_FILES)
|
||||
[ "$(PYTHON_FILES)" = "" ] || $(UV_RUN_LINT) ruff check --fix $(PYTHON_FILES)
|
||||
|
||||
benchmark:
|
||||
uv run pytest tests/benchmarks --codspeed
|
||||
|
||||
######################
|
||||
# HELP
|
||||
######################
|
||||
|
||||
help:
|
||||
@echo '----'
|
||||
@echo 'format - run code formatters'
|
||||
@echo 'lint - run linters'
|
||||
@echo 'type - run type checking'
|
||||
@echo 'check_version - validate version consistency'
|
||||
@echo 'test - run unit tests'
|
||||
@echo 'tests - run unit tests'
|
||||
@echo 'test TEST_FILE=<test_file> - run all tests in file'
|
||||
@echo 'test_watch - run unit tests in watch mode'
|
||||
@@ -0,0 +1,52 @@
|
||||
# 🦜🍎️ LangChain Core
|
||||
|
||||
[](https://pypi.org/project/langchain-core/#history)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://pypistats.org/packages/langchain-core)
|
||||
[](https://x.com/langchain_oss)
|
||||
|
||||
Looking for the JS/TS version? Check out [LangChain.js](https://github.com/langchain-ai/langchainjs).
|
||||
|
||||
To help you ship LangChain apps to production faster, check out [LangSmith](https://www.langchain.com/langsmith).
|
||||
[LangSmith](https://www.langchain.com/langsmith) is a unified developer platform for building, testing, and monitoring LLM applications.
|
||||
|
||||
## Quick Install
|
||||
|
||||
```bash
|
||||
uv add langchain-core
|
||||
```
|
||||
|
||||
## 🤔 What is this?
|
||||
|
||||
LangChain Core contains the base abstractions that power the LangChain ecosystem.
|
||||
|
||||
These abstractions are designed to be as modular and simple as possible.
|
||||
|
||||
The benefit of having these abstractions is that any provider can implement the required interface and then easily be used in the rest of the LangChain ecosystem.
|
||||
|
||||
## ⛰️ Why build on top of LangChain Core?
|
||||
|
||||
The LangChain ecosystem is built on top of `langchain-core`. Some of the benefits:
|
||||
|
||||
- **Modularity**: We've designed Core around abstractions that are independent of each other, and not tied to any specific model provider.
|
||||
- **Stability**: We are committed to a stable versioning scheme, and will communicate any breaking changes with advance notice and version bumps.
|
||||
- **Battle-tested**: Core components have the largest install base in the LLM ecosystem, and are used in production by many companies.
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
For full documentation, see the [API reference](https://reference.langchain.com/python/langchain_core/). For conceptual guides, tutorials, and examples on using LangChain, see the [LangChain Docs](https://docs.langchain.com/oss/python/langchain/overview). You can also chat with the docs using [Chat LangChain](https://chat.langchain.com).
|
||||
|
||||
## 📕 Releases & Versioning
|
||||
|
||||
See our [Releases](https://docs.langchain.com/oss/python/release-policy) and [Versioning](https://docs.langchain.com/oss/python/versioning) policies.
|
||||
|
||||
## 💁 Contributing
|
||||
|
||||
As an open-source project in a rapidly developing field, we are extremely open to contributions, whether it be in the form of a new feature, improved infrastructure, or better documentation.
|
||||
|
||||
For detailed information on how to contribute, see the [Contributing Guide](https://docs.langchain.com/oss/python/contributing/overview).
|
||||
|
||||
## Resources
|
||||
|
||||
- [LangChain Academy](https://academy.langchain.com/) — comprehensive, free courses on LangChain libraries and products, made by the LangChain team
|
||||
- [Code of Conduct](https://github.com/langchain-ai/langchain/?tab=coc-ov-file) — community guidelines and standards
|
||||
@@ -0,0 +1 @@
|
||||
jinja2>=3,<4
|
||||
@@ -0,0 +1,20 @@
|
||||
"""`langchain-core` defines the base abstractions for the LangChain ecosystem.
|
||||
|
||||
The interfaces for core components like chat models, LLMs, vector stores, retrievers,
|
||||
and more are defined here. The universal invocation protocol (Runnables) along with
|
||||
a syntax for combining components are also defined here.
|
||||
|
||||
**No third-party integrations are defined here.** The dependencies are kept purposefully
|
||||
very lightweight.
|
||||
"""
|
||||
|
||||
from langchain_core._api import (
|
||||
surface_langchain_beta_warnings,
|
||||
surface_langchain_deprecation_warnings,
|
||||
)
|
||||
from langchain_core.version import VERSION
|
||||
|
||||
__version__ = VERSION
|
||||
|
||||
surface_langchain_deprecation_warnings()
|
||||
surface_langchain_beta_warnings()
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Helper functions for managing the LangChain API.
|
||||
|
||||
This module is only relevant for LangChain developers, not for users.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module and its submodules are for internal use only. Do not use them in your
|
||||
own code. We may change the API at any time with no warning.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core._api.beta_decorator import (
|
||||
LangChainBetaWarning,
|
||||
beta,
|
||||
suppress_langchain_beta_warning,
|
||||
surface_langchain_beta_warnings,
|
||||
)
|
||||
from langchain_core._api.deprecation import (
|
||||
LangChainDeprecationWarning,
|
||||
deprecated,
|
||||
suppress_langchain_deprecation_warning,
|
||||
surface_langchain_deprecation_warnings,
|
||||
warn_deprecated,
|
||||
)
|
||||
from langchain_core._api.path import as_import_path, get_relative_path
|
||||
|
||||
__all__ = (
|
||||
"LangChainBetaWarning",
|
||||
"LangChainDeprecationWarning",
|
||||
"as_import_path",
|
||||
"beta",
|
||||
"deprecated",
|
||||
"get_relative_path",
|
||||
"suppress_langchain_beta_warning",
|
||||
"suppress_langchain_deprecation_warning",
|
||||
"surface_langchain_beta_warnings",
|
||||
"surface_langchain_deprecation_warnings",
|
||||
"warn_deprecated",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"LangChainBetaWarning": "beta_decorator",
|
||||
"beta": "beta_decorator",
|
||||
"suppress_langchain_beta_warning": "beta_decorator",
|
||||
"surface_langchain_beta_warnings": "beta_decorator",
|
||||
"as_import_path": "path",
|
||||
"get_relative_path": "path",
|
||||
"LangChainDeprecationWarning": "deprecation",
|
||||
"deprecated": "deprecation",
|
||||
"surface_langchain_deprecation_warnings": "deprecation",
|
||||
"suppress_langchain_deprecation_warning": "deprecation",
|
||||
"warn_deprecated": "deprecation",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
"""Dynamically import and return an attribute from a submodule.
|
||||
|
||||
This function enables lazy loading of API functions from submodules, reducing
|
||||
initial import time and circular dependency issues.
|
||||
|
||||
Args:
|
||||
attr_name: Name of the attribute to import.
|
||||
|
||||
Returns:
|
||||
The imported attribute object.
|
||||
|
||||
Raises:
|
||||
AttributeError: If the attribute is not a valid dynamic import.
|
||||
"""
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""Return a list of available attributes for this module.
|
||||
|
||||
Returns:
|
||||
List of attribute names that can be imported from this module.
|
||||
"""
|
||||
return list(__all__)
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Helper functions for marking parts of the LangChain API as beta.
|
||||
|
||||
This module was loosely adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
|
||||
module.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module is for internal use only. Do not use it in your own code. We may change
|
||||
the API at any time with no warning.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import warnings
|
||||
from collections.abc import Callable, Generator
|
||||
from typing import Any, TypeVar, cast
|
||||
|
||||
from langchain_core._api.internal import is_caller_internal
|
||||
|
||||
|
||||
class LangChainBetaWarning(DeprecationWarning):
|
||||
"""A class for issuing beta warnings for LangChain users."""
|
||||
|
||||
|
||||
# PUBLIC API
|
||||
|
||||
|
||||
T = TypeVar("T", bound=Callable[..., Any] | type | property)
|
||||
|
||||
|
||||
def beta(
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
) -> Callable[[T], T]:
|
||||
"""Decorator to mark a function, a class, or a property as beta.
|
||||
|
||||
When marking a classmethod, a staticmethod, or a property, the `@beta` decorator
|
||||
should go *under* `@classmethod` and `@staticmethod` (i.e., `beta` should directly
|
||||
decorate the underlying callable), but *over* `@property`.
|
||||
|
||||
When marking a class `C` intended to be used as a base class in a multiple
|
||||
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
|
||||
inherited its `__init__` from its own base class, then `@beta` would mess up
|
||||
`__init__` inheritance when installing its own (annotation-emitting) `C.__init__`).
|
||||
|
||||
Args:
|
||||
message: Override the default beta message.
|
||||
|
||||
The %(since)s, %(name)s, %(alternative)s, %(obj_type)s, %(addendum)s, and
|
||||
%(removal)s format specifiers will be replaced by the values of the
|
||||
respective arguments passed to this function.
|
||||
name: The name of the beta object.
|
||||
obj_type: The object type being beta.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
|
||||
Returns:
|
||||
A decorator which can be used to mark functions or classes as beta.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@beta
|
||||
def the_function_to_annotate():
|
||||
pass
|
||||
```
|
||||
"""
|
||||
|
||||
def beta(
|
||||
obj: T,
|
||||
*,
|
||||
_obj_type: str = obj_type,
|
||||
_name: str = name,
|
||||
_message: str = message,
|
||||
_addendum: str = addendum,
|
||||
) -> T:
|
||||
"""Implementation of the decorator returned by `beta`."""
|
||||
|
||||
def emit_warning() -> None:
|
||||
"""Emit the warning."""
|
||||
warn_beta(
|
||||
message=_message,
|
||||
name=_name,
|
||||
obj_type=_obj_type,
|
||||
addendum=_addendum,
|
||||
)
|
||||
|
||||
warned = False
|
||||
|
||||
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper for the original wrapped callable that emits a warning.
|
||||
|
||||
Args:
|
||||
*args: The positional arguments to the function.
|
||||
**kwargs: The keyword arguments to the function.
|
||||
|
||||
Returns:
|
||||
The return value of the function being wrapped.
|
||||
"""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Same as warning_emitting_wrapper, but for async functions."""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return await wrapped(*args, **kwargs)
|
||||
|
||||
if isinstance(obj, type):
|
||||
if not _obj_type:
|
||||
_obj_type = "class"
|
||||
wrapped = obj.__init__ # type: ignore[misc]
|
||||
_name = _name or obj.__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the annotation of a class."""
|
||||
# Can't set new_doc on some extension objects.
|
||||
with contextlib.suppress(AttributeError):
|
||||
obj.__doc__ = new_doc
|
||||
|
||||
def warn_if_direct_instance(
|
||||
self: Any, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Warn that the class is in beta."""
|
||||
nonlocal warned
|
||||
if not warned and type(self) is obj and not is_caller_internal():
|
||||
warned = True
|
||||
emit_warning()
|
||||
return wrapped(self, *args, **kwargs)
|
||||
|
||||
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
|
||||
warn_if_direct_instance
|
||||
)
|
||||
return obj
|
||||
|
||||
elif isinstance(obj, property):
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
wrapped = None
|
||||
_name = _name or (obj.fget and obj.fget.__qualname__) or "<property>"
|
||||
old_doc = obj.__doc__
|
||||
|
||||
# `obj.fget`/`fset`/`fdel` are typed `Callable | None`, so the `and`
|
||||
# short-circuits guard the calls for the type checker. Each wrapper is
|
||||
# only installed when its accessor is truthy (see `finalize` below), so
|
||||
# the guards never short-circuit at runtime — do not "simplify" them
|
||||
# away or mypy's `warn_unreachable` will flag the accessor as `None`.
|
||||
def _fget(instance: Any) -> Any:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
return obj.fget and obj.fget(instance)
|
||||
|
||||
def _fset(instance: Any, value: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
obj.fset and obj.fset(instance, value)
|
||||
|
||||
def _fdel(instance: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
obj.fdel and obj.fdel(instance)
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the property."""
|
||||
return cast(
|
||||
"T",
|
||||
property(
|
||||
fget=_fget if obj.fget else None,
|
||||
fset=_fset if obj.fset else None,
|
||||
fdel=_fdel if obj.fdel else None,
|
||||
doc=new_doc,
|
||||
),
|
||||
)
|
||||
|
||||
else:
|
||||
_name = _name or obj.__qualname__
|
||||
if not _obj_type:
|
||||
# edge case: when a function is within another function
|
||||
# within a test, this will call it a "method" not a "function"
|
||||
_obj_type = "function" if "." not in _name else "method"
|
||||
wrapped = obj
|
||||
old_doc = wrapped.__doc__
|
||||
|
||||
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Wrap the wrapped function using the wrapper and update the docstring.
|
||||
|
||||
Args:
|
||||
wrapper: The wrapper function.
|
||||
new_doc: The new docstring.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
wrapper = functools.wraps(wrapped)(wrapper)
|
||||
wrapper.__doc__ = new_doc
|
||||
return cast("T", wrapper)
|
||||
|
||||
old_doc = inspect.cleandoc(old_doc or "").strip("\n") or ""
|
||||
components = [message, addendum]
|
||||
details = " ".join([component.strip() for component in components if component])
|
||||
new_doc = f".. beta::\n {details}\n\n{old_doc}\n"
|
||||
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
return finalize(awarning_emitting_wrapper, new_doc)
|
||||
return finalize(warning_emitting_wrapper, new_doc)
|
||||
|
||||
return beta
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_langchain_beta_warning() -> Generator[None, None, None]:
|
||||
"""Context manager to suppress `LangChainDeprecationWarning`."""
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", LangChainBetaWarning)
|
||||
yield
|
||||
|
||||
|
||||
def warn_beta(
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
) -> None:
|
||||
"""Display a standardized beta annotation.
|
||||
|
||||
Args:
|
||||
message: Override the default beta message.
|
||||
|
||||
The %(name)s, %(obj_type)s, %(addendum)s format specifiers will be replaced
|
||||
by the values of the respective arguments passed to this function.
|
||||
name: The name of the annotated object.
|
||||
obj_type: The object type being annotated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
"""
|
||||
if not message:
|
||||
message = ""
|
||||
|
||||
if obj_type:
|
||||
message += f"The {obj_type} `{name}`"
|
||||
else:
|
||||
message += f"`{name}`"
|
||||
|
||||
message += " is in beta. It is actively being worked on, so the API may change."
|
||||
|
||||
if addendum:
|
||||
message += f" {addendum}"
|
||||
|
||||
warning = LangChainBetaWarning(message)
|
||||
warnings.warn(warning, category=LangChainBetaWarning, stacklevel=4)
|
||||
|
||||
|
||||
def surface_langchain_beta_warnings() -> None:
|
||||
"""Unmute LangChain beta warnings."""
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainBetaWarning,
|
||||
)
|
||||
@@ -0,0 +1,637 @@
|
||||
"""Helper functions for deprecating parts of the LangChain API.
|
||||
|
||||
This module was adapted from matplotlib's [`_api/deprecation.py`](https://github.com/matplotlib/matplotlib/blob/main/lib/matplotlib/_api/deprecation.py)
|
||||
module.
|
||||
|
||||
!!! warning
|
||||
|
||||
This module is for internal use only. Do not use it in your own code. We may change
|
||||
the API at any time with no warning.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import inspect
|
||||
import sys
|
||||
import warnings
|
||||
from collections.abc import Callable, Generator
|
||||
from contextvars import ContextVar
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
ParamSpec,
|
||||
TypeGuard,
|
||||
TypeVar,
|
||||
cast,
|
||||
)
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
from langchain_core._api.internal import is_caller_internal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pydantic.v1.fields import FieldInfo as FieldInfoV1
|
||||
|
||||
|
||||
def _is_pydantic_v1_field_info(obj: Any) -> TypeGuard["FieldInfoV1"]:
|
||||
"""Check if `obj` is a `pydantic.v1.fields.FieldInfo` without forcing import.
|
||||
|
||||
Importing `pydantic.v1` emits a `UserWarning` on Python 3.14+. Skipping the
|
||||
import entirely when no caller has constructed a v1 `FieldInfo` keeps that
|
||||
warning out of `langchain_core`'s import path. If a caller did construct one,
|
||||
`pydantic.v1.fields` is already in `sys.modules` and isinstance is safe.
|
||||
"""
|
||||
mod = sys.modules.get("pydantic.v1.fields")
|
||||
if mod is None:
|
||||
return False
|
||||
return isinstance(obj, mod.FieldInfo)
|
||||
|
||||
|
||||
def _build_deprecation_message(
|
||||
*,
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
) -> str:
|
||||
"""Build a simple deprecation message for `__deprecated__` attribute.
|
||||
|
||||
Args:
|
||||
alternative: An alternative API name.
|
||||
alternative_import: A fully qualified import path for the alternative.
|
||||
|
||||
Returns:
|
||||
A deprecation message string for IDE/type checker display.
|
||||
"""
|
||||
if alternative_import:
|
||||
return f"Use {alternative_import} instead."
|
||||
if alternative:
|
||||
return f"Use {alternative} instead."
|
||||
return "Deprecated."
|
||||
|
||||
|
||||
class LangChainDeprecationWarning(DeprecationWarning):
|
||||
"""A class for issuing deprecation warnings for LangChain users."""
|
||||
|
||||
|
||||
class LangChainPendingDeprecationWarning(PendingDeprecationWarning):
|
||||
"""A class for issuing deprecation warnings for LangChain users."""
|
||||
|
||||
|
||||
# Tracks when callers intentionally silence LangChain deprecation warnings.
|
||||
# Suppressed warnings should not consume a deprecated callable's one-time
|
||||
# warning state; otherwise an internal compatibility path can prevent the first
|
||||
# user-visible call from warning.
|
||||
_SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING = ContextVar(
|
||||
"_SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING", default=False
|
||||
)
|
||||
|
||||
|
||||
# PUBLIC API
|
||||
|
||||
|
||||
# Bound is `Any` (not `FieldInfoV1`) because importing `pydantic.v1` at module
|
||||
# scope emits a `UserWarning` on Python 3.14+; v1 `FieldInfo` support is handled
|
||||
# at runtime via `_is_pydantic_v1_field_info`.
|
||||
T = TypeVar("T", bound=type | Callable[..., Any] | Any)
|
||||
|
||||
|
||||
def _validate_deprecation_params(
|
||||
removal: str,
|
||||
alternative: str,
|
||||
alternative_import: str,
|
||||
*,
|
||||
pending: bool,
|
||||
) -> None:
|
||||
"""Validate the deprecation parameters."""
|
||||
if pending and removal:
|
||||
msg = "A pending deprecation cannot have a scheduled removal"
|
||||
raise ValueError(msg)
|
||||
if alternative and alternative_import:
|
||||
msg = "Cannot specify both alternative and alternative_import"
|
||||
raise ValueError(msg)
|
||||
|
||||
if alternative_import and "." not in alternative_import:
|
||||
msg = (
|
||||
"alternative_import must be a fully qualified module path. Got "
|
||||
f" {alternative_import}"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
|
||||
def deprecated(
|
||||
since: str,
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
pending: bool = False,
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
removal: str = "",
|
||||
package: str = "",
|
||||
) -> Callable[[T], T]:
|
||||
"""Decorator to mark a function, a class, or a property as deprecated.
|
||||
|
||||
When deprecating a classmethod, a staticmethod, or a property, the `@deprecated`
|
||||
decorator should go *under* `@classmethod` and `@staticmethod` (i.e., `deprecated`
|
||||
should directly decorate the underlying callable), but *over* `@property`.
|
||||
|
||||
When deprecating a class `C` intended to be used as a base class in a multiple
|
||||
inheritance hierarchy, `C` *must* define an `__init__` method (if `C` instead
|
||||
inherited its `__init__` from its own base class, then `@deprecated` would mess up
|
||||
`__init__` inheritance when installing its own (deprecation-emitting) `C.__init__`).
|
||||
|
||||
Parameters are the same as for `warn_deprecated`, except that *obj_type* defaults to
|
||||
'class' if decorating a class, 'attribute' if decorating a property, and 'function'
|
||||
otherwise.
|
||||
|
||||
Args:
|
||||
since: The release at which this API became deprecated.
|
||||
message: Override the default deprecation message.
|
||||
|
||||
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
|
||||
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
|
||||
values of the respective arguments passed to this function.
|
||||
name: The name of the deprecated object.
|
||||
alternative: An alternative API that the user may use in place of the deprecated
|
||||
API.
|
||||
|
||||
The deprecation warning will tell the user about this alternative if
|
||||
provided.
|
||||
alternative_import: An alternative import that the user may use instead.
|
||||
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
|
||||
`DeprecationWarning`.
|
||||
|
||||
Cannot be used together with removal.
|
||||
obj_type: The object type being deprecated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
removal: The expected removal version.
|
||||
|
||||
With the default (an empty string), no removal version is shown in the
|
||||
warning message.
|
||||
|
||||
Cannot be used together with pending.
|
||||
package: The package of the deprecated object.
|
||||
|
||||
Returns:
|
||||
A decorator to mark a function or class as deprecated.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@deprecated("1.4.0")
|
||||
def the_function_to_deprecate():
|
||||
pass
|
||||
```
|
||||
"""
|
||||
_validate_deprecation_params(
|
||||
removal, alternative, alternative_import, pending=pending
|
||||
)
|
||||
|
||||
def deprecate(
|
||||
obj: T,
|
||||
*,
|
||||
_obj_type: str = obj_type,
|
||||
_name: str = name,
|
||||
_message: str = message,
|
||||
_alternative: str = alternative,
|
||||
_alternative_import: str = alternative_import,
|
||||
_pending: bool = pending,
|
||||
_addendum: str = addendum,
|
||||
_package: str = package,
|
||||
) -> T:
|
||||
"""Implementation of the decorator returned by `deprecated`."""
|
||||
|
||||
def emit_warning() -> None:
|
||||
"""Emit the warning."""
|
||||
warn_deprecated(
|
||||
since,
|
||||
message=_message,
|
||||
name=_name,
|
||||
alternative=_alternative,
|
||||
alternative_import=_alternative_import,
|
||||
pending=_pending,
|
||||
obj_type=_obj_type,
|
||||
addendum=_addendum,
|
||||
removal=removal,
|
||||
package=_package,
|
||||
)
|
||||
|
||||
warned = False
|
||||
|
||||
def warning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Wrapper for the original wrapped callable that emits a warning.
|
||||
|
||||
Args:
|
||||
*args: The positional arguments to the function.
|
||||
**kwargs: The keyword arguments to the function.
|
||||
|
||||
Returns:
|
||||
The return value of the function being wrapped.
|
||||
"""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
emit_warning()
|
||||
# Only mark the warning as emitted if it was not intentionally
|
||||
# suppressed by `suppress_langchain_deprecation_warning()`.
|
||||
warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get()
|
||||
return wrapped(*args, **kwargs)
|
||||
|
||||
async def awarning_emitting_wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
"""Same as warning_emitting_wrapper, but for async functions."""
|
||||
nonlocal warned
|
||||
if not warned and not is_caller_internal():
|
||||
emit_warning()
|
||||
# Only mark the warning as emitted if it was not intentionally
|
||||
# suppressed by `suppress_langchain_deprecation_warning()`.
|
||||
warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get()
|
||||
return await wrapped(*args, **kwargs)
|
||||
|
||||
_package = _package or obj.__module__.split(".")[0].replace("_", "-")
|
||||
|
||||
if isinstance(obj, type):
|
||||
if not _obj_type:
|
||||
_obj_type = "class"
|
||||
wrapped = obj.__init__ # type: ignore[misc]
|
||||
_name = _name or obj.__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the deprecation of a class."""
|
||||
# Can't set new_doc on some extension objects.
|
||||
with contextlib.suppress(AttributeError):
|
||||
obj.__doc__ = new_doc
|
||||
|
||||
def warn_if_direct_instance(
|
||||
self: Any, *args: Any, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Warn that the class is in beta."""
|
||||
nonlocal warned
|
||||
if not warned and type(self) is obj and not is_caller_internal():
|
||||
emit_warning()
|
||||
# Only mark the warning as emitted if it was not intentionally
|
||||
# suppressed by `suppress_langchain_deprecation_warning()`.
|
||||
warned = not _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.get()
|
||||
return wrapped(self, *args, **kwargs)
|
||||
|
||||
obj.__init__ = functools.wraps(obj.__init__)( # type: ignore[misc]
|
||||
warn_if_direct_instance
|
||||
)
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
obj.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return obj
|
||||
|
||||
elif _is_pydantic_v1_field_info(obj):
|
||||
wrapped = None
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
if not _name:
|
||||
msg = f"Field {obj} must have a name to be deprecated."
|
||||
raise ValueError(msg)
|
||||
old_doc = obj.description
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
from pydantic.v1.fields import FieldInfo as FieldInfoV1 # noqa: PLC0415
|
||||
|
||||
return cast(
|
||||
"T",
|
||||
FieldInfoV1(
|
||||
default=obj.default,
|
||||
default_factory=obj.default_factory,
|
||||
description=new_doc,
|
||||
alias=obj.alias,
|
||||
exclude=obj.exclude,
|
||||
),
|
||||
)
|
||||
|
||||
elif isinstance(obj, FieldInfo):
|
||||
wrapped = None
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
if not _name:
|
||||
msg = f"Field {obj} must have a name to be deprecated."
|
||||
raise ValueError(msg)
|
||||
old_doc = obj.description
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
return cast(
|
||||
"T",
|
||||
FieldInfo(
|
||||
default=obj.default,
|
||||
default_factory=obj.default_factory,
|
||||
description=new_doc,
|
||||
alias=obj.alias,
|
||||
exclude=obj.exclude,
|
||||
),
|
||||
)
|
||||
|
||||
elif isinstance(obj, property):
|
||||
if not _obj_type:
|
||||
_obj_type = "attribute"
|
||||
wrapped = None
|
||||
_name = _name or cast("type", obj.fget).__qualname__
|
||||
old_doc = obj.__doc__
|
||||
|
||||
class _DeprecatedProperty(property):
|
||||
"""A deprecated property."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
fget: Callable[[Any], Any] | None = None,
|
||||
fset: Callable[[Any, Any], None] | None = None,
|
||||
fdel: Callable[[Any], None] | None = None,
|
||||
doc: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(fget, fset, fdel, doc)
|
||||
self.__orig_fget = fget
|
||||
self.__orig_fset = fset
|
||||
self.__orig_fdel = fdel
|
||||
|
||||
def __get__(self, instance: Any, owner: type | None = None) -> Any:
|
||||
if instance is not None or owner is not None:
|
||||
emit_warning()
|
||||
if self.fget is None:
|
||||
return None
|
||||
return self.fget(instance)
|
||||
|
||||
def __set__(self, instance: Any, value: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
if self.fset is not None:
|
||||
self.fset(instance, value)
|
||||
|
||||
def __delete__(self, instance: Any) -> None:
|
||||
if instance is not None:
|
||||
emit_warning()
|
||||
if self.fdel is not None:
|
||||
self.fdel(instance)
|
||||
|
||||
def __set_name__(self, owner: type | None, set_name: str) -> None:
|
||||
nonlocal _name
|
||||
if _name == "<lambda>":
|
||||
_name = set_name
|
||||
|
||||
def finalize(_: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Finalize the property."""
|
||||
prop = _DeprecatedProperty(
|
||||
fget=obj.fget, fset=obj.fset, fdel=obj.fdel, doc=new_doc
|
||||
)
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
prop.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return cast("T", prop)
|
||||
|
||||
else:
|
||||
_name = _name or cast("type", obj).__qualname__
|
||||
if not _obj_type:
|
||||
# edge case: when a function is within another function
|
||||
# within a test, this will call it a "method" not a "function"
|
||||
_obj_type = "function" if "." not in _name else "method"
|
||||
wrapped = obj
|
||||
old_doc = wrapped.__doc__
|
||||
|
||||
def finalize(wrapper: Callable[..., Any], new_doc: str, /) -> T:
|
||||
"""Wrap the wrapped function using the wrapper and update the docstring.
|
||||
|
||||
Args:
|
||||
wrapper: The wrapper function.
|
||||
new_doc: The new docstring.
|
||||
|
||||
Returns:
|
||||
The wrapped function.
|
||||
"""
|
||||
wrapper = functools.wraps(wrapped)(wrapper)
|
||||
wrapper.__doc__ = new_doc
|
||||
# Set __deprecated__ for PEP 702 (IDE/type checker support)
|
||||
wrapper.__deprecated__ = _build_deprecation_message( # type: ignore[attr-defined]
|
||||
alternative=alternative,
|
||||
alternative_import=alternative_import,
|
||||
)
|
||||
return cast("T", wrapper)
|
||||
|
||||
old_doc = inspect.cleandoc(old_doc or "").strip("\n")
|
||||
|
||||
# old_doc can be None
|
||||
if not old_doc:
|
||||
old_doc = ""
|
||||
|
||||
# Modify the docstring to include a deprecation notice.
|
||||
if (
|
||||
_alternative
|
||||
and _alternative.rsplit(".", maxsplit=1)[-1].lower()
|
||||
== _alternative.rsplit(".", maxsplit=1)[-1]
|
||||
) or _alternative:
|
||||
_alternative = f"`{_alternative}`"
|
||||
|
||||
if (
|
||||
_alternative_import
|
||||
and _alternative_import.rsplit(".", maxsplit=1)[-1].lower()
|
||||
== _alternative_import.rsplit(".", maxsplit=1)[-1]
|
||||
) or _alternative_import:
|
||||
_alternative_import = f"`{_alternative_import}`"
|
||||
|
||||
components = [
|
||||
_message,
|
||||
f"Use {_alternative} instead." if _alternative else "",
|
||||
f"Use {_alternative_import} instead." if _alternative_import else "",
|
||||
_addendum,
|
||||
]
|
||||
details = " ".join([component.strip() for component in components if component])
|
||||
package = _package or (
|
||||
_name.split(".")[0].replace("_", "-") if "." in _name else None
|
||||
)
|
||||
if removal:
|
||||
if removal.startswith("1.") and package and package.startswith("langchain"):
|
||||
removal_str = f"It will not be removed until {package}=={removal}."
|
||||
else:
|
||||
removal_str = f"It will be removed in {package}=={removal}."
|
||||
else:
|
||||
removal_str = ""
|
||||
new_doc = f"""\
|
||||
!!! deprecated "{since} {details} {removal_str}"
|
||||
|
||||
{old_doc}\
|
||||
"""
|
||||
|
||||
if inspect.iscoroutinefunction(obj):
|
||||
return finalize(awarning_emitting_wrapper, new_doc)
|
||||
return finalize(warning_emitting_wrapper, new_doc)
|
||||
|
||||
return deprecate
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def suppress_langchain_deprecation_warning() -> Generator[None, None, None]:
|
||||
"""Context manager to suppress `LangChainDeprecationWarning`."""
|
||||
token = _SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.set(True)
|
||||
try:
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", LangChainDeprecationWarning)
|
||||
warnings.simplefilter("ignore", LangChainPendingDeprecationWarning)
|
||||
yield
|
||||
finally:
|
||||
_SUPPRESSING_LANGCHAIN_DEPRECATION_WARNING.reset(token)
|
||||
|
||||
|
||||
def warn_deprecated(
|
||||
since: str,
|
||||
*,
|
||||
message: str = "",
|
||||
name: str = "",
|
||||
alternative: str = "",
|
||||
alternative_import: str = "",
|
||||
pending: bool = False,
|
||||
obj_type: str = "",
|
||||
addendum: str = "",
|
||||
removal: str = "",
|
||||
package: str = "",
|
||||
) -> None:
|
||||
"""Display a standardized deprecation.
|
||||
|
||||
Args:
|
||||
since: The release at which this API became deprecated.
|
||||
message: Override the default deprecation message.
|
||||
|
||||
The `%(since)s`, `%(name)s`, `%(alternative)s`, `%(obj_type)s`,
|
||||
`%(addendum)s`, and `%(removal)s` format specifiers will be replaced by the
|
||||
values of the respective arguments passed to this function.
|
||||
name: The name of the deprecated object.
|
||||
alternative: An alternative API that the user may use in place of the
|
||||
deprecated API.
|
||||
|
||||
The deprecation warning will tell the user about this alternative if
|
||||
provided.
|
||||
alternative_import: An alternative import that the user may use instead.
|
||||
pending: If `True`, uses a `PendingDeprecationWarning` instead of a
|
||||
`DeprecationWarning`.
|
||||
|
||||
Cannot be used together with removal.
|
||||
obj_type: The object type being deprecated.
|
||||
addendum: Additional text appended directly to the final message.
|
||||
removal: The expected removal version.
|
||||
|
||||
With the default (an empty string), no removal version is shown in the
|
||||
warning message.
|
||||
|
||||
Cannot be used together with pending.
|
||||
package: The package of the deprecated object.
|
||||
"""
|
||||
if not pending and removal:
|
||||
removal = f"in {removal}"
|
||||
|
||||
if not message:
|
||||
message = ""
|
||||
package_ = (
|
||||
package or name.split(".", maxsplit=1)[0].replace("_", "-")
|
||||
if "." in name
|
||||
else "LangChain"
|
||||
)
|
||||
|
||||
if obj_type:
|
||||
message += f"The {obj_type} `{name}`"
|
||||
else:
|
||||
message += f"`{name}`"
|
||||
|
||||
if pending:
|
||||
message += " will be deprecated in a future version"
|
||||
else:
|
||||
message += f" was deprecated in {package_} {since}"
|
||||
|
||||
if removal:
|
||||
message += f" and will be removed {removal}"
|
||||
|
||||
if alternative_import:
|
||||
alt_package = alternative_import.split(".", maxsplit=1)[0].replace("_", "-")
|
||||
if alt_package == package_:
|
||||
message += f". Use {alternative_import} instead."
|
||||
else:
|
||||
alt_module, alt_name = alternative_import.rsplit(".", 1)
|
||||
message += (
|
||||
f". An updated version of the {obj_type} exists in the "
|
||||
f"{alt_package} package and should be used instead. To use it run "
|
||||
f"`pip install -U {alt_package}` and import as "
|
||||
f"`from {alt_module} import {alt_name}`."
|
||||
)
|
||||
elif alternative:
|
||||
message += f". Use {alternative} instead."
|
||||
|
||||
if addendum:
|
||||
message += f" {addendum}"
|
||||
|
||||
warning_cls = (
|
||||
LangChainPendingDeprecationWarning if pending else LangChainDeprecationWarning
|
||||
)
|
||||
warning = warning_cls(message)
|
||||
warnings.warn(warning, category=LangChainDeprecationWarning, stacklevel=4)
|
||||
|
||||
|
||||
def surface_langchain_deprecation_warnings() -> None:
|
||||
"""Unmute LangChain deprecation warnings."""
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainPendingDeprecationWarning,
|
||||
)
|
||||
|
||||
warnings.filterwarnings(
|
||||
"default",
|
||||
category=LangChainDeprecationWarning,
|
||||
)
|
||||
|
||||
|
||||
_P = ParamSpec("_P")
|
||||
_R = TypeVar("_R")
|
||||
|
||||
|
||||
def rename_parameter(
|
||||
*,
|
||||
since: str,
|
||||
removal: str,
|
||||
old: str,
|
||||
new: str,
|
||||
) -> Callable[[Callable[_P, _R]], Callable[_P, _R]]:
|
||||
"""Decorator indicating that parameter *old* of *func* is renamed to *new*.
|
||||
|
||||
The actual implementation of *func* should use *new*, not *old*. If *old* is passed
|
||||
to *func*, a `DeprecationWarning` is emitted, and its value is used, even if *new*
|
||||
is also passed by keyword.
|
||||
|
||||
Args:
|
||||
since: The version in which the parameter was renamed.
|
||||
removal: The version in which the old parameter will be removed.
|
||||
old: The old parameter name.
|
||||
new: The new parameter name.
|
||||
|
||||
Returns:
|
||||
A decorator indicating that a parameter was renamed.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@_api.rename_parameter("3.1", "bad_name", "good_name")
|
||||
def func(good_name): ...
|
||||
```
|
||||
"""
|
||||
|
||||
def decorator(f: Callable[_P, _R]) -> Callable[_P, _R]:
|
||||
@functools.wraps(f)
|
||||
def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> _R:
|
||||
if new in kwargs and old in kwargs:
|
||||
msg = f"{f.__name__}() got multiple values for argument {new!r}"
|
||||
raise TypeError(msg)
|
||||
if old in kwargs:
|
||||
warn_deprecated(
|
||||
since,
|
||||
removal=removal,
|
||||
message=f"The parameter `{old}` of `{f.__name__}` was "
|
||||
f"deprecated in {since} and will be removed "
|
||||
f"in {removal} Use `{new}` instead.",
|
||||
)
|
||||
kwargs[new] = kwargs.pop(old)
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
@@ -0,0 +1,23 @@
|
||||
import inspect
|
||||
from typing import cast
|
||||
|
||||
|
||||
def is_caller_internal(depth: int = 2) -> bool:
|
||||
"""Return whether the caller at `depth` of this function is internal."""
|
||||
try:
|
||||
frame = inspect.currentframe()
|
||||
except AttributeError:
|
||||
return False
|
||||
if frame is None:
|
||||
return False
|
||||
try:
|
||||
for _ in range(depth):
|
||||
frame = frame.f_back
|
||||
if frame is None:
|
||||
return False
|
||||
# Directly access the module name from the frame's global variables
|
||||
module_globals = frame.f_globals
|
||||
caller_module_name = cast("str", module_globals.get("__name__", ""))
|
||||
return caller_module_name.startswith("langchain")
|
||||
finally:
|
||||
del frame
|
||||
@@ -0,0 +1,50 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
HERE = Path(__file__).parent
|
||||
|
||||
# Get directory of langchain package
|
||||
PACKAGE_DIR = HERE.parent
|
||||
SEPARATOR = os.sep
|
||||
|
||||
|
||||
def get_relative_path(file: Path | str, *, relative_to: Path = PACKAGE_DIR) -> str:
|
||||
"""Get the path of the file as a relative path to the package directory.
|
||||
|
||||
Args:
|
||||
file: The file path to convert.
|
||||
relative_to: The base path to make the file path relative to.
|
||||
|
||||
Returns:
|
||||
The relative path as a string.
|
||||
"""
|
||||
if isinstance(file, str):
|
||||
file = Path(file)
|
||||
return str(file.relative_to(relative_to))
|
||||
|
||||
|
||||
def as_import_path(
|
||||
file: Path | str,
|
||||
*,
|
||||
suffix: str | None = None,
|
||||
relative_to: Path = PACKAGE_DIR,
|
||||
) -> str:
|
||||
"""Path of the file as a LangChain import exclude langchain top namespace.
|
||||
|
||||
Args:
|
||||
file: The file path to convert.
|
||||
suffix: An optional suffix to append to the import path.
|
||||
relative_to: The base path to make the file path relative to.
|
||||
|
||||
Returns:
|
||||
The import path as a string.
|
||||
"""
|
||||
if isinstance(file, str):
|
||||
file = Path(file)
|
||||
path = get_relative_path(file, relative_to=relative_to)
|
||||
if file.is_file():
|
||||
path = path[: -len(file.suffix)]
|
||||
import_path = path.replace(SEPARATOR, ".")
|
||||
if suffix:
|
||||
import_path += "." + suffix
|
||||
return import_path
|
||||
@@ -0,0 +1,41 @@
|
||||
from importlib import import_module
|
||||
|
||||
|
||||
def import_attr(
|
||||
attr_name: str,
|
||||
module_name: str | None,
|
||||
package: str | None,
|
||||
) -> object:
|
||||
"""Import an attribute from a module located in a package.
|
||||
|
||||
This utility function is used in custom `__getattr__` methods within `__init__.py`
|
||||
files to dynamically import attributes.
|
||||
|
||||
Args:
|
||||
attr_name: The name of the attribute to import.
|
||||
module_name: The name of the module to import from.
|
||||
|
||||
If `None`, the attribute is imported from the package itself.
|
||||
package: The name of the package where the module is located.
|
||||
|
||||
Raises:
|
||||
ImportError: If the module cannot be found.
|
||||
AttributeError: If the attribute does not exist in the module or package.
|
||||
|
||||
Returns:
|
||||
The imported attribute.
|
||||
"""
|
||||
if module_name == "__module__" or module_name is None:
|
||||
try:
|
||||
result = import_module(f".{attr_name}", package=package)
|
||||
except ModuleNotFoundError:
|
||||
msg = f"module '{package!r}' has no attribute {attr_name!r}"
|
||||
raise AttributeError(msg) from None
|
||||
else:
|
||||
try:
|
||||
module = import_module(f".{module_name}", package=package)
|
||||
except ModuleNotFoundError as err:
|
||||
msg = f"module '{package!r}.{module_name!r}' not found ({err})"
|
||||
raise ImportError(msg) from None
|
||||
result = getattr(module, attr_name)
|
||||
return result
|
||||
@@ -0,0 +1,36 @@
|
||||
"""SSRF protection and security utilities.
|
||||
|
||||
This is an **internal** module (note the `_security` prefix). It is NOT part of
|
||||
the public `langchain-core` API and may change or be removed at any time without
|
||||
notice. External code should not import from or depend on anything in this
|
||||
module. Any vulnerability reports should target the public APIs that use these
|
||||
utilities, not this internal module directly.
|
||||
"""
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
SSRFPolicy,
|
||||
validate_hostname,
|
||||
validate_resolved_ip,
|
||||
validate_url,
|
||||
validate_url_sync,
|
||||
)
|
||||
from langchain_core._security._transport import (
|
||||
SSRFSafeSyncTransport,
|
||||
SSRFSafeTransport,
|
||||
ssrf_safe_async_client,
|
||||
ssrf_safe_client,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"SSRFBlockedError",
|
||||
"SSRFPolicy",
|
||||
"SSRFSafeSyncTransport",
|
||||
"SSRFSafeTransport",
|
||||
"ssrf_safe_async_client",
|
||||
"ssrf_safe_client",
|
||||
"validate_hostname",
|
||||
"validate_resolved_ip",
|
||||
"validate_url",
|
||||
"validate_url_sync",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""SSRF protection exceptions."""
|
||||
|
||||
|
||||
class SSRFBlockedError(Exception):
|
||||
"""Raised when a request is blocked by SSRF protection policy."""
|
||||
|
||||
def __init__(self, reason: str) -> None:
|
||||
self.reason = reason
|
||||
super().__init__(f"SSRF blocked: {reason}")
|
||||
@@ -0,0 +1,313 @@
|
||||
"""SSRF protection policy with IP validation and DNS-aware URL checking."""
|
||||
|
||||
import asyncio
|
||||
import dataclasses
|
||||
import ipaddress
|
||||
import os
|
||||
import socket
|
||||
import urllib.parse
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Blocklist constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_BLOCKED_IPV4_NETWORKS: tuple[ipaddress.IPv4Network, ...] = tuple(
|
||||
ipaddress.IPv4Network(n)
|
||||
for n in (
|
||||
"10.0.0.0/8", # RFC 1918 - private class A
|
||||
"172.16.0.0/12", # RFC 1918 - private class B
|
||||
"192.168.0.0/16", # RFC 1918 - private class C
|
||||
"127.0.0.0/8", # RFC 1122 - loopback
|
||||
"169.254.0.0/16", # RFC 3927 - link-local
|
||||
"0.0.0.0/8", # RFC 1122 - "this network"
|
||||
"100.64.0.0/10", # RFC 6598 - shared/CGN address space
|
||||
"192.0.0.0/24", # RFC 6890 - IETF protocol assignments
|
||||
"192.0.2.0/24", # RFC 5737 - TEST-NET-1 (documentation)
|
||||
"198.18.0.0/15", # RFC 2544 - benchmarking
|
||||
"198.51.100.0/24", # RFC 5737 - TEST-NET-2 (documentation)
|
||||
"203.0.113.0/24", # RFC 5737 - TEST-NET-3 (documentation)
|
||||
"224.0.0.0/4", # RFC 5771 - multicast
|
||||
"240.0.0.0/4", # RFC 1112 - reserved for future use
|
||||
"255.255.255.255/32", # RFC 919 - limited broadcast
|
||||
)
|
||||
)
|
||||
|
||||
_BLOCKED_IPV6_NETWORKS: tuple[ipaddress.IPv6Network, ...] = tuple(
|
||||
ipaddress.IPv6Network(n)
|
||||
for n in (
|
||||
"::1/128", # RFC 4291 - loopback
|
||||
"fc00::/7", # RFC 4193 - unique local addresses (ULA)
|
||||
"fe80::/10", # RFC 4291 - link-local
|
||||
"ff00::/8", # RFC 4291 - multicast
|
||||
"::ffff:0:0/96", # RFC 4291 - IPv4-mapped IPv6 addresses
|
||||
"::0.0.0.0/96", # RFC 4291 - IPv4-compatible IPv6 (deprecated)
|
||||
"64:ff9b::/96", # RFC 6052 - NAT64 well-known prefix
|
||||
"64:ff9b:1::/48", # RFC 8215 - NAT64 discovery prefix
|
||||
)
|
||||
)
|
||||
|
||||
_CLOUD_METADATA_IPS: frozenset[str] = frozenset(
|
||||
{
|
||||
"169.254.169.254", # AWS, GCP, Azure, DigitalOcean, Oracle Cloud
|
||||
"169.254.170.2", # AWS ECS task metadata
|
||||
"169.254.170.23", # AWS EKS Pod Identity Agent
|
||||
"100.100.100.200", # Alibaba Cloud metadata
|
||||
"fd00:ec2::254", # AWS EC2 IMDSv2 over IPv6 (Nitro instances)
|
||||
"fd00:ec2::23", # AWS EKS Pod Identity Agent (IPv6)
|
||||
"fe80::a9fe:a9fe", # OpenStack Nova metadata (IPv6 link-local)
|
||||
}
|
||||
)
|
||||
|
||||
# Network ranges that are always blocked when block_cloud_metadata=True,
|
||||
# independent of block_private_ips. The entire link-local range is used by
|
||||
# cloud metadata services across providers.
|
||||
_CLOUD_METADATA_NETWORKS: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = (
|
||||
ipaddress.IPv4Network("169.254.0.0/16"),
|
||||
)
|
||||
|
||||
_CLOUD_METADATA_HOSTNAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"metadata.google.internal",
|
||||
"metadata.amazonaws.com",
|
||||
"metadata",
|
||||
"instance-data",
|
||||
}
|
||||
)
|
||||
|
||||
_LOCALHOST_NAMES: frozenset[str] = frozenset(
|
||||
{
|
||||
"localhost",
|
||||
"localhost.localdomain",
|
||||
"host.docker.internal",
|
||||
}
|
||||
)
|
||||
|
||||
_K8S_SUFFIX = ".svc.cluster.local"
|
||||
|
||||
_LOOPBACK_IPV4 = ipaddress.IPv4Network("127.0.0.0/8")
|
||||
_LOOPBACK_IPV6 = ipaddress.IPv6Address("::1")
|
||||
|
||||
# NAT64 well-known prefixes
|
||||
_NAT64_PREFIX = ipaddress.IPv6Network("64:ff9b::/96")
|
||||
_NAT64_DISCOVERY_PREFIX = ipaddress.IPv6Network("64:ff9b:1::/48")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SSRFPolicy
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class SSRFPolicy:
|
||||
"""Immutable policy controlling which URLs/IPs are considered safe."""
|
||||
|
||||
allowed_schemes: frozenset[str] = frozenset({"http", "https"})
|
||||
block_private_ips: bool = True
|
||||
block_localhost: bool = True
|
||||
block_cloud_metadata: bool = True
|
||||
block_k8s_internal: bool = True
|
||||
allowed_hosts: frozenset[str] = frozenset()
|
||||
additional_blocked_cidrs: tuple[
|
||||
ipaddress.IPv4Network | ipaddress.IPv6Network, ...
|
||||
] = ()
|
||||
|
||||
|
||||
DEFAULT_SSRF_POLICY = SSRFPolicy()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_embedded_ipv4(
|
||||
addr: ipaddress.IPv6Address,
|
||||
) -> ipaddress.IPv4Address | None:
|
||||
"""Extract an embedded IPv4 from IPv4-mapped or NAT64 IPv6 addresses."""
|
||||
# Check ipv4_mapped first (covers ::ffff:x.x.x.x)
|
||||
if addr.ipv4_mapped is not None:
|
||||
return addr.ipv4_mapped
|
||||
|
||||
# Check NAT64 prefixes — embedded IPv4 is in the last 4 bytes
|
||||
if addr in _NAT64_PREFIX or addr in _NAT64_DISCOVERY_PREFIX:
|
||||
raw = addr.packed
|
||||
return ipaddress.IPv4Address(raw[-4:])
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ip_in_blocked_networks(
|
||||
addr: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
policy: SSRFPolicy,
|
||||
) -> str | None:
|
||||
"""Return a reason string if *addr* falls in a blocked range, else None."""
|
||||
# NOTE: if profiling shows this is a hot path, consider memoising with
|
||||
# @functools.lru_cache (key on (addr, id(policy))).
|
||||
if isinstance(addr, ipaddress.IPv4Address):
|
||||
if policy.block_private_ips:
|
||||
for blocked_ipv4_net in _BLOCKED_IPV4_NETWORKS:
|
||||
if addr in blocked_ipv4_net:
|
||||
return "private IP range"
|
||||
for blocked_cidr in policy.additional_blocked_cidrs:
|
||||
if isinstance(blocked_cidr, ipaddress.IPv4Network) and addr in blocked_cidr:
|
||||
return "blocked CIDR"
|
||||
else:
|
||||
if policy.block_private_ips:
|
||||
for blocked_ipv6_net in _BLOCKED_IPV6_NETWORKS:
|
||||
if addr in blocked_ipv6_net:
|
||||
return "private IP range"
|
||||
for blocked_cidr in policy.additional_blocked_cidrs:
|
||||
if isinstance(blocked_cidr, ipaddress.IPv6Network) and addr in blocked_cidr:
|
||||
return "blocked CIDR"
|
||||
|
||||
# Loopback check — independent of block_private_ips so that
|
||||
# block_localhost=True still catches 127.x.x.x / ::1 even when
|
||||
# private IPs are allowed.
|
||||
if policy.block_localhost:
|
||||
if isinstance(addr, ipaddress.IPv4Address) and (
|
||||
addr in _LOOPBACK_IPV4 or addr in ipaddress.IPv4Network("0.0.0.0/8")
|
||||
):
|
||||
return "localhost address"
|
||||
if isinstance(addr, ipaddress.IPv6Address) and addr == _LOOPBACK_IPV6:
|
||||
return "localhost address"
|
||||
|
||||
# Cloud metadata check — IP set *and* network ranges (e.g. 169.254.0.0/16).
|
||||
# Independent of block_private_ips so that allow_private=True still blocks
|
||||
# cloud metadata endpoints.
|
||||
if policy.block_cloud_metadata:
|
||||
if str(addr) in _CLOUD_METADATA_IPS:
|
||||
return "cloud metadata endpoint"
|
||||
for net in _CLOUD_METADATA_NETWORKS:
|
||||
if addr in net:
|
||||
return "cloud metadata endpoint"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Public validation functions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def validate_resolved_ip(ip_str: str, policy: SSRFPolicy) -> None:
|
||||
"""Validate a resolved IP address against the SSRF policy.
|
||||
|
||||
Raises SSRFBlockedError if the IP is blocked.
|
||||
"""
|
||||
try:
|
||||
addr = ipaddress.ip_address(ip_str)
|
||||
except ValueError as exc:
|
||||
msg = "invalid IP address"
|
||||
raise SSRFBlockedError(msg) from exc
|
||||
|
||||
if isinstance(addr, ipaddress.IPv6Address):
|
||||
inner = _extract_embedded_ipv4(addr)
|
||||
if inner is not None:
|
||||
addr = inner
|
||||
|
||||
reason = _ip_in_blocked_networks(addr, policy)
|
||||
if reason is not None:
|
||||
raise SSRFBlockedError(reason)
|
||||
|
||||
|
||||
def validate_hostname(hostname: str, policy: SSRFPolicy) -> None:
|
||||
"""Validate a hostname against the SSRF policy.
|
||||
|
||||
Raises SSRFBlockedError if the hostname is blocked.
|
||||
"""
|
||||
lower = hostname.lower()
|
||||
|
||||
if policy.block_localhost and lower in _LOCALHOST_NAMES:
|
||||
msg = "localhost address"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
if policy.block_cloud_metadata and lower in _CLOUD_METADATA_HOSTNAMES:
|
||||
msg = "cloud metadata endpoint"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
if policy.block_k8s_internal and lower.endswith(_K8S_SUFFIX):
|
||||
msg = "Kubernetes internal DNS"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
|
||||
def _effective_allowed_hosts(policy: SSRFPolicy) -> frozenset[str]:
|
||||
"""Return allowed_hosts, augmented for local environments."""
|
||||
extra: set[str] = set()
|
||||
if os.environ.get("LANGCHAIN_ENV", "").startswith("local"):
|
||||
extra.update({"localhost", "testserver"})
|
||||
if extra:
|
||||
return policy.allowed_hosts | frozenset(extra)
|
||||
return policy.allowed_hosts
|
||||
|
||||
|
||||
async def validate_url(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None:
|
||||
"""Validate a URL against the SSRF policy, including DNS resolution.
|
||||
|
||||
This is the primary entry-point for async code paths. It delegates
|
||||
scheme/hostname/allowed-hosts checks to `validate_url_sync`, then
|
||||
resolves DNS and validates every resolved IP.
|
||||
|
||||
Raises:
|
||||
SSRFBlockedError: If the URL violates the policy.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
validate_url_sync(url, policy)
|
||||
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return
|
||||
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
port = parsed.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = await asyncio.to_thread(
|
||||
socket.getaddrinfo, hostname, port, type=socket.SOCK_STREAM
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
msg = "DNS resolution failed"
|
||||
raise SSRFBlockedError(msg) from exc
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
validate_resolved_ip(str(sockaddr[0]), policy)
|
||||
|
||||
|
||||
def validate_url_sync(url: str, policy: SSRFPolicy = DEFAULT_SSRF_POLICY) -> None:
|
||||
"""Synchronous URL validation (no DNS resolution).
|
||||
|
||||
Suitable for Pydantic validators and other sync contexts. Checks scheme
|
||||
and hostname patterns only - use `validate_url` for full DNS-aware checking.
|
||||
|
||||
Raises:
|
||||
SSRFBlockedError: If the URL violates the policy.
|
||||
"""
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
|
||||
scheme = (parsed.scheme or "").lower()
|
||||
if scheme not in policy.allowed_schemes:
|
||||
msg = f"scheme '{scheme}' not allowed"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
hostname = parsed.hostname
|
||||
if not hostname:
|
||||
msg = "missing hostname"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
allowed = _effective_allowed_hosts(policy)
|
||||
if hostname.lower() in {h.lower() for h in allowed}:
|
||||
return
|
||||
|
||||
try:
|
||||
ipaddress.ip_address(hostname)
|
||||
validate_resolved_ip(hostname, policy)
|
||||
except SSRFBlockedError:
|
||||
raise
|
||||
except ValueError:
|
||||
pass
|
||||
else:
|
||||
return
|
||||
|
||||
validate_hostname(hostname, policy)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""SSRF Protection - thin wrapper raising ValueError for internal callers.
|
||||
|
||||
Delegates all validation to `langchain_core._security._policy`.
|
||||
"""
|
||||
|
||||
import os
|
||||
import socket
|
||||
from typing import Annotated, Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from pydantic import (
|
||||
AnyHttpUrl,
|
||||
BeforeValidator,
|
||||
HttpUrl,
|
||||
)
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
SSRFPolicy,
|
||||
)
|
||||
from langchain_core._security._policy import (
|
||||
validate_resolved_ip as _validate_resolved_ip,
|
||||
)
|
||||
from langchain_core._security._policy import (
|
||||
validate_url_sync as _validate_url_sync,
|
||||
)
|
||||
|
||||
|
||||
def _policy_for(*, allow_private: bool, allow_http: bool) -> SSRFPolicy:
|
||||
"""Build an `SSRFPolicy` from the legacy flag interface."""
|
||||
schemes = frozenset({"http", "https"}) if allow_http else frozenset({"https"})
|
||||
return SSRFPolicy(
|
||||
allowed_schemes=schemes,
|
||||
block_private_ips=not allow_private,
|
||||
block_localhost=not allow_private,
|
||||
block_cloud_metadata=True,
|
||||
block_k8s_internal=True,
|
||||
)
|
||||
|
||||
|
||||
def validate_safe_url(
|
||||
url: str | AnyHttpUrl,
|
||||
*,
|
||||
allow_private: bool = False,
|
||||
allow_http: bool = True,
|
||||
) -> str:
|
||||
"""Validate a URL for SSRF protection.
|
||||
|
||||
This function validates URLs to prevent Server-Side Request Forgery (SSRF) attacks
|
||||
by blocking requests to private networks and cloud metadata endpoints.
|
||||
|
||||
Args:
|
||||
url: The URL to validate (string or Pydantic HttpUrl).
|
||||
allow_private: If `True`, allows private IPs and localhost (for development).
|
||||
Cloud metadata endpoints are ALWAYS blocked.
|
||||
allow_http: If `True`, allows both HTTP and HTTPS. If `False`, only HTTPS.
|
||||
|
||||
Returns:
|
||||
The validated URL as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If URL is invalid or potentially dangerous.
|
||||
"""
|
||||
url_str = str(url)
|
||||
parsed = urlparse(url_str)
|
||||
hostname = parsed.hostname or ""
|
||||
|
||||
# Test-environment bypass (preserved from original implementation)
|
||||
if (
|
||||
os.environ.get("LANGCHAIN_ENV") == "local_test"
|
||||
and hostname.startswith("test")
|
||||
and "server" in hostname
|
||||
):
|
||||
return url_str
|
||||
|
||||
policy = _policy_for(allow_private=allow_private, allow_http=allow_http)
|
||||
|
||||
# Synchronous scheme + hostname checks
|
||||
try:
|
||||
_validate_url_sync(url_str, policy)
|
||||
except SSRFBlockedError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
# DNS resolution and IP validation
|
||||
try:
|
||||
addr_info = socket.getaddrinfo(
|
||||
hostname,
|
||||
parsed.port or (443 if parsed.scheme == "https" else 80),
|
||||
socket.AF_UNSPEC,
|
||||
socket.SOCK_STREAM,
|
||||
)
|
||||
|
||||
for result in addr_info:
|
||||
ip_str: str = result[4][0] # type: ignore[assignment]
|
||||
try:
|
||||
_validate_resolved_ip(ip_str, policy)
|
||||
except SSRFBlockedError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
|
||||
except socket.gaierror as e:
|
||||
msg = f"Failed to resolve hostname '{hostname}': {e}"
|
||||
raise ValueError(msg) from e
|
||||
except OSError as e:
|
||||
msg = f"Network error while validating URL: {e}"
|
||||
raise ValueError(msg) from e
|
||||
|
||||
return url_str
|
||||
|
||||
|
||||
def is_safe_url(
|
||||
url: str | AnyHttpUrl,
|
||||
*,
|
||||
allow_private: bool = False,
|
||||
allow_http: bool = True,
|
||||
) -> bool:
|
||||
"""Non-throwing version of `validate_safe_url`."""
|
||||
try:
|
||||
validate_safe_url(url, allow_private=allow_private, allow_http=allow_http)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
|
||||
|
||||
def _validate_url_ssrf_strict(v: Any) -> Any:
|
||||
"""Validate URL for SSRF protection (strict mode)."""
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=False, allow_http=True)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_url_ssrf_https_only(v: Any) -> Any:
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=False, allow_http=False)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_url_ssrf_relaxed(v: Any) -> Any:
|
||||
"""Validate URL for SSRF protection (relaxed mode - allows private IPs)."""
|
||||
if isinstance(v, str):
|
||||
validate_safe_url(v, allow_private=True, allow_http=True)
|
||||
return v
|
||||
|
||||
|
||||
# Annotated types with SSRF protection
|
||||
SSRFProtectedUrl = Annotated[HttpUrl, BeforeValidator(_validate_url_ssrf_strict)]
|
||||
SSRFProtectedUrlRelaxed = Annotated[
|
||||
HttpUrl, BeforeValidator(_validate_url_ssrf_relaxed)
|
||||
]
|
||||
SSRFProtectedHttpsUrl = Annotated[
|
||||
HttpUrl, BeforeValidator(_validate_url_ssrf_https_only)
|
||||
]
|
||||
SSRFProtectedHttpsUrlStr = Annotated[
|
||||
str, BeforeValidator(_validate_url_ssrf_https_only)
|
||||
]
|
||||
@@ -0,0 +1,254 @@
|
||||
"""SSRF-safe httpx transport with DNS resolution and IP pinning."""
|
||||
|
||||
import asyncio
|
||||
import socket
|
||||
|
||||
import httpx
|
||||
|
||||
from langchain_core._security._exceptions import SSRFBlockedError
|
||||
from langchain_core._security._policy import (
|
||||
DEFAULT_SSRF_POLICY,
|
||||
SSRFPolicy,
|
||||
_effective_allowed_hosts,
|
||||
validate_resolved_ip,
|
||||
validate_url_sync,
|
||||
)
|
||||
|
||||
# Keys that AsyncHTTPTransport accepts (forwarded from factory kwargs).
|
||||
_TRANSPORT_KWARGS = frozenset(
|
||||
{
|
||||
"verify",
|
||||
"cert",
|
||||
"trust_env",
|
||||
"http1",
|
||||
"http2",
|
||||
"limits",
|
||||
"retries",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class SSRFSafeTransport(httpx.AsyncBaseTransport):
|
||||
"""httpx async transport that validates DNS results against an SSRF policy.
|
||||
|
||||
For every outgoing request the transport:
|
||||
1. Checks the URL scheme against `policy.allowed_schemes`.
|
||||
2. Validates the hostname against blocked patterns.
|
||||
3. Resolves DNS and validates **all** returned IPs.
|
||||
4. Rewrites the request to connect to the first valid IP while
|
||||
preserving the original `Host` header and TLS SNI hostname.
|
||||
|
||||
Redirects are re-validated on each hop because `follow_redirects`
|
||||
is set on the *client*, causing `handle_async_request` to be called
|
||||
again for each redirect target.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: SSRFPolicy = DEFAULT_SSRF_POLICY,
|
||||
**transport_kwargs: object,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
self._inner = httpx.AsyncHTTPTransport(**transport_kwargs) # type: ignore[arg-type]
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Core request handler
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def handle_async_request(
|
||||
self,
|
||||
request: httpx.Request,
|
||||
) -> httpx.Response:
|
||||
hostname = request.url.host or ""
|
||||
scheme = request.url.scheme.lower()
|
||||
|
||||
# 1-3. Scheme, hostname, and pattern checks (reuse sync validator).
|
||||
validate_url_sync(str(request.url), self._policy)
|
||||
|
||||
# Allowed-hosts bypass - skip DNS/IP validation entirely.
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return await self._inner.handle_async_request(request)
|
||||
|
||||
# 4. DNS resolution
|
||||
port = request.url.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = await asyncio.to_thread(
|
||||
socket.getaddrinfo,
|
||||
hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
msg = "DNS resolution failed"
|
||||
raise SSRFBlockedError(msg) from exc
|
||||
|
||||
if not addrinfo:
|
||||
msg = "DNS resolution returned no results"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
# 5. Validate ALL resolved IPs - any blocked means reject.
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
ip_str: str = sockaddr[0] # type: ignore[assignment]
|
||||
validate_resolved_ip(ip_str, self._policy)
|
||||
|
||||
# 6. Pin to first resolved IP.
|
||||
pinned_ip = addrinfo[0][4][0]
|
||||
|
||||
# 7. Rewrite URL to use pinned IP, preserving Host header and SNI.
|
||||
pinned_url = request.url.copy_with(host=pinned_ip)
|
||||
|
||||
# Build extensions dict, adding sni_hostname for HTTPS so TLS
|
||||
# certificate validation uses the original hostname.
|
||||
extensions = dict(request.extensions)
|
||||
if scheme == "https":
|
||||
extensions["sni_hostname"] = hostname.encode("ascii")
|
||||
|
||||
pinned_request = httpx.Request(
|
||||
method=request.method,
|
||||
url=pinned_url,
|
||||
headers=request.headers, # Host header already set to original
|
||||
content=request.content,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
return await self._inner.handle_async_request(pinned_request)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Lifecycle
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
async def aclose(self) -> None:
|
||||
await self._inner.aclose()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Factory
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
class SSRFSafeSyncTransport(httpx.BaseTransport):
|
||||
"""httpx sync transport that validates DNS results against an SSRF policy.
|
||||
|
||||
Sync mirror of `SSRFSafeTransport`. See that class for full documentation.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
policy: SSRFPolicy = DEFAULT_SSRF_POLICY,
|
||||
**transport_kwargs: object,
|
||||
) -> None:
|
||||
self._policy = policy
|
||||
self._inner = httpx.HTTPTransport(**transport_kwargs) # type: ignore[arg-type]
|
||||
|
||||
def handle_request(
|
||||
self,
|
||||
request: httpx.Request,
|
||||
) -> httpx.Response:
|
||||
hostname = request.url.host or ""
|
||||
scheme = request.url.scheme.lower()
|
||||
|
||||
validate_url_sync(str(request.url), self._policy)
|
||||
|
||||
allowed = {h.lower() for h in _effective_allowed_hosts(self._policy)}
|
||||
if hostname.lower() in allowed:
|
||||
return self._inner.handle_request(request)
|
||||
|
||||
port = request.url.port or (443 if scheme == "https" else 80)
|
||||
try:
|
||||
addrinfo = socket.getaddrinfo(
|
||||
hostname,
|
||||
port,
|
||||
type=socket.SOCK_STREAM,
|
||||
)
|
||||
except socket.gaierror as exc:
|
||||
msg = "DNS resolution failed"
|
||||
raise SSRFBlockedError(msg) from exc
|
||||
|
||||
if not addrinfo:
|
||||
msg = "DNS resolution returned no results"
|
||||
raise SSRFBlockedError(msg)
|
||||
|
||||
for _family, _type, _proto, _canonname, sockaddr in addrinfo:
|
||||
ip_str: str = sockaddr[0] # type: ignore[assignment]
|
||||
validate_resolved_ip(ip_str, self._policy)
|
||||
|
||||
pinned_ip = addrinfo[0][4][0]
|
||||
pinned_url = request.url.copy_with(host=pinned_ip)
|
||||
|
||||
extensions = dict(request.extensions)
|
||||
if scheme == "https":
|
||||
extensions["sni_hostname"] = hostname.encode("ascii")
|
||||
|
||||
pinned_request = httpx.Request(
|
||||
method=request.method,
|
||||
url=pinned_url,
|
||||
headers=request.headers,
|
||||
content=request.content,
|
||||
extensions=extensions,
|
||||
)
|
||||
|
||||
return self._inner.handle_request(pinned_request)
|
||||
|
||||
def close(self) -> None:
|
||||
self._inner.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------- #
|
||||
# Factories
|
||||
# ---------------------------------------------------------------------- #
|
||||
|
||||
|
||||
def ssrf_safe_client(
|
||||
policy: SSRFPolicy = DEFAULT_SSRF_POLICY,
|
||||
**kwargs: object,
|
||||
) -> httpx.Client:
|
||||
"""Create an `httpx.Client` with SSRF protection."""
|
||||
transport_kwargs: dict[str, object] = {}
|
||||
client_kwargs: dict[str, object] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in _TRANSPORT_KWARGS:
|
||||
transport_kwargs[key] = value
|
||||
else:
|
||||
client_kwargs[key] = value
|
||||
|
||||
transport = SSRFSafeSyncTransport(policy=policy, **transport_kwargs)
|
||||
|
||||
client_kwargs.setdefault("follow_redirects", True)
|
||||
client_kwargs.setdefault("max_redirects", 10)
|
||||
|
||||
return httpx.Client(
|
||||
transport=transport,
|
||||
**client_kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def ssrf_safe_async_client(
|
||||
policy: SSRFPolicy = DEFAULT_SSRF_POLICY,
|
||||
**kwargs: object,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Create an `httpx.AsyncClient` with SSRF protection.
|
||||
|
||||
Drop-in replacement for `httpx.AsyncClient(...)` - callers just swap
|
||||
the constructor call. Transport-specific kwargs (`verify`, `cert`,
|
||||
`retries`, etc.) are forwarded to the inner `AsyncHTTPTransport`;
|
||||
everything else goes to the `AsyncClient`.
|
||||
"""
|
||||
transport_kwargs: dict[str, object] = {}
|
||||
client_kwargs: dict[str, object] = {}
|
||||
for key, value in kwargs.items():
|
||||
if key in _TRANSPORT_KWARGS:
|
||||
transport_kwargs[key] = value
|
||||
else:
|
||||
client_kwargs[key] = value
|
||||
|
||||
transport = SSRFSafeTransport(policy=policy, **transport_kwargs)
|
||||
|
||||
# Apply defaults only if not overridden by caller.
|
||||
client_kwargs.setdefault("follow_redirects", True)
|
||||
client_kwargs.setdefault("max_redirects", 10)
|
||||
|
||||
return httpx.AsyncClient(
|
||||
transport=transport,
|
||||
**client_kwargs, # type: ignore[arg-type]
|
||||
)
|
||||
@@ -0,0 +1,258 @@
|
||||
"""Schema definitions for representing agent actions, observations, and return values.
|
||||
|
||||
!!! warning
|
||||
|
||||
The schema definitions are provided for backwards compatibility.
|
||||
|
||||
!!! warning
|
||||
|
||||
New agents should be built using the
|
||||
[`langchain` library](https://pypi.org/project/langchain/), which provides a
|
||||
simpler and more flexible way to define agents.
|
||||
|
||||
See docs on [building agents](https://docs.langchain.com/oss/python/langchain/agents).
|
||||
|
||||
Agents use language models to choose a sequence of actions to take.
|
||||
|
||||
A basic agent works in the following manner:
|
||||
|
||||
1. Given a prompt an agent uses an LLM to request an action to take
|
||||
(e.g., a tool to run).
|
||||
2. The agent executes the action (e.g., runs the tool), and receives an observation.
|
||||
3. The agent returns the observation to the LLM, which can then be used to generate
|
||||
the next action.
|
||||
4. When the agent reaches a stopping condition, it returns a final return value.
|
||||
|
||||
The schemas for the agents themselves are defined in `langchain.agents.agent`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, Literal
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
FunctionMessage,
|
||||
HumanMessage,
|
||||
)
|
||||
|
||||
|
||||
class AgentAction(Serializable):
|
||||
"""Represents a request to execute an action by an agent.
|
||||
|
||||
The action consists of the name of the tool to execute and the input to pass
|
||||
to the tool. The log is used to pass along extra information about the action.
|
||||
"""
|
||||
|
||||
tool: str
|
||||
"""The name of the `Tool` to execute."""
|
||||
|
||||
tool_input: str | dict[Any, Any]
|
||||
"""The input to pass in to the `Tool`."""
|
||||
|
||||
log: str
|
||||
"""Additional information to log about the action.
|
||||
|
||||
This log can be used in a few ways. First, it can be used to audit what exactly the
|
||||
LLM predicted to lead to this `(tool, tool_input)`.
|
||||
|
||||
Second, it can be used in future iterations to show the LLMs prior thoughts. This is
|
||||
useful when `(tool, tool_input)` does not contain full information about the LLM
|
||||
prediction (for example, any `thought` before the tool/tool_input).
|
||||
"""
|
||||
|
||||
type: Literal["AgentAction"] = "AgentAction"
|
||||
|
||||
# Override init to support instantiation by position for backward compat.
|
||||
def __init__(
|
||||
self, tool: str, tool_input: str | dict[Any, Any], log: str, **kwargs: Any
|
||||
):
|
||||
"""Create an `AgentAction`.
|
||||
|
||||
Args:
|
||||
tool: The name of the tool to execute.
|
||||
tool_input: The input to pass in to the `Tool`.
|
||||
log: Additional information to log about the action.
|
||||
"""
|
||||
super().__init__(tool=tool, tool_input=tool_input, log=log, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""`AgentAction` is serializable.
|
||||
|
||||
Returns:
|
||||
`True`
|
||||
"""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "agent"]`
|
||||
"""
|
||||
return ["langchain", "schema", "agent"]
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Return the messages that correspond to this action."""
|
||||
return _convert_agent_action_to_messages(self)
|
||||
|
||||
|
||||
class AgentActionMessageLog(AgentAction):
|
||||
"""Representation of an action to be executed by an agent.
|
||||
|
||||
This is similar to `AgentAction`, but includes a message log consisting of
|
||||
chat messages.
|
||||
|
||||
This is useful when working with `ChatModels`, and is used to reconstruct
|
||||
conversation history from the agent's perspective.
|
||||
"""
|
||||
|
||||
message_log: Sequence[BaseMessage]
|
||||
"""Similar to log, this can be used to pass along extra information about what exact
|
||||
messages were predicted by the LLM before parsing out the `(tool, tool_input)`.
|
||||
|
||||
This is again useful if `(tool, tool_input)` cannot be used to fully recreate the
|
||||
LLM prediction, and you need that LLM prediction (for future agent iteration).
|
||||
|
||||
Compared to `log`, this is useful when the underlying LLM is a chat model (and
|
||||
therefore returns messages rather than a string).
|
||||
"""
|
||||
# Ignoring type because we're overriding the type from AgentAction.
|
||||
# And this is the correct thing to do in this case.
|
||||
# The type literal is used for serialization purposes.
|
||||
type: Literal["AgentActionMessageLog"] = "AgentActionMessageLog" # type: ignore[assignment]
|
||||
|
||||
|
||||
class AgentStep(Serializable):
|
||||
"""Result of running an `AgentAction`."""
|
||||
|
||||
action: AgentAction
|
||||
"""The `AgentAction` that was executed."""
|
||||
|
||||
observation: Any
|
||||
"""The result of the `AgentAction`."""
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Messages that correspond to this observation."""
|
||||
return _convert_agent_observation_to_messages(self.action, self.observation)
|
||||
|
||||
|
||||
class AgentFinish(Serializable):
|
||||
"""Final return value of an `ActionAgent`.
|
||||
|
||||
Agents return an `AgentFinish` when they have reached a stopping condition.
|
||||
"""
|
||||
|
||||
return_values: dict[Any, Any]
|
||||
"""Dictionary of return values."""
|
||||
|
||||
log: str
|
||||
"""Additional information to log about the return value.
|
||||
|
||||
This is used to pass along the full LLM prediction, not just the parsed out
|
||||
return value.
|
||||
|
||||
For example, if the full LLM prediction was `Final Answer: 2` you may want to just
|
||||
return `2` as a return value, but pass along the full string as a `log` (for
|
||||
debugging or observability purposes).
|
||||
"""
|
||||
type: Literal["AgentFinish"] = "AgentFinish"
|
||||
|
||||
def __init__(self, return_values: dict[Any, Any], log: str, **kwargs: Any):
|
||||
"""Override init to support instantiation by position for backward compat."""
|
||||
super().__init__(return_values=return_values, log=log, **kwargs)
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""Return `True` as this class is serializable."""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "agent"]`
|
||||
"""
|
||||
return ["langchain", "schema", "agent"]
|
||||
|
||||
@property
|
||||
def messages(self) -> Sequence[BaseMessage]:
|
||||
"""Messages that correspond to this observation."""
|
||||
return [AIMessage(content=self.log)]
|
||||
|
||||
|
||||
def _convert_agent_action_to_messages(
|
||||
agent_action: AgentAction,
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Convert an agent action to a message.
|
||||
|
||||
This code is used to reconstruct the original AI message from the agent action.
|
||||
|
||||
Args:
|
||||
agent_action: Agent action to convert.
|
||||
|
||||
Returns:
|
||||
`AIMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if isinstance(agent_action, AgentActionMessageLog):
|
||||
return agent_action.message_log
|
||||
return [AIMessage(content=agent_action.log)]
|
||||
|
||||
|
||||
def _convert_agent_observation_to_messages(
|
||||
agent_action: AgentAction, observation: Any
|
||||
) -> Sequence[BaseMessage]:
|
||||
"""Convert an agent action to a message.
|
||||
|
||||
This code is used to reconstruct the original AI message from the agent action.
|
||||
|
||||
Args:
|
||||
agent_action: Agent action to convert.
|
||||
observation: Observation to convert to a message.
|
||||
|
||||
Returns:
|
||||
`AIMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if isinstance(agent_action, AgentActionMessageLog):
|
||||
return [_create_function_message(agent_action, observation)]
|
||||
content = observation
|
||||
if not isinstance(observation, str):
|
||||
try:
|
||||
content = json.dumps(observation, ensure_ascii=False)
|
||||
except Exception:
|
||||
content = str(observation)
|
||||
return [HumanMessage(content=content)]
|
||||
|
||||
|
||||
def _create_function_message(
|
||||
agent_action: AgentAction, observation: Any
|
||||
) -> FunctionMessage:
|
||||
"""Convert agent action and observation into a function message.
|
||||
|
||||
Args:
|
||||
agent_action: the tool invocation request from the agent.
|
||||
observation: the result of the tool invocation.
|
||||
|
||||
Returns:
|
||||
`FunctionMessage` that corresponds to the original tool invocation.
|
||||
"""
|
||||
if not isinstance(observation, str):
|
||||
try:
|
||||
content = json.dumps(observation, ensure_ascii=False)
|
||||
except Exception:
|
||||
content = str(observation)
|
||||
else:
|
||||
content = observation
|
||||
return FunctionMessage(
|
||||
name=agent_action.tool,
|
||||
content=content,
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
"""Optional caching layer for language models.
|
||||
|
||||
Distinct from provider-based [prompt caching](https://docs.langchain.com/oss/python/langchain/models#prompt-caching).
|
||||
|
||||
!!! warning "Beta feature"
|
||||
|
||||
This is a beta feature. Please be wary of deploying experimental code to production
|
||||
unless you've taken appropriate precautions.
|
||||
|
||||
A cache is useful for two reasons:
|
||||
|
||||
1. It can save you money by reducing the number of API calls you make to the LLM
|
||||
provider if you're often requesting the same completion multiple times.
|
||||
2. It can speed up your application by reducing the number of API calls you make to the
|
||||
LLM provider.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Sequence
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.outputs import Generation
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
RETURN_VAL_TYPE = Sequence[Generation]
|
||||
|
||||
|
||||
class BaseCache(ABC):
|
||||
"""Interface for a caching layer for LLMs and Chat models.
|
||||
|
||||
The cache interface consists of the following methods:
|
||||
|
||||
- lookup: Look up a value based on a prompt and `llm_string`.
|
||||
- update: Update the cache based on a prompt and `llm_string`.
|
||||
- clear: Clear the cache.
|
||||
|
||||
In addition, the cache interface provides an async version of each method.
|
||||
|
||||
The default implementation of the async methods is to run the synchronous
|
||||
method in an executor. It's recommended to override the async methods
|
||||
and provide async implementations to avoid unnecessary overhead.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Look up based on `prompt` and `llm_string`.
|
||||
|
||||
A cache implementation is expected to generate a key from the 2-tuple
|
||||
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string representation.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
The cached value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
||||
"""Update cache based on `prompt` and `llm_string`.
|
||||
|
||||
The `prompt` and `llm_string` are used to generate a key for the cache. The key
|
||||
should match that of the lookup method.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
return_val: The value to be cached.
|
||||
|
||||
The value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def clear(self, **kwargs: Any) -> None:
|
||||
"""Clear cache that can take additional keyword arguments."""
|
||||
|
||||
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Async look up based on `prompt` and `llm_string`.
|
||||
|
||||
A cache implementation is expected to generate a key from the 2-tuple
|
||||
of `prompt` and `llm_string` (e.g., by concatenating them with a delimiter).
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
The cached value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
return await run_in_executor(None, self.lookup, prompt, llm_string)
|
||||
|
||||
async def aupdate(
|
||||
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
|
||||
) -> None:
|
||||
"""Async update cache based on `prompt` and `llm_string`.
|
||||
|
||||
The prompt and llm_string are used to generate a key for the cache.
|
||||
The key should match that of the look up method.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
This is used to capture the invocation parameters of the LLM
|
||||
(e.g., model name, temperature, stop tokens, max tokens, etc.).
|
||||
|
||||
These invocation parameters are serialized into a string
|
||||
representation.
|
||||
return_val: The value to be cached. The value is a list of `Generation`
|
||||
(or subclasses).
|
||||
"""
|
||||
return await run_in_executor(None, self.update, prompt, llm_string, return_val)
|
||||
|
||||
async def aclear(self, **kwargs: Any) -> None:
|
||||
"""Async clear cache that can take additional keyword arguments."""
|
||||
return await run_in_executor(None, self.clear, **kwargs)
|
||||
|
||||
|
||||
class InMemoryCache(BaseCache):
|
||||
"""Cache that stores things in memory.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain_core.caches import InMemoryCache
|
||||
from langchain_core.outputs import Generation
|
||||
|
||||
# Initialize cache
|
||||
cache = InMemoryCache()
|
||||
|
||||
# Update cache
|
||||
cache.update(
|
||||
prompt="What is the capital of France?",
|
||||
llm_string="model='gpt-5.4-mini',
|
||||
return_val=[Generation(text="Paris")],
|
||||
)
|
||||
|
||||
# Lookup cache
|
||||
result = cache.lookup(
|
||||
prompt="What is the capital of France?",
|
||||
llm_string="model='gpt-5.4-mini',
|
||||
)
|
||||
# result is [Generation(text="Paris")]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, *, maxsize: int | None = None) -> None:
|
||||
"""Initialize with empty cache.
|
||||
|
||||
Args:
|
||||
maxsize: The maximum number of items to store in the cache.
|
||||
|
||||
If `None`, the cache has no maximum size.
|
||||
|
||||
If the cache exceeds the maximum size, the oldest items are removed.
|
||||
|
||||
Raises:
|
||||
ValueError: If `maxsize` is less than or equal to `0`.
|
||||
"""
|
||||
self._cache: dict[tuple[str, str], RETURN_VAL_TYPE] = {}
|
||||
if maxsize is not None and maxsize <= 0:
|
||||
msg = "maxsize must be greater than 0"
|
||||
raise ValueError(msg)
|
||||
self._maxsize = maxsize
|
||||
|
||||
def lookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Look up based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
"""
|
||||
return self._cache.get((prompt, llm_string), None)
|
||||
|
||||
def update(self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE) -> None:
|
||||
"""Update cache based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
return_val: The value to be cached.
|
||||
|
||||
The value is a list of `Generation` (or subclasses).
|
||||
"""
|
||||
if self._maxsize is not None and len(self._cache) == self._maxsize:
|
||||
del self._cache[next(iter(self._cache))]
|
||||
self._cache[prompt, llm_string] = return_val
|
||||
|
||||
@override
|
||||
def clear(self, **kwargs: Any) -> None:
|
||||
"""Clear cache."""
|
||||
self._cache = {}
|
||||
|
||||
async def alookup(self, prompt: str, llm_string: str) -> RETURN_VAL_TYPE | None:
|
||||
"""Async look up based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
|
||||
Returns:
|
||||
On a cache miss, return `None`. On a cache hit, return the cached value.
|
||||
"""
|
||||
return self.lookup(prompt, llm_string)
|
||||
|
||||
async def aupdate(
|
||||
self, prompt: str, llm_string: str, return_val: RETURN_VAL_TYPE
|
||||
) -> None:
|
||||
"""Async update cache based on `prompt` and `llm_string`.
|
||||
|
||||
Args:
|
||||
prompt: A string representation of the prompt.
|
||||
|
||||
In the case of a chat model, the prompt is a non-trivial
|
||||
serialization of the prompt into the language model.
|
||||
llm_string: A string representation of the LLM configuration.
|
||||
return_val: The value to be cached. The value is a list of `Generation`
|
||||
(or subclasses).
|
||||
"""
|
||||
self.update(prompt, llm_string, return_val)
|
||||
|
||||
@override
|
||||
async def aclear(self, **kwargs: Any) -> None:
|
||||
"""Async clear cache."""
|
||||
self.clear()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Callback handlers allow listening to events in LangChain."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.callbacks.base import (
|
||||
AsyncCallbackHandler,
|
||||
BaseCallbackHandler,
|
||||
BaseCallbackManager,
|
||||
CallbackManagerMixin,
|
||||
Callbacks,
|
||||
ChainManagerMixin,
|
||||
LLMManagerMixin,
|
||||
RetrieverManagerMixin,
|
||||
RunManagerMixin,
|
||||
ToolManagerMixin,
|
||||
)
|
||||
from langchain_core.callbacks.file import FileCallbackHandler
|
||||
from langchain_core.callbacks.manager import (
|
||||
AsyncCallbackManager,
|
||||
AsyncCallbackManagerForChainGroup,
|
||||
AsyncCallbackManagerForChainRun,
|
||||
AsyncCallbackManagerForLLMRun,
|
||||
AsyncCallbackManagerForRetrieverRun,
|
||||
AsyncCallbackManagerForToolRun,
|
||||
AsyncParentRunManager,
|
||||
AsyncRunManager,
|
||||
BaseRunManager,
|
||||
CallbackManager,
|
||||
CallbackManagerForChainGroup,
|
||||
CallbackManagerForChainRun,
|
||||
CallbackManagerForLLMRun,
|
||||
CallbackManagerForRetrieverRun,
|
||||
CallbackManagerForToolRun,
|
||||
ParentRunManager,
|
||||
RunManager,
|
||||
adispatch_custom_event,
|
||||
dispatch_custom_event,
|
||||
)
|
||||
from langchain_core.callbacks.stdout import StdOutCallbackHandler
|
||||
from langchain_core.callbacks.streaming_stdout import StreamingStdOutCallbackHandler
|
||||
from langchain_core.callbacks.usage import (
|
||||
UsageMetadataCallbackHandler,
|
||||
get_usage_metadata_callback,
|
||||
)
|
||||
|
||||
__all__ = (
|
||||
"AsyncCallbackHandler",
|
||||
"AsyncCallbackManager",
|
||||
"AsyncCallbackManagerForChainGroup",
|
||||
"AsyncCallbackManagerForChainRun",
|
||||
"AsyncCallbackManagerForLLMRun",
|
||||
"AsyncCallbackManagerForRetrieverRun",
|
||||
"AsyncCallbackManagerForToolRun",
|
||||
"AsyncParentRunManager",
|
||||
"AsyncRunManager",
|
||||
"BaseCallbackHandler",
|
||||
"BaseCallbackManager",
|
||||
"BaseRunManager",
|
||||
"CallbackManager",
|
||||
"CallbackManagerForChainGroup",
|
||||
"CallbackManagerForChainRun",
|
||||
"CallbackManagerForLLMRun",
|
||||
"CallbackManagerForRetrieverRun",
|
||||
"CallbackManagerForToolRun",
|
||||
"CallbackManagerMixin",
|
||||
"Callbacks",
|
||||
"ChainManagerMixin",
|
||||
"FileCallbackHandler",
|
||||
"LLMManagerMixin",
|
||||
"ParentRunManager",
|
||||
"RetrieverManagerMixin",
|
||||
"RunManager",
|
||||
"RunManagerMixin",
|
||||
"StdOutCallbackHandler",
|
||||
"StreamingStdOutCallbackHandler",
|
||||
"ToolManagerMixin",
|
||||
"UsageMetadataCallbackHandler",
|
||||
"adispatch_custom_event",
|
||||
"dispatch_custom_event",
|
||||
"get_usage_metadata_callback",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"AsyncCallbackHandler": "base",
|
||||
"BaseCallbackHandler": "base",
|
||||
"BaseCallbackManager": "base",
|
||||
"CallbackManagerMixin": "base",
|
||||
"Callbacks": "base",
|
||||
"ChainManagerMixin": "base",
|
||||
"LLMManagerMixin": "base",
|
||||
"RetrieverManagerMixin": "base",
|
||||
"RunManagerMixin": "base",
|
||||
"ToolManagerMixin": "base",
|
||||
"FileCallbackHandler": "file",
|
||||
"AsyncCallbackManager": "manager",
|
||||
"AsyncCallbackManagerForChainGroup": "manager",
|
||||
"AsyncCallbackManagerForChainRun": "manager",
|
||||
"AsyncCallbackManagerForLLMRun": "manager",
|
||||
"AsyncCallbackManagerForRetrieverRun": "manager",
|
||||
"AsyncCallbackManagerForToolRun": "manager",
|
||||
"AsyncParentRunManager": "manager",
|
||||
"AsyncRunManager": "manager",
|
||||
"BaseRunManager": "manager",
|
||||
"CallbackManager": "manager",
|
||||
"CallbackManagerForChainGroup": "manager",
|
||||
"CallbackManagerForChainRun": "manager",
|
||||
"CallbackManagerForLLMRun": "manager",
|
||||
"CallbackManagerForRetrieverRun": "manager",
|
||||
"CallbackManagerForToolRun": "manager",
|
||||
"ParentRunManager": "manager",
|
||||
"RunManager": "manager",
|
||||
"adispatch_custom_event": "manager",
|
||||
"dispatch_custom_event": "manager",
|
||||
"StdOutCallbackHandler": "stdout",
|
||||
"StreamingStdOutCallbackHandler": "streaming_stdout",
|
||||
"UsageMetadataCallbackHandler": "usage",
|
||||
"get_usage_metadata_callback": "usage",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,267 @@
|
||||
"""Callback handler that writes to a file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, TextIO, cast
|
||||
|
||||
from typing_extensions import Self, override
|
||||
|
||||
from langchain_core._api import warn_deprecated
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.utils.input import print_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
|
||||
|
||||
_GLOBAL_DEPRECATION_WARNED = False
|
||||
|
||||
|
||||
class FileCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler that writes to a file.
|
||||
|
||||
This handler supports both context manager usage (recommended) and direct
|
||||
instantiation (deprecated) for backwards compatibility.
|
||||
|
||||
Examples:
|
||||
Using as a context manager (recommended):
|
||||
|
||||
```python
|
||||
with FileCallbackHandler("output.txt") as handler:
|
||||
# Use handler with your chain/agent
|
||||
chain.invoke(inputs, config={"callbacks": [handler]})
|
||||
```
|
||||
|
||||
Direct instantiation (deprecated):
|
||||
|
||||
```python
|
||||
handler = FileCallbackHandler("output.txt")
|
||||
# File remains open until handler is garbage collected
|
||||
try:
|
||||
chain.invoke(inputs, config={"callbacks": [handler]})
|
||||
finally:
|
||||
handler.close() # Explicit cleanup recommended
|
||||
```
|
||||
|
||||
Args:
|
||||
filename: The file path to write to.
|
||||
mode: The file open mode. Defaults to `'a'` (append).
|
||||
color: Default color for text output.
|
||||
|
||||
!!! note
|
||||
|
||||
When not used as a context manager, a deprecation warning will be issued on
|
||||
first use. The file will be opened immediately in `__init__` and closed in
|
||||
`__del__` or when `close()` is called explicitly.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, filename: str, mode: str = "a", color: str | None = None
|
||||
) -> None:
|
||||
"""Initialize the file callback handler.
|
||||
|
||||
Args:
|
||||
filename: Path to the output file.
|
||||
mode: File open mode (e.g., `'w'`, `'a'`, `'x'`). Defaults to `'a'`.
|
||||
color: Default text color for output.
|
||||
|
||||
"""
|
||||
self.filename = filename
|
||||
self.mode = mode
|
||||
self.color = color
|
||||
self._file_opened_in_context = False
|
||||
self.file: TextIO = cast(
|
||||
"TextIO",
|
||||
# Open the file in the specified mode with UTF-8 encoding.
|
||||
Path(self.filename).open(self.mode, encoding="utf-8"), # noqa: SIM115
|
||||
)
|
||||
|
||||
def __enter__(self) -> Self:
|
||||
"""Enter the context manager.
|
||||
|
||||
Returns:
|
||||
The `FileCallbackHandler` instance.
|
||||
|
||||
!!! note
|
||||
|
||||
The file is already opened in `__init__`, so this just marks that the
|
||||
handler is being used as a context manager.
|
||||
|
||||
"""
|
||||
self._file_opened_in_context = True
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_val: BaseException | None,
|
||||
exc_tb: object,
|
||||
) -> None:
|
||||
"""Exit the context manager and close the file.
|
||||
|
||||
Args:
|
||||
exc_type: Exception type if an exception occurred.
|
||||
exc_val: Exception value if an exception occurred.
|
||||
exc_tb: Exception traceback if an exception occurred.
|
||||
|
||||
"""
|
||||
self.close()
|
||||
|
||||
def __del__(self) -> None:
|
||||
"""Destructor to cleanup when done."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the file if it's open.
|
||||
|
||||
This method is safe to call multiple times and will only close
|
||||
the file if it's currently open.
|
||||
|
||||
"""
|
||||
if hasattr(self, "file") and self.file and not self.file.closed:
|
||||
self.file.close()
|
||||
|
||||
def _write(
|
||||
self,
|
||||
text: str,
|
||||
color: str | None = None,
|
||||
end: str = "",
|
||||
) -> None:
|
||||
"""Write text to the file with deprecation warning if needed.
|
||||
|
||||
Args:
|
||||
text: The text to write to the file.
|
||||
color: Optional color for the text. Defaults to `self.color`.
|
||||
end: String appended after the text.
|
||||
file: Optional file to write to. Defaults to `self.file`.
|
||||
|
||||
Raises:
|
||||
RuntimeError: If the file is closed or not available.
|
||||
|
||||
"""
|
||||
global _GLOBAL_DEPRECATION_WARNED # noqa: PLW0603
|
||||
if not self._file_opened_in_context and not _GLOBAL_DEPRECATION_WARNED:
|
||||
warn_deprecated(
|
||||
since="0.3.67",
|
||||
pending=True,
|
||||
message=(
|
||||
"Using FileCallbackHandler without a context manager is "
|
||||
"deprecated. Use 'with FileCallbackHandler(...) as "
|
||||
"handler:' instead."
|
||||
),
|
||||
)
|
||||
_GLOBAL_DEPRECATION_WARNED = True
|
||||
|
||||
if not hasattr(self, "file") or self.file is None or self.file.closed:
|
||||
msg = "File is not open. Use FileCallbackHandler as a context manager."
|
||||
raise RuntimeError(msg)
|
||||
|
||||
print_text(text, file=self.file, color=color, end=end)
|
||||
|
||||
@override
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Print that we are entering a chain.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain information.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments that may contain `'name'`.
|
||||
|
||||
"""
|
||||
name = (
|
||||
kwargs.get("name")
|
||||
or serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
|
||||
or "<unknown>"
|
||||
)
|
||||
self._write(f"\n\n> Entering new {name} chain...", end="\n")
|
||||
|
||||
@override
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Print that we finished a chain.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write("\n> Finished chain.", end="\n")
|
||||
|
||||
@override
|
||||
def on_agent_action(
|
||||
self, action: AgentAction, color: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Handle agent action by writing the action log.
|
||||
|
||||
Args:
|
||||
action: The agent action containing the log to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(action.log, color=color or self.color)
|
||||
|
||||
@override
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: str,
|
||||
color: str | None = None,
|
||||
observation_prefix: str | None = None,
|
||||
llm_prefix: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Handle tool end by writing the output with optional prefixes.
|
||||
|
||||
Args:
|
||||
output: The tool output to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
observation_prefix: Optional prefix to write before the output.
|
||||
llm_prefix: Optional prefix to write after the output.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
if observation_prefix is not None:
|
||||
self._write(f"\n{observation_prefix}")
|
||||
self._write(output)
|
||||
if llm_prefix is not None:
|
||||
self._write(f"\n{llm_prefix}")
|
||||
|
||||
@override
|
||||
def on_text(
|
||||
self, text: str, color: str | None = None, end: str = "", **kwargs: Any
|
||||
) -> None:
|
||||
"""Handle text output.
|
||||
|
||||
Args:
|
||||
text: The text to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
end: String appended after the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(text, color=color or self.color, end=end)
|
||||
|
||||
@override
|
||||
def on_agent_finish(
|
||||
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Handle agent finish by writing the finish log.
|
||||
|
||||
Args:
|
||||
finish: The agent finish object containing the log to write.
|
||||
color: Color override for this specific output.
|
||||
|
||||
If `None`, uses `self.color`.
|
||||
**kwargs: Additional keyword arguments.
|
||||
|
||||
"""
|
||||
self._write(finish.log, color=color or self.color, end="\n")
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,123 @@
|
||||
"""Callback handler that prints to std out."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
from langchain_core.utils import print_text
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
|
||||
|
||||
class StdOutCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler that prints to std out."""
|
||||
|
||||
def __init__(self, color: str | None = None) -> None:
|
||||
"""Initialize callback handler.
|
||||
|
||||
Args:
|
||||
color: The color to use for the text.
|
||||
"""
|
||||
self.color = color
|
||||
|
||||
@override
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Print out that we are entering a chain.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
if "name" in kwargs:
|
||||
name = kwargs["name"]
|
||||
elif serialized:
|
||||
name = serialized.get("name", serialized.get("id", ["<unknown>"])[-1])
|
||||
else:
|
||||
name = "<unknown>"
|
||||
print(f"\n\n\033[1m> Entering new {name} chain...\033[0m") # noqa: T201
|
||||
|
||||
@override
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Print out that we finished a chain.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print("\n\033[1m> Finished chain.\033[0m") # noqa: T201
|
||||
|
||||
@override
|
||||
def on_agent_action(
|
||||
self, action: AgentAction, color: str | None = None, **kwargs: Any
|
||||
) -> Any:
|
||||
"""Run on agent action.
|
||||
|
||||
Args:
|
||||
action: The agent action.
|
||||
color: The color to use for the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(action.log, color=color or self.color)
|
||||
|
||||
@override
|
||||
def on_tool_end(
|
||||
self,
|
||||
output: Any,
|
||||
color: str | None = None,
|
||||
observation_prefix: str | None = None,
|
||||
llm_prefix: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""If not the final action, print out observation.
|
||||
|
||||
Args:
|
||||
output: The output to print.
|
||||
color: The color to use for the text.
|
||||
observation_prefix: The observation prefix.
|
||||
llm_prefix: The LLM prefix.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
output = str(output)
|
||||
if observation_prefix is not None:
|
||||
print_text(f"\n{observation_prefix}")
|
||||
print_text(output, color=color or self.color)
|
||||
if llm_prefix is not None:
|
||||
print_text(f"\n{llm_prefix}")
|
||||
|
||||
@override
|
||||
def on_text(
|
||||
self,
|
||||
text: str,
|
||||
color: str | None = None,
|
||||
end: str = "",
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Run when the agent ends.
|
||||
|
||||
Args:
|
||||
text: The text to print.
|
||||
color: The color to use for the text.
|
||||
end: The end character to use.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(text, color=color or self.color, end=end)
|
||||
|
||||
@override
|
||||
def on_agent_finish(
|
||||
self, finish: AgentFinish, color: str | None = None, **kwargs: Any
|
||||
) -> None:
|
||||
"""Run on the agent end.
|
||||
|
||||
Args:
|
||||
finish: The agent finish.
|
||||
color: The color to use for the text.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
print_text(finish.log, color=color or self.color, end="\n")
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Callback Handler streams to stdout on new llm token."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks.base import BaseCallbackHandler
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.agents import AgentAction, AgentFinish
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.outputs import LLMResult
|
||||
|
||||
|
||||
class StreamingStdOutCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback handler for streaming.
|
||||
|
||||
!!! warning "Only works with LLMs that support streaming."
|
||||
"""
|
||||
|
||||
def on_llm_start(
|
||||
self, serialized: dict[str, Any], prompts: list[str], **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when LLM starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized LLM.
|
||||
prompts: The prompts to run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chat_model_start(
|
||||
self,
|
||||
serialized: dict[str, Any],
|
||||
messages: list[list[BaseMessage]],
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Run when LLM starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized LLM.
|
||||
messages: The messages to run.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
@override
|
||||
def on_llm_new_token(
|
||||
self, token: str | list[str | dict[str, Any]], **kwargs: Any
|
||||
) -> None:
|
||||
"""Run on new LLM token. Only available when streaming is enabled.
|
||||
|
||||
Args:
|
||||
token: The new token, or a list of content blocks.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
sys.stdout.write(str(token))
|
||||
sys.stdout.flush()
|
||||
|
||||
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
||||
"""Run when LLM ends running.
|
||||
|
||||
Args:
|
||||
response: The response from the LLM.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_llm_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when LLM errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_start(
|
||||
self, serialized: dict[str, Any], inputs: dict[str, Any], **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when a chain starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized chain.
|
||||
inputs: The inputs to the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_end(self, outputs: dict[str, Any], **kwargs: Any) -> None:
|
||||
"""Run when a chain ends running.
|
||||
|
||||
Args:
|
||||
outputs: The outputs of the chain.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_chain_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when chain errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_start(
|
||||
self, serialized: dict[str, Any], input_str: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Run when the tool starts running.
|
||||
|
||||
Args:
|
||||
serialized: The serialized tool.
|
||||
input_str: The input string.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_agent_action(self, action: AgentAction, **kwargs: Any) -> Any:
|
||||
"""Run on agent action.
|
||||
|
||||
Args:
|
||||
action: The agent action.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_end(self, output: Any, **kwargs: Any) -> None:
|
||||
"""Run when tool ends running.
|
||||
|
||||
Args:
|
||||
output: The output of the tool.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_tool_error(self, error: BaseException, **kwargs: Any) -> None:
|
||||
"""Run when tool errors.
|
||||
|
||||
Args:
|
||||
error: The error that occurred.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_text(self, text: str, **kwargs: Any) -> None:
|
||||
"""Run on an arbitrary text.
|
||||
|
||||
Args:
|
||||
text: The text to print.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
|
||||
def on_agent_finish(self, finish: AgentFinish, **kwargs: Any) -> None:
|
||||
"""Run on the agent end.
|
||||
|
||||
Args:
|
||||
finish: The agent finish.
|
||||
**kwargs: Additional keyword arguments.
|
||||
"""
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Callback Handler that tracks `AIMessage.usage_metadata`."""
|
||||
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Any
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.callbacks import BaseCallbackHandler
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages.ai import UsageMetadata, add_usage
|
||||
from langchain_core.outputs import ChatGeneration, LLMResult
|
||||
from langchain_core.tracers.context import register_configure_hook
|
||||
|
||||
|
||||
class UsageMetadataCallbackHandler(BaseCallbackHandler):
|
||||
"""Callback Handler that tracks `AIMessage.usage_metadata`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.callbacks import UsageMetadataCallbackHandler
|
||||
|
||||
llm_1 = init_chat_model(model="openai:gpt-5.5")
|
||||
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
|
||||
|
||||
callback = UsageMetadataCallbackHandler()
|
||||
result_1 = llm_1.invoke("Hello", config={"callbacks": [callback]})
|
||||
result_2 = llm_2.invoke("Hello", config={"callbacks": [callback]})
|
||||
callback.usage_metadata
|
||||
```
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.49"
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the `UsageMetadataCallbackHandler`."""
|
||||
super().__init__()
|
||||
self._lock = threading.Lock()
|
||||
self.usage_metadata: dict[str, UsageMetadata] = {}
|
||||
|
||||
@override
|
||||
def __repr__(self) -> str:
|
||||
return str(self.usage_metadata)
|
||||
|
||||
@override
|
||||
def on_llm_end(self, response: LLMResult, **kwargs: Any) -> None:
|
||||
"""Collect token usage."""
|
||||
# Check for usage_metadata (langchain-core >= 0.2.2)
|
||||
try:
|
||||
generation = response.generations[0][0]
|
||||
except IndexError:
|
||||
generation = None
|
||||
|
||||
usage_metadata = None
|
||||
model_name = None
|
||||
if isinstance(generation, ChatGeneration):
|
||||
try:
|
||||
message = generation.message
|
||||
if isinstance(message, AIMessage):
|
||||
usage_metadata = message.usage_metadata
|
||||
model_name = message.response_metadata.get("model_name")
|
||||
except AttributeError:
|
||||
pass
|
||||
|
||||
# update shared state behind lock
|
||||
if usage_metadata and model_name:
|
||||
with self._lock:
|
||||
if model_name not in self.usage_metadata:
|
||||
self.usage_metadata[model_name] = usage_metadata
|
||||
else:
|
||||
self.usage_metadata[model_name] = add_usage(
|
||||
self.usage_metadata[model_name], usage_metadata
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def get_usage_metadata_callback(
|
||||
name: str = "usage_metadata_callback",
|
||||
) -> Generator[UsageMetadataCallbackHandler, None, None]:
|
||||
"""Get usage metadata callback.
|
||||
|
||||
Get context manager for tracking usage metadata across chat model calls using
|
||||
[`AIMessage.usage_metadata`][langchain.messages.AIMessage.usage_metadata].
|
||||
|
||||
Args:
|
||||
name: The name of the context variable.
|
||||
|
||||
Yields:
|
||||
The usage metadata callback.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain.chat_models import init_chat_model
|
||||
from langchain_core.callbacks import get_usage_metadata_callback
|
||||
|
||||
llm_1 = init_chat_model(model="openai:gpt-5.5")
|
||||
llm_2 = init_chat_model(model="anthropic:claude-haiku-4-5-20251001")
|
||||
|
||||
with get_usage_metadata_callback() as cb:
|
||||
llm_1.invoke("Hello")
|
||||
llm_2.invoke("Hello")
|
||||
print(cb.usage_metadata)
|
||||
```
|
||||
|
||||
!!! version-added "Added in `langchain-core` 0.3.49"
|
||||
|
||||
"""
|
||||
usage_metadata_callback_var: ContextVar[UsageMetadataCallbackHandler | None] = (
|
||||
ContextVar(name, default=None)
|
||||
)
|
||||
register_configure_hook(usage_metadata_callback_var, inheritable=True)
|
||||
cb = UsageMetadataCallbackHandler()
|
||||
usage_metadata_callback_var.set(cb)
|
||||
yield cb
|
||||
usage_metadata_callback_var.set(None)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""Chat message history stores a history of the message interactions in a chat."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from langchain_core.messages import (
|
||||
AIMessage,
|
||||
BaseMessage,
|
||||
HumanMessage,
|
||||
get_buffer_string,
|
||||
)
|
||||
from langchain_core.runnables.config import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
|
||||
|
||||
class BaseChatMessageHistory(ABC):
|
||||
"""Abstract base class for storing chat message history.
|
||||
|
||||
Implementations guidelines:
|
||||
|
||||
Implementations are expected to over-ride all or some of the following methods:
|
||||
|
||||
* `add_messages`: sync variant for bulk addition of messages
|
||||
* `aadd_messages`: async variant for bulk addition of messages
|
||||
* `messages`: sync variant for getting messages
|
||||
* `aget_messages`: async variant for getting messages
|
||||
* `clear`: sync variant for clearing messages
|
||||
* `aclear`: async variant for clearing messages
|
||||
|
||||
`add_messages` contains a default implementation that calls `add_message`
|
||||
for each message in the sequence. This is provided for backwards compatibility
|
||||
with existing implementations which only had `add_message`.
|
||||
|
||||
Async variants all have default implementations that call the sync variants.
|
||||
Implementers can choose to override the async implementations to provide
|
||||
truly async implementations.
|
||||
|
||||
Usage guidelines:
|
||||
|
||||
When used for updating history, users should favor usage of `add_messages`
|
||||
over `add_message` or other variants like `add_user_message` and `add_ai_message`
|
||||
to avoid unnecessary round-trips to the underlying persistence layer.
|
||||
|
||||
Example:
|
||||
```python
|
||||
import json
|
||||
import os
|
||||
from langchain_core.messages import messages_from_dict, message_to_dict
|
||||
|
||||
|
||||
class FileChatMessageHistory(BaseChatMessageHistory):
|
||||
storage_path: str
|
||||
session_id: str
|
||||
|
||||
@property
|
||||
def messages(self) -> list[BaseMessage]:
|
||||
try:
|
||||
with open(
|
||||
os.path.join(self.storage_path, self.session_id),
|
||||
"r",
|
||||
encoding="utf-8",
|
||||
) as f:
|
||||
messages_data = json.load(f)
|
||||
return messages_from_dict(messages_data)
|
||||
except FileNotFoundError:
|
||||
return []
|
||||
|
||||
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
all_messages = list(self.messages) # Existing messages
|
||||
all_messages.extend(messages) # Add new messages
|
||||
|
||||
serialized = [message_to_dict(message) for message in all_messages]
|
||||
file_path = os.path.join(self.storage_path, self.session_id)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump(serialized, f)
|
||||
|
||||
def clear(self) -> None:
|
||||
file_path = os.path.join(self.storage_path, self.session_id)
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
json.dump([], f)
|
||||
```
|
||||
"""
|
||||
|
||||
messages: list[BaseMessage]
|
||||
"""A property or attribute that returns a list of messages.
|
||||
|
||||
In general, getting the messages may involve IO to the underlying persistence
|
||||
layer, so this operation is expected to incur some latency.
|
||||
"""
|
||||
|
||||
async def aget_messages(self) -> list[BaseMessage]:
|
||||
"""Async version of getting messages.
|
||||
|
||||
Can over-ride this method to provide an efficient async implementation.
|
||||
|
||||
In general, fetching messages may involve IO to the underlying persistence
|
||||
layer.
|
||||
|
||||
Returns:
|
||||
The messages.
|
||||
"""
|
||||
return await run_in_executor(None, lambda: self.messages)
|
||||
|
||||
def add_user_message(self, message: HumanMessage | str) -> None:
|
||||
"""Convenience method for adding a human message string to the store.
|
||||
|
||||
!!! note
|
||||
|
||||
This is a convenience method. Code should favor the bulk `add_messages`
|
||||
interface instead to save on round-trips to the persistence layer.
|
||||
|
||||
This method may be deprecated in a future release.
|
||||
|
||||
Args:
|
||||
message: The `HumanMessage` to add to the store.
|
||||
"""
|
||||
if isinstance(message, HumanMessage):
|
||||
self.add_message(message)
|
||||
else:
|
||||
self.add_message(HumanMessage(content=message))
|
||||
|
||||
def add_ai_message(self, message: AIMessage | str) -> None:
|
||||
"""Convenience method for adding an `AIMessage` string to the store.
|
||||
|
||||
!!! note
|
||||
|
||||
This is a convenience method. Code should favor the bulk `add_messages`
|
||||
interface instead to save on round-trips to the persistence layer.
|
||||
|
||||
This method may be deprecated in a future release.
|
||||
|
||||
Args:
|
||||
message: The `AIMessage` to add.
|
||||
"""
|
||||
if isinstance(message, AIMessage):
|
||||
self.add_message(message)
|
||||
else:
|
||||
self.add_message(AIMessage(content=message))
|
||||
|
||||
def add_message(self, message: BaseMessage) -> None:
|
||||
"""Add a Message object to the store.
|
||||
|
||||
Args:
|
||||
message: A `BaseMessage` object to store.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the sub-class has not implemented an efficient
|
||||
`add_messages` method.
|
||||
"""
|
||||
if type(self).add_messages != BaseChatMessageHistory.add_messages:
|
||||
# This means that the sub-class has implemented an efficient add_messages
|
||||
# method, so we should use it.
|
||||
self.add_messages([message])
|
||||
else:
|
||||
msg = (
|
||||
"add_message is not implemented for this class. "
|
||||
"Please implement add_message or add_messages."
|
||||
)
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
def add_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Add a list of messages.
|
||||
|
||||
Implementations should over-ride this method to handle bulk addition of messages
|
||||
in an efficient manner to avoid unnecessary round-trips to the underlying store.
|
||||
|
||||
Args:
|
||||
messages: A sequence of `BaseMessage` objects to store.
|
||||
"""
|
||||
for message in messages:
|
||||
self.add_message(message)
|
||||
|
||||
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Async add a list of messages.
|
||||
|
||||
Args:
|
||||
messages: A sequence of `BaseMessage` objects to store.
|
||||
"""
|
||||
await run_in_executor(None, self.add_messages, messages)
|
||||
|
||||
@abstractmethod
|
||||
def clear(self) -> None:
|
||||
"""Remove all messages from the store."""
|
||||
|
||||
async def aclear(self) -> None:
|
||||
"""Async remove all messages from the store."""
|
||||
await run_in_executor(None, self.clear)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Return a string representation of the chat history."""
|
||||
return get_buffer_string(self.messages)
|
||||
|
||||
|
||||
class InMemoryChatMessageHistory(BaseChatMessageHistory, BaseModel):
|
||||
"""In memory implementation of chat message history.
|
||||
|
||||
Stores messages in a memory list.
|
||||
"""
|
||||
|
||||
messages: list[BaseMessage] = Field(default_factory=list)
|
||||
"""A list of messages stored in memory."""
|
||||
|
||||
async def aget_messages(self) -> list[BaseMessage]:
|
||||
"""Async version of getting messages.
|
||||
|
||||
Can over-ride this method to provide an efficient async implementation.
|
||||
|
||||
In general, fetching messages may involve IO to the underlying persistence
|
||||
layer.
|
||||
|
||||
Returns:
|
||||
List of messages.
|
||||
"""
|
||||
return self.messages
|
||||
|
||||
def add_message(self, message: BaseMessage) -> None:
|
||||
"""Add a self-created message to the store.
|
||||
|
||||
Args:
|
||||
message: The message to add.
|
||||
"""
|
||||
self.messages.append(message)
|
||||
|
||||
async def aadd_messages(self, messages: Sequence[BaseMessage]) -> None:
|
||||
"""Async add messages to the store.
|
||||
|
||||
Args:
|
||||
messages: The messages to add.
|
||||
"""
|
||||
self.add_messages(messages)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all messages from the store."""
|
||||
self.messages = []
|
||||
|
||||
async def aclear(self) -> None:
|
||||
"""Async clear all messages from the store."""
|
||||
self.clear()
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Chat loaders."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Iterator
|
||||
|
||||
from langchain_core.chat_sessions import ChatSession
|
||||
|
||||
|
||||
class BaseChatLoader(ABC):
|
||||
"""Base class for chat loaders."""
|
||||
|
||||
@abstractmethod
|
||||
def lazy_load(self) -> Iterator[ChatSession]:
|
||||
"""Lazy load the chat sessions.
|
||||
|
||||
Returns:
|
||||
An iterator of chat sessions.
|
||||
"""
|
||||
|
||||
def load(self) -> list[ChatSession]:
|
||||
"""Eagerly load the chat sessions into memory.
|
||||
|
||||
Returns:
|
||||
A list of chat sessions.
|
||||
"""
|
||||
return list(self.lazy_load())
|
||||
@@ -0,0 +1,19 @@
|
||||
"""**Chat Sessions** are a collection of messages and function calls."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
from typing import Any, TypedDict
|
||||
|
||||
from langchain_core.messages import BaseMessage
|
||||
|
||||
|
||||
class ChatSession(TypedDict, total=False):
|
||||
"""Chat Session.
|
||||
|
||||
Chat Session represents a single conversation, channel, or other group of messages.
|
||||
"""
|
||||
|
||||
messages: Sequence[BaseMessage]
|
||||
"""A sequence of the LangChain chat messages loaded from the source."""
|
||||
|
||||
functions: Sequence[dict[str, Any]]
|
||||
"""A sequence of the function calling specs for the messages."""
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Cross Encoder interface."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
|
||||
class BaseCrossEncoder(ABC):
|
||||
"""Interface for cross encoder models."""
|
||||
|
||||
@abstractmethod
|
||||
def score(self, text_pairs: list[tuple[str, str]]) -> list[float]:
|
||||
"""Score pairs' similarity.
|
||||
|
||||
Args:
|
||||
text_pairs: List of pairs of texts.
|
||||
|
||||
Returns:
|
||||
List of scores.
|
||||
"""
|
||||
@@ -0,0 +1,39 @@
|
||||
"""Document loaders."""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.document_loaders.base import BaseBlobParser, BaseLoader
|
||||
from langchain_core.document_loaders.blob_loaders import Blob, BlobLoader, PathLike
|
||||
from langchain_core.document_loaders.langsmith import LangSmithLoader
|
||||
|
||||
__all__ = (
|
||||
"BaseBlobParser",
|
||||
"BaseLoader",
|
||||
"Blob",
|
||||
"BlobLoader",
|
||||
"LangSmithLoader",
|
||||
"PathLike",
|
||||
)
|
||||
|
||||
_dynamic_imports = {
|
||||
"BaseBlobParser": "base",
|
||||
"BaseLoader": "base",
|
||||
"Blob": "blob_loaders",
|
||||
"BlobLoader": "blob_loaders",
|
||||
"PathLike": "blob_loaders",
|
||||
"LangSmithLoader": "langsmith",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
@@ -0,0 +1,155 @@
|
||||
"""Abstract interface for document loader implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core.runnables import run_in_executor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import AsyncIterator, Iterator
|
||||
|
||||
from langchain_text_splitters import TextSplitter
|
||||
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.documents.base import Blob
|
||||
|
||||
try:
|
||||
from langchain_text_splitters import RecursiveCharacterTextSplitter
|
||||
|
||||
_HAS_TEXT_SPLITTERS = True
|
||||
except ImportError:
|
||||
_HAS_TEXT_SPLITTERS = False
|
||||
|
||||
|
||||
class BaseLoader(ABC): # noqa: B024
|
||||
"""Interface for document loader.
|
||||
|
||||
Implementations should implement the lazy-loading method using generators to avoid
|
||||
loading all documents into memory at once.
|
||||
|
||||
`load` is provided just for user convenience and should not be overridden.
|
||||
"""
|
||||
|
||||
# Sub-classes should not implement this method directly. Instead, they
|
||||
# should implement the lazy load method.
|
||||
def load(self) -> list[Document]:
|
||||
"""Load data into `Document` objects.
|
||||
|
||||
Returns:
|
||||
The documents.
|
||||
"""
|
||||
return list(self.lazy_load())
|
||||
|
||||
async def aload(self) -> list[Document]:
|
||||
"""Load data into `Document` objects.
|
||||
|
||||
Returns:
|
||||
The documents.
|
||||
"""
|
||||
return [document async for document in self.alazy_load()]
|
||||
|
||||
def load_and_split(
|
||||
self, text_splitter: TextSplitter | None = None
|
||||
) -> list[Document]:
|
||||
"""Load `Document` and split into chunks. Chunks are returned as `Document`.
|
||||
|
||||
!!! danger
|
||||
|
||||
Do not override this method. It should be considered to be deprecated!
|
||||
|
||||
Args:
|
||||
text_splitter: `TextSplitter` instance to use for splitting documents.
|
||||
|
||||
Defaults to `RecursiveCharacterTextSplitter`.
|
||||
|
||||
Raises:
|
||||
ImportError: If `langchain-text-splitters` is not installed and no
|
||||
`text_splitter` is provided.
|
||||
|
||||
Returns:
|
||||
List of `Document` objects.
|
||||
"""
|
||||
if text_splitter is None:
|
||||
if not _HAS_TEXT_SPLITTERS:
|
||||
msg = (
|
||||
"Unable to import from langchain_text_splitters. Please specify "
|
||||
"text_splitter or install langchain_text_splitters with "
|
||||
"`pip install -U langchain-text-splitters`."
|
||||
)
|
||||
raise ImportError(msg)
|
||||
|
||||
text_splitter_: TextSplitter = RecursiveCharacterTextSplitter()
|
||||
else:
|
||||
text_splitter_ = text_splitter
|
||||
docs = self.load()
|
||||
return text_splitter_.split_documents(docs)
|
||||
|
||||
# Attention: This method will be upgraded into an abstractmethod once it's
|
||||
# implemented in all the existing subclasses.
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
"""A lazy loader for `Document`.
|
||||
|
||||
Yields:
|
||||
The `Document` objects.
|
||||
"""
|
||||
if type(self).load != BaseLoader.load:
|
||||
return iter(self.load())
|
||||
msg = f"{self.__class__.__name__} does not implement lazy_load()"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
async def alazy_load(self) -> AsyncIterator[Document]:
|
||||
"""A lazy loader for `Document`.
|
||||
|
||||
Yields:
|
||||
The `Document` objects.
|
||||
"""
|
||||
iterator = await run_in_executor(None, self.lazy_load)
|
||||
done = object()
|
||||
while True:
|
||||
doc = await run_in_executor(None, next, iterator, done)
|
||||
if doc is done:
|
||||
break
|
||||
yield doc # type: ignore[misc]
|
||||
|
||||
|
||||
class BaseBlobParser(ABC):
|
||||
"""Abstract interface for blob parsers.
|
||||
|
||||
A blob parser provides a way to parse raw data stored in a blob into one or more
|
||||
`Document` objects.
|
||||
|
||||
The parser can be composed with blob loaders, making it easy to reuse a parser
|
||||
independent of how the blob was originally loaded.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def lazy_parse(self, blob: Blob) -> Iterator[Document]:
|
||||
"""Lazy parsing interface.
|
||||
|
||||
Subclasses are required to implement this method.
|
||||
|
||||
Args:
|
||||
blob: `Blob` instance
|
||||
|
||||
Returns:
|
||||
Generator of `Document` objects
|
||||
"""
|
||||
|
||||
def parse(self, blob: Blob) -> list[Document]:
|
||||
"""Eagerly parse the blob into a `Document` or list of `Document` objects.
|
||||
|
||||
This is a convenience method for interactive development environment.
|
||||
|
||||
Production applications should favor the `lazy_parse` method instead.
|
||||
|
||||
Subclasses should generally not over-ride this parse method.
|
||||
|
||||
Args:
|
||||
blob: `Blob` instance
|
||||
|
||||
Returns:
|
||||
List of `Document` objects
|
||||
"""
|
||||
return list(self.lazy_parse(blob))
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Schema for Blobs and Blob Loaders.
|
||||
|
||||
The goal is to facilitate decoupling of content loading from content parsing code. In
|
||||
addition, content loading code should provide a lazy loading interface by default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
# Re-export Blob and PathLike for backwards compatibility
|
||||
from langchain_core.documents.base import Blob, PathLike
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
class BlobLoader(ABC):
|
||||
"""Abstract interface for blob loaders implementation.
|
||||
|
||||
Implementer should be able to load raw content from a storage system according to
|
||||
some criteria and return the raw content lazily as a stream of blobs.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def yield_blobs(
|
||||
self,
|
||||
) -> Iterator[Blob]:
|
||||
"""A lazy loader for raw data represented by LangChain's `Blob` object.
|
||||
|
||||
Yields:
|
||||
`Blob` objects.
|
||||
"""
|
||||
|
||||
|
||||
# Re-export Blob and Pathlike for backwards compatibility
|
||||
__all__ = ["Blob", "BlobLoader", "PathLike"]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""LangSmith document loader."""
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Callable, Iterator, Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
from langsmith import Client as LangSmithClient
|
||||
from typing_extensions import override
|
||||
|
||||
from langchain_core.document_loaders.base import BaseLoader
|
||||
from langchain_core.documents import Document
|
||||
from langchain_core.tracers._compat import pydantic_to_dict
|
||||
|
||||
|
||||
class LangSmithLoader(BaseLoader):
|
||||
"""Load LangSmith Dataset examples as `Document` objects.
|
||||
|
||||
Loads the example inputs as the `Document` page content and places the entire
|
||||
example into the `Document` metadata. This allows you to easily create few-shot
|
||||
example retrievers from the loaded documents.
|
||||
|
||||
??? example "Lazy loading"
|
||||
|
||||
```python
|
||||
from langchain_core.document_loaders import LangSmithLoader
|
||||
|
||||
loader = LangSmithLoader(dataset_id="...", limit=100)
|
||||
docs = []
|
||||
for doc in loader.lazy_load():
|
||||
docs.append(doc)
|
||||
```
|
||||
|
||||
```python
|
||||
# -> [Document("...", metadata={"inputs": {...}, "outputs": {...}, ...}), ...]
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
dataset_id: uuid.UUID | str | None = None,
|
||||
dataset_name: str | None = None,
|
||||
example_ids: Sequence[uuid.UUID | str] | None = None,
|
||||
as_of: datetime.datetime | str | None = None,
|
||||
splits: Sequence[str] | None = None,
|
||||
inline_s3_urls: bool = True,
|
||||
offset: int = 0,
|
||||
limit: int | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
filter: str | None = None, # noqa: A002
|
||||
content_key: str = "",
|
||||
format_content: Callable[..., str] | None = None,
|
||||
client: LangSmithClient | None = None,
|
||||
**client_kwargs: Any,
|
||||
) -> None:
|
||||
"""Create a LangSmith loader.
|
||||
|
||||
Args:
|
||||
dataset_id: The ID of the dataset to filter by.
|
||||
dataset_name: The name of the dataset to filter by.
|
||||
content_key: The inputs key to set as `Document` page content.
|
||||
|
||||
`'.'` characters are interpreted as nested keys, e.g.
|
||||
`content_key="first.second"` will result in
|
||||
`Document(page_content=format_content(example.inputs["first"]["second"]))`
|
||||
format_content: Function for converting the content extracted from the example
|
||||
inputs into a string.
|
||||
|
||||
Defaults to JSON-encoding the contents.
|
||||
example_ids: The IDs of the examples to filter by.
|
||||
as_of: The dataset version tag or timestamp to retrieve the examples as of.
|
||||
|
||||
Response examples will only be those that were present at the time of
|
||||
the tagged (or timestamped) version.
|
||||
splits: A list of dataset splits, which are divisions of your dataset such
|
||||
as `train`, `test`, or `validation`.
|
||||
|
||||
Returns examples only from the specified splits.
|
||||
inline_s3_urls: Whether to inline S3 URLs.
|
||||
offset: The offset to start from.
|
||||
limit: The maximum number of examples to return.
|
||||
metadata: Metadata to filter by.
|
||||
filter: A structured filter string to apply to the examples.
|
||||
client: LangSmith Client.
|
||||
|
||||
If not provided will be initialized from below args.
|
||||
client_kwargs: Keyword args to pass to LangSmith client init.
|
||||
|
||||
Should only be specified if `client` isn't.
|
||||
|
||||
Raises:
|
||||
ValueError: If both `client` and `client_kwargs` are provided.
|
||||
""" # noqa: E501
|
||||
if client and client_kwargs:
|
||||
msg = (
|
||||
"Received both `client` and `client_kwargs`. "
|
||||
"Pass `client_kwargs` only when `client` is not provided."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
self._client = client or LangSmithClient(**client_kwargs)
|
||||
self.content_key = list(content_key.split(".")) if content_key else []
|
||||
self.format_content = format_content or _stringify
|
||||
self.dataset_id = dataset_id
|
||||
self.dataset_name = dataset_name
|
||||
self.example_ids = example_ids
|
||||
self.as_of = as_of
|
||||
self.splits = splits
|
||||
self.inline_s3_urls = inline_s3_urls
|
||||
self.offset = offset
|
||||
self.limit = limit
|
||||
self.metadata = metadata
|
||||
self.filter = filter
|
||||
|
||||
@override
|
||||
def lazy_load(self) -> Iterator[Document]:
|
||||
for example in self._client.list_examples(
|
||||
dataset_id=self.dataset_id,
|
||||
dataset_name=self.dataset_name,
|
||||
example_ids=self.example_ids,
|
||||
as_of=self.as_of,
|
||||
splits=self.splits,
|
||||
inline_s3_urls=self.inline_s3_urls,
|
||||
offset=self.offset,
|
||||
limit=self.limit,
|
||||
metadata=self.metadata,
|
||||
filter=self.filter,
|
||||
):
|
||||
content = _get_content_from_inputs(example.inputs, self.content_key)
|
||||
content_str = self.format_content(content)
|
||||
metadata = pydantic_to_dict(example)
|
||||
# Stringify datetime and UUID types.
|
||||
for k in ("dataset_id", "created_at", "modified_at", "source_run_id", "id"):
|
||||
metadata[k] = str(metadata[k]) if metadata[k] else metadata[k]
|
||||
yield Document(content_str, metadata=metadata)
|
||||
|
||||
|
||||
def _get_content_from_inputs(inputs: Any, content_key: Sequence[str]) -> Any:
|
||||
"""Resolve nested example input content for `LangSmithLoader`.
|
||||
|
||||
Args:
|
||||
inputs: Example input payload returned by LangSmith.
|
||||
content_key: Ordered key path used to extract the document content.
|
||||
|
||||
Returns:
|
||||
The extracted content value.
|
||||
|
||||
Raises:
|
||||
ValueError: If a key in `content_key` is missing, or a value along the path
|
||||
(including `inputs` itself) is not a mapping.
|
||||
"""
|
||||
content = inputs
|
||||
full_path = ".".join(content_key)
|
||||
|
||||
for i, key in enumerate(content_key):
|
||||
current_path = ".".join(content_key[:i]) or "<root>"
|
||||
if not isinstance(content, Mapping):
|
||||
msg = (
|
||||
f"Could not resolve content_key {full_path!r}: expected a mapping at "
|
||||
f"{current_path!r}, but found {type(content).__name__}."
|
||||
)
|
||||
# A too-deep `content_key` is an invalid-argument error, not a runtime
|
||||
# type bug, so it is unified with the missing-key case as `ValueError`.
|
||||
raise ValueError(msg) # noqa: TRY004
|
||||
if key not in content:
|
||||
msg = (
|
||||
f"Could not resolve content_key {full_path!r}: missing key {key!r} "
|
||||
f"under {current_path!r}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
content = content[key]
|
||||
|
||||
return content
|
||||
|
||||
|
||||
def _stringify(x: str | dict[str, Any]) -> str:
|
||||
if isinstance(x, str):
|
||||
return x
|
||||
try:
|
||||
return json.dumps(x, indent=2)
|
||||
except Exception:
|
||||
return str(x)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Documents module for data retrieval and processing workflows.
|
||||
|
||||
This module provides core abstractions for handling data in retrieval-augmented
|
||||
generation (RAG) pipelines, vector stores, and document processing workflows.
|
||||
|
||||
!!! warning "Documents vs. message content"
|
||||
|
||||
This module is distinct from `langchain_core.messages.content`, which provides
|
||||
multimodal content blocks for **LLM chat I/O** (text, images, audio, etc. within
|
||||
messages).
|
||||
|
||||
**Key distinction:**
|
||||
|
||||
- **Documents** (this module): For **data retrieval and processing workflows**
|
||||
- Vector stores, retrievers, RAG pipelines
|
||||
- Text chunking, embedding, and semantic search
|
||||
- Example: Chunks of a PDF stored in a vector database
|
||||
|
||||
- **Content Blocks** (`messages.content`): For **LLM conversational I/O**
|
||||
- Multimodal message content sent to/from models
|
||||
- Tool calls, reasoning, citations within chat
|
||||
- Example: An image sent to a vision model in a chat message (via
|
||||
[`ImageContentBlock`][langchain.messages.ImageContentBlock])
|
||||
|
||||
While both can represent similar data types (text, files), they serve different
|
||||
architectural purposes in LangChain applications.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from langchain_core._import_utils import import_attr
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from langchain_core.documents.base import Document
|
||||
from langchain_core.documents.compressor import BaseDocumentCompressor
|
||||
from langchain_core.documents.transformers import BaseDocumentTransformer
|
||||
|
||||
__all__ = ("BaseDocumentCompressor", "BaseDocumentTransformer", "Document")
|
||||
|
||||
_dynamic_imports = {
|
||||
"Document": "base",
|
||||
"BaseDocumentCompressor": "compressor",
|
||||
"BaseDocumentTransformer": "transformers",
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(attr_name: str) -> object:
|
||||
module_name = _dynamic_imports.get(attr_name)
|
||||
result = import_attr(attr_name, module_name, __spec__.parent)
|
||||
globals()[attr_name] = result
|
||||
return result
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
return list(__all__)
|
||||
@@ -0,0 +1,347 @@
|
||||
"""Base classes for media and documents.
|
||||
|
||||
This module contains core abstractions for **data retrieval and processing workflows**:
|
||||
|
||||
- `BaseMedia`: Base class providing `id` and `metadata` fields
|
||||
- `Blob`: Raw data loading (files, binary data) - used by document loaders
|
||||
- `Document`: Text content for retrieval (RAG, vector stores, semantic search)
|
||||
|
||||
!!! note "Not for LLM chat messages"
|
||||
|
||||
These classes are for data processing pipelines, not LLM I/O. For multimodal
|
||||
content in chat messages (images, audio in conversations), see
|
||||
`langchain.messages` content blocks instead.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import mimetypes
|
||||
from io import BufferedReader, BytesIO
|
||||
from pathlib import Path, PurePath
|
||||
from typing import TYPE_CHECKING, Any, Literal, cast
|
||||
|
||||
from pydantic import ConfigDict, Field, model_validator
|
||||
|
||||
from langchain_core.load.serializable import Serializable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator
|
||||
|
||||
PathLike = str | PurePath
|
||||
|
||||
|
||||
class BaseMedia(Serializable):
|
||||
"""Base class for content used in retrieval and data processing workflows.
|
||||
|
||||
Provides common fields for content that needs to be stored, indexed, or searched.
|
||||
|
||||
!!! note
|
||||
|
||||
For multimodal content in **chat messages** (images, audio sent to/from LLMs),
|
||||
use `langchain.messages` content blocks instead.
|
||||
"""
|
||||
|
||||
# The ID field is optional at the moment.
|
||||
# It will likely become required in a future major release after
|
||||
# it has been adopted by enough VectorStore implementations.
|
||||
id: str | None = Field(default=None, coerce_numbers_to_str=True)
|
||||
"""An optional identifier for the document.
|
||||
|
||||
Ideally this should be unique across the document collection and formatted
|
||||
as a UUID, but this will not be enforced.
|
||||
"""
|
||||
|
||||
metadata: dict[Any, Any] = Field(default_factory=dict)
|
||||
"""Arbitrary metadata associated with the content."""
|
||||
|
||||
|
||||
class Blob(BaseMedia):
|
||||
"""Raw data abstraction for document loading and file processing.
|
||||
|
||||
Represents raw bytes or text, either in-memory or by file reference. Used
|
||||
primarily by document loaders to decouple data loading from parsing.
|
||||
|
||||
Inspired by [Mozilla's `Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob)
|
||||
|
||||
???+ example "Initialize a blob from in-memory data"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_data("Hello, world!")
|
||||
|
||||
# Read the blob as a string
|
||||
print(blob.as_string())
|
||||
|
||||
# Read the blob as bytes
|
||||
print(blob.as_bytes())
|
||||
|
||||
# Read the blob as a byte stream
|
||||
with blob.as_bytes_io() as f:
|
||||
print(f.read())
|
||||
```
|
||||
|
||||
??? example "Load from memory and specify MIME type and metadata"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_data(
|
||||
data="Hello, world!",
|
||||
mime_type="text/plain",
|
||||
metadata={"source": "https://example.com"},
|
||||
)
|
||||
```
|
||||
|
||||
??? example "Load the blob from a file"
|
||||
|
||||
```python
|
||||
from langchain_core.documents import Blob
|
||||
|
||||
blob = Blob.from_path("path/to/file.txt")
|
||||
|
||||
# Read the blob as a string
|
||||
print(blob.as_string())
|
||||
|
||||
# Read the blob as bytes
|
||||
print(blob.as_bytes())
|
||||
|
||||
# Read the blob as a byte stream
|
||||
with blob.as_bytes_io() as f:
|
||||
print(f.read())
|
||||
```
|
||||
"""
|
||||
|
||||
data: bytes | str | None = None
|
||||
"""Raw data associated with the `Blob`."""
|
||||
|
||||
mimetype: str | None = None
|
||||
"""MIME type, not to be confused with a file extension."""
|
||||
|
||||
encoding: str = "utf-8"
|
||||
"""Encoding to use if decoding the bytes into a string.
|
||||
|
||||
Uses `utf-8` as default encoding if decoding to string.
|
||||
"""
|
||||
|
||||
path: PathLike | None = None
|
||||
"""Location where the original content was found."""
|
||||
|
||||
model_config = ConfigDict(
|
||||
arbitrary_types_allowed=True,
|
||||
frozen=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def source(self) -> str | None:
|
||||
"""The source location of the blob as string if known otherwise none.
|
||||
|
||||
If a path is associated with the `Blob`, it will default to the path location.
|
||||
|
||||
Unless explicitly set via a metadata field called `'source'`, in which
|
||||
case that value will be used instead.
|
||||
"""
|
||||
if self.metadata and "source" in self.metadata:
|
||||
return cast("str | None", self.metadata["source"])
|
||||
return str(self.path) if self.path else None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def check_blob_is_valid(cls, values: dict[str, Any]) -> Any:
|
||||
"""Verify that either data or path is provided."""
|
||||
if "data" not in values and "path" not in values:
|
||||
msg = "Either data or path must be provided"
|
||||
raise ValueError(msg)
|
||||
return values
|
||||
|
||||
def as_string(self) -> str:
|
||||
"""Read data as a string.
|
||||
|
||||
Raises:
|
||||
ValueError: If the blob cannot be represented as a string.
|
||||
|
||||
Returns:
|
||||
The data as a string.
|
||||
"""
|
||||
if self.data is None and self.path:
|
||||
return Path(self.path).read_text(encoding=self.encoding)
|
||||
if isinstance(self.data, bytes):
|
||||
return self.data.decode(self.encoding)
|
||||
if isinstance(self.data, str):
|
||||
return self.data
|
||||
msg = f"Unable to get string for blob {self}"
|
||||
raise ValueError(msg)
|
||||
|
||||
def as_bytes(self) -> bytes:
|
||||
"""Read data as bytes.
|
||||
|
||||
Raises:
|
||||
ValueError: If the blob cannot be represented as bytes.
|
||||
|
||||
Returns:
|
||||
The data as bytes.
|
||||
"""
|
||||
if isinstance(self.data, bytes):
|
||||
return self.data
|
||||
if isinstance(self.data, str):
|
||||
return self.data.encode(self.encoding)
|
||||
if self.data is None and self.path:
|
||||
return Path(self.path).read_bytes()
|
||||
msg = f"Unable to get bytes for blob {self}"
|
||||
raise ValueError(msg)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def as_bytes_io(self) -> Generator[BytesIO | BufferedReader, None, None]:
|
||||
"""Read data as a byte stream.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: If the blob cannot be represented as a byte stream.
|
||||
|
||||
Yields:
|
||||
The data as a byte stream.
|
||||
"""
|
||||
if isinstance(self.data, bytes):
|
||||
yield BytesIO(self.data)
|
||||
elif self.data is None and self.path:
|
||||
with Path(self.path).open("rb") as f:
|
||||
yield f
|
||||
else:
|
||||
msg = f"Unable to convert blob {self}"
|
||||
raise NotImplementedError(msg)
|
||||
|
||||
@classmethod
|
||||
def from_path(
|
||||
cls,
|
||||
path: PathLike,
|
||||
*,
|
||||
encoding: str = "utf-8",
|
||||
mime_type: str | None = None,
|
||||
guess_type: bool = True,
|
||||
metadata: dict[Any, Any] | None = None,
|
||||
) -> Blob:
|
||||
"""Load the blob from a path like object.
|
||||
|
||||
Args:
|
||||
path: Path-like object to file to be read
|
||||
encoding: Encoding to use if decoding the bytes into a string
|
||||
mime_type: If provided, will be set as the MIME type of the data
|
||||
guess_type: If `True`, the MIME type will be guessed from the file
|
||||
extension, if a MIME type was not provided
|
||||
metadata: Metadata to associate with the `Blob`
|
||||
|
||||
Returns:
|
||||
`Blob` instance
|
||||
"""
|
||||
if mime_type is None and guess_type:
|
||||
mimetype = mimetypes.guess_type(path)[0]
|
||||
else:
|
||||
mimetype = mime_type
|
||||
# We do not load the data immediately, instead we treat the blob as a
|
||||
# reference to the underlying data.
|
||||
return cls(
|
||||
data=None,
|
||||
mimetype=mimetype,
|
||||
encoding=encoding,
|
||||
path=path,
|
||||
metadata=metadata if metadata is not None else {},
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_data(
|
||||
cls,
|
||||
data: str | bytes,
|
||||
*,
|
||||
encoding: str = "utf-8",
|
||||
mime_type: str | None = None,
|
||||
path: str | None = None,
|
||||
metadata: dict[Any, Any] | None = None,
|
||||
) -> Blob:
|
||||
"""Initialize the `Blob` from in-memory data.
|
||||
|
||||
Args:
|
||||
data: The in-memory data associated with the `Blob`
|
||||
encoding: Encoding to use if decoding the bytes into a string
|
||||
mime_type: If provided, will be set as the MIME type of the data
|
||||
path: If provided, will be set as the source from which the data came
|
||||
metadata: Metadata to associate with the `Blob`
|
||||
|
||||
Returns:
|
||||
`Blob` instance
|
||||
"""
|
||||
return cls(
|
||||
data=data,
|
||||
mimetype=mime_type,
|
||||
encoding=encoding,
|
||||
path=path,
|
||||
metadata=metadata if metadata is not None else {},
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
"""Return the blob representation."""
|
||||
str_repr = f"Blob {id(self)}"
|
||||
if self.source:
|
||||
str_repr += f" {self.source}"
|
||||
return str_repr
|
||||
|
||||
|
||||
class Document(BaseMedia):
|
||||
"""Class for storing a piece of text and associated metadata.
|
||||
|
||||
!!! note
|
||||
|
||||
`Document` is for **retrieval workflows**, not chat I/O. For sending text
|
||||
to an LLM in a conversation, use message types from `langchain.messages`.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from langchain_core.documents import Document
|
||||
|
||||
document = Document(
|
||||
page_content="Hello, world!", metadata={"source": "https://example.com"}
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
page_content: str
|
||||
"""String text."""
|
||||
|
||||
type: Literal["Document"] = "Document"
|
||||
|
||||
def __init__(self, page_content: str, **kwargs: Any) -> None:
|
||||
"""Pass page_content in as positional or named arg."""
|
||||
# my-py is complaining that page_content is not defined on the base class.
|
||||
# Here, we're relying on pydantic base class to handle the validation.
|
||||
super().__init__(page_content=page_content, **kwargs) # type: ignore[call-arg,unused-ignore]
|
||||
|
||||
@classmethod
|
||||
def is_lc_serializable(cls) -> bool:
|
||||
"""Return `True` as this class is serializable."""
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def get_lc_namespace(cls) -> list[str]:
|
||||
"""Get the namespace of the LangChain object.
|
||||
|
||||
Returns:
|
||||
`["langchain", "schema", "document"]`
|
||||
"""
|
||||
return ["langchain", "schema", "document"]
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Override `__str__` to restrict it to page_content and metadata.
|
||||
|
||||
Returns:
|
||||
A string representation of the `Document`.
|
||||
"""
|
||||
# The format matches pydantic format for __str__.
|
||||
#
|
||||
# The purpose of this change is to make sure that user code that feeds
|
||||
# Document objects directly into prompts remains unchanged due to the addition
|
||||
# of the id field (or any other fields in the future).
|
||||
#
|
||||
# This override will likely be removed in the future in favor of a more general
|
||||
# solution of formatting content directly inside the prompts.
|
||||
if self.metadata:
|
||||
return f"page_content='{self.page_content}' metadata={self.metadata}"
|
||||
return f"page_content='{self.page_content}'"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user