langchain-content-normalizer 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. langchain_content_normalizer-0.1.0/.github/ISSUE_TEMPLATE/bug_report.yml +30 -0
  2. langchain_content_normalizer-0.1.0/.github/ISSUE_TEMPLATE/feature_request.yml +19 -0
  3. langchain_content_normalizer-0.1.0/.github/dependabot.yml +10 -0
  4. langchain_content_normalizer-0.1.0/.github/pull_request_template.md +9 -0
  5. langchain_content_normalizer-0.1.0/.github/workflows/build.yml +27 -0
  6. langchain_content_normalizer-0.1.0/.github/workflows/ci.yml +25 -0
  7. langchain_content_normalizer-0.1.0/.github/workflows/publish.yml +22 -0
  8. langchain_content_normalizer-0.1.0/.github/workflows/smoke.yml +23 -0
  9. langchain_content_normalizer-0.1.0/.gitignore +10 -0
  10. langchain_content_normalizer-0.1.0/CHANGELOG.md +15 -0
  11. langchain_content_normalizer-0.1.0/CONTRIBUTING.md +25 -0
  12. langchain_content_normalizer-0.1.0/LICENSE +21 -0
  13. langchain_content_normalizer-0.1.0/MAINTAINERS.md +20 -0
  14. langchain_content_normalizer-0.1.0/PKG-INFO +131 -0
  15. langchain_content_normalizer-0.1.0/README.md +91 -0
  16. langchain_content_normalizer-0.1.0/docs/release-process.md +33 -0
  17. langchain_content_normalizer-0.1.0/examples/build_vision_content.py +10 -0
  18. langchain_content_normalizer-0.1.0/examples/normalize_mcp_output.py +20 -0
  19. langchain_content_normalizer-0.1.0/pyproject.toml +46 -0
  20. langchain_content_normalizer-0.1.0/scripts/smoke.py +23 -0
  21. langchain_content_normalizer-0.1.0/src/lc_content_normalizer/__init__.py +20 -0
  22. langchain_content_normalizer-0.1.0/src/lc_content_normalizer/text.py +74 -0
  23. langchain_content_normalizer-0.1.0/src/lc_content_normalizer/vision.py +51 -0
  24. langchain_content_normalizer-0.1.0/tests/test_text.py +83 -0
  25. langchain_content_normalizer-0.1.0/tests/test_vision.py +60 -0
  26. langchain_content_normalizer-0.1.0/uv.lock +108 -0
@@ -0,0 +1,30 @@
1
+ name: Bug report
2
+ description: Report incorrect content normalization behavior.
3
+ title: "[Bug]: "
4
+ labels: [bug]
5
+ body:
6
+ - type: textarea
7
+ id: input
8
+ attributes:
9
+ label: Input content shape
10
+ description: Paste the minimal content shape that fails.
11
+ render: python
12
+ validations:
13
+ required: true
14
+ - type: textarea
15
+ id: expected
16
+ attributes:
17
+ label: Expected output
18
+ validations:
19
+ required: true
20
+ - type: textarea
21
+ id: actual
22
+ attributes:
23
+ label: Actual output
24
+ validations:
25
+ required: true
26
+ - type: input
27
+ id: version
28
+ attributes:
29
+ label: Package version
30
+ placeholder: 0.1.0
@@ -0,0 +1,19 @@
1
+ name: Feature request
2
+ description: Suggest a new content shape, provider adapter, or option.
3
+ title: "[Feature]: "
4
+ labels: [enhancement]
5
+ body:
6
+ - type: textarea
7
+ id: problem
8
+ attributes:
9
+ label: Problem
10
+ description: What content shape or workflow is not covered today?
11
+ validations:
12
+ required: true
13
+ - type: textarea
14
+ id: proposal
15
+ attributes:
16
+ label: Proposal
17
+ description: Describe the behavior you want.
18
+ validations:
19
+ required: true
@@ -0,0 +1,10 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: "github-actions"
4
+ directory: "/"
5
+ schedule:
6
+ interval: "monthly"
7
+ - package-ecosystem: "uv"
8
+ directory: "/"
9
+ schedule:
10
+ interval: "monthly"
@@ -0,0 +1,9 @@
1
+ ## Summary
2
+
3
+ ## Checklist
4
+
5
+ - [ ] Added or updated tests.
6
+ - [ ] `uv run ruff check .` passes.
7
+ - [ ] `uv run pytest` passes.
8
+ - [ ] Runtime dependencies are still zero, or the dependency is justified.
9
+ - [ ] Unknown non-empty content is not silently dropped.
@@ -0,0 +1,27 @@
1
+ name: Build
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ tags: ["v*"]
7
+ pull_request:
8
+ branches: [main]
9
+
10
+ jobs:
11
+ build:
12
+ runs-on: ubuntu-latest
13
+ steps:
14
+ - uses: actions/checkout@v4
15
+ - name: Install uv
16
+ uses: astral-sh/setup-uv@v5
17
+ - name: Set up Python
18
+ uses: actions/setup-python@v5
19
+ with:
20
+ python-version: "3.11"
21
+ - name: Build package
22
+ run: uv build
23
+ - name: Upload distributions
24
+ uses: actions/upload-artifact@v4
25
+ with:
26
+ name: dist
27
+ path: dist/*
@@ -0,0 +1,25 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ test:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+ - name: Install dependencies
21
+ run: uv sync --dev
22
+ - name: Lint
23
+ run: uv run ruff check .
24
+ - name: Test
25
+ run: uv run pytest
@@ -0,0 +1,22 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ publish:
10
+ runs-on: ubuntu-latest
11
+ environment: pypi
12
+ permissions:
13
+ id-token: write
14
+ contents: read
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - name: Install uv
18
+ uses: astral-sh/setup-uv@v5
19
+ - name: Build package
20
+ run: uv build
21
+ - name: Publish package
22
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,23 @@
1
+ name: Smoke
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ smoke:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - name: Install uv
15
+ uses: astral-sh/setup-uv@v5
16
+ - name: Set up Python
17
+ uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.11"
20
+ - name: Install dependencies
21
+ run: uv sync --dev
22
+ - name: Run smoke test
23
+ run: uv run python scripts/smoke.py
@@ -0,0 +1,10 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .coverage
9
+ htmlcov/
10
+ .DS_Store
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## Unreleased
4
+
5
+ - Added maintainer documentation, release process, examples, and issue/PR templates.
6
+ - Added build and manual publish workflows.
7
+
8
+ ## 0.1.0
9
+
10
+ Initial public release.
11
+
12
+ - Added text normalization for strings, Anthropic blocks, MCP objects, tool results, and message-like wrappers.
13
+ - Added tool output truncation helper.
14
+ - Added provider-aware multimodal image payload builders.
15
+ - Added tests and CI.
@@ -0,0 +1,25 @@
1
+ # Contributing
2
+
3
+ Thanks for helping improve `langchain-content-normalizer`.
4
+
5
+ ## Development setup
6
+
7
+ ```bash
8
+ uv sync --dev
9
+ uv run ruff check .
10
+ uv run pytest
11
+ ```
12
+
13
+ ## Contribution guidelines
14
+
15
+ - Keep runtime dependencies at zero unless there is a strong reason.
16
+ - Prefer duck typing over importing LangChain, MCP, or provider SDK classes.
17
+ - Add tests for every new content shape or provider format.
18
+ - Do not silently drop unknown non-empty content. Preserve it with a safe fallback.
19
+ - Keep public APIs small and documented in `README.md`.
20
+
21
+ ## Useful PRs
22
+
23
+ - New MCP or LangChain content fixtures.
24
+ - Better multimodal provider adapters.
25
+ - Strict-mode behavior for applications that prefer errors over fallback strings.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Benjamin Jornet
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,20 @@
1
+ # Maintainers
2
+
3
+ ## Primary maintainer
4
+
5
+ - Benjamin Jornet (`@BenjaminJornet`)
6
+
7
+ ## Maintainer responsibilities
8
+
9
+ - Review pull requests that add new content shapes or provider adapters.
10
+ - Triage issues with reproducible input/output examples.
11
+ - Keep CI, packaging, and release workflows working.
12
+ - Preserve the zero-runtime-dependency contract unless a change is explicitly justified.
13
+ - Publish releases and update `CHANGELOG.md`.
14
+
15
+ ## Review priorities
16
+
17
+ 1. Unknown non-empty content must not be silently dropped.
18
+ 2. Runtime dependencies should remain at zero.
19
+ 3. Tests must cover each new block shape.
20
+ 4. Public APIs should stay small and documented.
@@ -0,0 +1,131 @@
1
+ Metadata-Version: 2.4
2
+ Name: langchain-content-normalizer
3
+ Version: 0.1.0
4
+ Summary: Normalize LangChain, MCP, and multimodal content blocks into provider-ready text and image payloads.
5
+ Project-URL: Homepage, https://github.com/benjaminjornet/langchain-content-normalizer
6
+ Project-URL: Issues, https://github.com/benjaminjornet/langchain-content-normalizer/issues
7
+ Author-email: Benjamin Jornet <benjamin.jornet@gmail.com>
8
+ License: MIT License
9
+
10
+ Copyright (c) 2026 Benjamin Jornet
11
+
12
+ Permission is hereby granted, free of charge, to any person obtaining a copy
13
+ of this software and associated documentation files (the "Software"), to deal
14
+ in the Software without restriction, including without limitation the rights
15
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
16
+ copies of the Software, and to permit persons to whom the Software is
17
+ furnished to do so, subject to the following conditions:
18
+
19
+ The above copyright notice and this permission notice shall be included in all
20
+ copies or substantial portions of the Software.
21
+
22
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
23
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
24
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
25
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
26
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
27
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
28
+ SOFTWARE.
29
+ License-File: LICENSE
30
+ Keywords: content-normalization,langchain,llm,mcp,multimodal
31
+ Classifier: Development Status :: 3 - Alpha
32
+ Classifier: Intended Audience :: Developers
33
+ Classifier: License :: OSI Approved :: MIT License
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.11
36
+ Classifier: Programming Language :: Python :: 3.12
37
+ Classifier: Programming Language :: Python :: 3.13
38
+ Requires-Python: >=3.11
39
+ Description-Content-Type: text/markdown
40
+
41
+ # langchain-content-normalizer
42
+
43
+ [![CI](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml/badge.svg)](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml)
44
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
45
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
46
+
47
+ Normalize the messy content shapes produced by LangChain, MCP tools, Anthropic content blocks, and multimodal chat APIs.
48
+
49
+ The package has no runtime dependencies. It works by duck typing instead of importing LangChain or MCP classes.
50
+
51
+ ## What it solves
52
+
53
+ LLM agent stacks often receive content as one of many incompatible shapes:
54
+
55
+ | Source | Example shape | Output |
56
+ | --- | --- | --- |
57
+ | Classic chat | `"plain text"` | `"plain text"` |
58
+ | Anthropic blocks | `[{"type": "text", "text": "hi"}]` | `"hi"` |
59
+ | Tool calls | `[{"type": "tool_use", ...}]` | skipped by default |
60
+ | MCP tool results | `[{"type": "tool_result", "content": [...]}]` | flattened text |
61
+ | MCP objects | objects exposing `.text` | extracted text |
62
+ | Message wrappers | objects exposing `.content` | recursively normalized |
63
+
64
+ ## Install
65
+
66
+ ```bash
67
+ uv add langchain-content-normalizer
68
+ ```
69
+
70
+ ## Text normalization
71
+
72
+ ```python
73
+ from lc_content_normalizer import extract_text_content, normalize_tool_output
74
+
75
+ content = [
76
+ {"type": "text", "text": "Reading logs..."},
77
+ {"type": "tool_use", "name": "tail_logs", "input": {"service": "api"}},
78
+ ]
79
+
80
+ assert extract_text_content(content) == "Reading logs..."
81
+ assert "tail_logs" in extract_text_content(content, skip_tool_use=False)
82
+
83
+ safe_output = normalize_tool_output(huge_tool_payload, max_chars=50_000)
84
+ ```
85
+
86
+ ## Vision format routing
87
+
88
+ ```python
89
+ from lc_content_normalizer import build_human_message_content, detect_vision_format
90
+
91
+ vision_format = detect_vision_format("anthropic", "claude-3-5-sonnet")
92
+ content = build_human_message_content(
93
+ "Explain this alert screenshot",
94
+ images=[{"data_url": "data:image/png;base64,...", "mime_type": "image/png"}],
95
+ vision_format=vision_format,
96
+ )
97
+ ```
98
+
99
+ `detect_vision_format()` returns:
100
+
101
+ | Provider/model | Format |
102
+ | --- | --- |
103
+ | `anthropic` | native Anthropic `image` block with `source.base64` |
104
+ | `ollama` + `llava`/`vision` model name | OpenAI-compatible `image_url` block |
105
+ | `ollama` text-only model | `none`, images are dropped |
106
+ | OpenAI-compatible providers | OpenAI-compatible `image_url` block |
107
+
108
+ ## Examples
109
+
110
+ - `examples/normalize_mcp_output.py` shows how MCP-style tool results are flattened.
111
+ - `examples/build_vision_content.py` shows provider-aware image block generation.
112
+
113
+ ## Roadmap
114
+
115
+ - Add strict mode for unknown content blocks.
116
+ - Add more MCP fixture coverage.
117
+ - Add provider-specific adapters as content formats evolve.
118
+ - Keep runtime dependencies at zero.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ uv sync --dev
124
+ uv run ruff check .
125
+ uv run pytest
126
+ uv run python scripts/smoke.py
127
+ ```
128
+
129
+ ## License
130
+
131
+ MIT
@@ -0,0 +1,91 @@
1
+ # langchain-content-normalizer
2
+
3
+ [![CI](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml/badge.svg)](https://github.com/BenjaminJornet/langchain-content-normalizer/actions/workflows/ci.yml)
4
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
5
+ [![Python](https://img.shields.io/badge/python-3.11%2B-blue.svg)](pyproject.toml)
6
+
7
+ Normalize the messy content shapes produced by LangChain, MCP tools, Anthropic content blocks, and multimodal chat APIs.
8
+
9
+ The package has no runtime dependencies. It works by duck typing instead of importing LangChain or MCP classes.
10
+
11
+ ## What it solves
12
+
13
+ LLM agent stacks often receive content as one of many incompatible shapes:
14
+
15
+ | Source | Example shape | Output |
16
+ | --- | --- | --- |
17
+ | Classic chat | `"plain text"` | `"plain text"` |
18
+ | Anthropic blocks | `[{"type": "text", "text": "hi"}]` | `"hi"` |
19
+ | Tool calls | `[{"type": "tool_use", ...}]` | skipped by default |
20
+ | MCP tool results | `[{"type": "tool_result", "content": [...]}]` | flattened text |
21
+ | MCP objects | objects exposing `.text` | extracted text |
22
+ | Message wrappers | objects exposing `.content` | recursively normalized |
23
+
24
+ ## Install
25
+
26
+ ```bash
27
+ uv add langchain-content-normalizer
28
+ ```
29
+
30
+ ## Text normalization
31
+
32
+ ```python
33
+ from lc_content_normalizer import extract_text_content, normalize_tool_output
34
+
35
+ content = [
36
+ {"type": "text", "text": "Reading logs..."},
37
+ {"type": "tool_use", "name": "tail_logs", "input": {"service": "api"}},
38
+ ]
39
+
40
+ assert extract_text_content(content) == "Reading logs..."
41
+ assert "tail_logs" in extract_text_content(content, skip_tool_use=False)
42
+
43
+ safe_output = normalize_tool_output(huge_tool_payload, max_chars=50_000)
44
+ ```
45
+
46
+ ## Vision format routing
47
+
48
+ ```python
49
+ from lc_content_normalizer import build_human_message_content, detect_vision_format
50
+
51
+ vision_format = detect_vision_format("anthropic", "claude-3-5-sonnet")
52
+ content = build_human_message_content(
53
+ "Explain this alert screenshot",
54
+ images=[{"data_url": "data:image/png;base64,...", "mime_type": "image/png"}],
55
+ vision_format=vision_format,
56
+ )
57
+ ```
58
+
59
+ `detect_vision_format()` returns:
60
+
61
+ | Provider/model | Format |
62
+ | --- | --- |
63
+ | `anthropic` | native Anthropic `image` block with `source.base64` |
64
+ | `ollama` + `llava`/`vision` model name | OpenAI-compatible `image_url` block |
65
+ | `ollama` text-only model | `none`, images are dropped |
66
+ | OpenAI-compatible providers | OpenAI-compatible `image_url` block |
67
+
68
+ ## Examples
69
+
70
+ - `examples/normalize_mcp_output.py` shows how MCP-style tool results are flattened.
71
+ - `examples/build_vision_content.py` shows provider-aware image block generation.
72
+
73
+ ## Roadmap
74
+
75
+ - Add strict mode for unknown content blocks.
76
+ - Add more MCP fixture coverage.
77
+ - Add provider-specific adapters as content formats evolve.
78
+ - Keep runtime dependencies at zero.
79
+
80
+ ## Development
81
+
82
+ ```bash
83
+ uv sync --dev
84
+ uv run ruff check .
85
+ uv run pytest
86
+ uv run python scripts/smoke.py
87
+ ```
88
+
89
+ ## License
90
+
91
+ MIT
@@ -0,0 +1,33 @@
1
+ # Release Process
2
+
3
+ This project uses semantic versioning while the API is pre-1.0.
4
+
5
+ ## Checklist
6
+
7
+ 1. Run `uv run ruff check .`.
8
+ 2. Run `uv run pytest`.
9
+ 3. Run `uv build`.
10
+ 4. Update `CHANGELOG.md`.
11
+ 5. Create a Git tag, for example `v0.1.1`.
12
+ 6. Push the tag.
13
+ 7. Create a GitHub release.
14
+ 8. Publish to PyPI through the manual `Publish to PyPI` workflow.
15
+
16
+ ## Smoke checks
17
+
18
+ ```bash
19
+ uv run python examples/normalize_mcp_output.py
20
+ uv run python examples/build_vision_content.py
21
+ ```
22
+
23
+ ## Release notes format
24
+
25
+ ```md
26
+ ## vX.Y.Z
27
+
28
+ ### Added
29
+
30
+ ### Fixed
31
+
32
+ ### Changed
33
+ ```
@@ -0,0 +1,10 @@
1
+ from __future__ import annotations
2
+
3
+ from lc_content_normalizer import build_human_message_content, detect_vision_format
4
+
5
+ image = {"data_url": "data:image/png;base64,abc123", "mime_type": "image/png"}
6
+
7
+ for provider, model in [("anthropic", "claude"), ("ollama", "llava"), ("ollama", "llama")]:
8
+ vision_format = detect_vision_format(provider, model)
9
+ content = build_human_message_content("Explain this screenshot", [image], vision_format)
10
+ print(provider, model, vision_format, content)
@@ -0,0 +1,20 @@
1
+ from __future__ import annotations
2
+
3
+ from lc_content_normalizer import extract_text_content, normalize_tool_output
4
+
5
+
6
+ class FakeTextContent:
7
+ def __init__(self, text: str) -> None:
8
+ self.type = "text"
9
+ self.text = text
10
+
11
+
12
+ raw_tool_output = [
13
+ {
14
+ "type": "tool_result",
15
+ "content": [FakeTextContent("service=api status=healthy\n")],
16
+ }
17
+ ]
18
+
19
+ print(extract_text_content(raw_tool_output))
20
+ print(normalize_tool_output(raw_tool_output, max_chars=80))
@@ -0,0 +1,46 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-content-normalizer"
7
+ version = "0.1.0"
8
+ description = "Normalize LangChain, MCP, and multimodal content blocks into provider-ready text and image payloads."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { file = "LICENSE" }
12
+ authors = [{ name = "Benjamin Jornet", email = "benjamin.jornet@gmail.com" }]
13
+ keywords = ["langchain", "mcp", "llm", "multimodal", "content-normalization"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ ]
23
+ dependencies = []
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/benjaminjornet/langchain-content-normalizer"
27
+ Issues = "https://github.com/benjaminjornet/langchain-content-normalizer/issues"
28
+
29
+ [dependency-groups]
30
+ dev = [
31
+ "pytest>=8.0",
32
+ "ruff>=0.8",
33
+ ]
34
+
35
+ [tool.hatch.build.targets.wheel]
36
+ packages = ["src/lc_content_normalizer"]
37
+
38
+ [tool.ruff]
39
+ line-length = 100
40
+ target-version = "py311"
41
+
42
+ [tool.ruff.lint]
43
+ select = ["E", "F", "I", "UP", "B", "SIM"]
44
+
45
+ [tool.pytest.ini_options]
46
+ testpaths = ["tests"]
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ from lc_content_normalizer import build_human_message_content, extract_text_content
4
+
5
+
6
+ def main() -> None:
7
+ text = extract_text_content(
8
+ [{"type": "tool_result", "content": [{"type": "text", "text": "ok"}]}]
9
+ )
10
+ assert text == "ok"
11
+
12
+ image_content = build_human_message_content(
13
+ "describe",
14
+ [{"data_url": "data:image/png;base64,abc", "mime_type": "image/png"}],
15
+ "openai",
16
+ )
17
+ assert isinstance(image_content, list)
18
+ assert image_content[1]["type"] == "image_url"
19
+ print("smoke ok")
20
+
21
+
22
+ if __name__ == "__main__":
23
+ main()
@@ -0,0 +1,20 @@
1
+ from .text import extract_text_content, normalize_tool_output
2
+ from .vision import (
3
+ VISION_FORMAT_ANTHROPIC_NATIVE,
4
+ VISION_FORMAT_NONE,
5
+ VISION_FORMAT_OPENAI,
6
+ build_human_message_content,
7
+ detect_vision_format,
8
+ image_block_for_format,
9
+ )
10
+
11
+ __all__ = [
12
+ "VISION_FORMAT_ANTHROPIC_NATIVE",
13
+ "VISION_FORMAT_NONE",
14
+ "VISION_FORMAT_OPENAI",
15
+ "build_human_message_content",
16
+ "detect_vision_format",
17
+ "extract_text_content",
18
+ "image_block_for_format",
19
+ "normalize_tool_output",
20
+ ]
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+
6
+ def extract_text_content(content: Any, *, skip_tool_use: bool = True) -> str:
7
+ """Normalize LangChain, Anthropic, and MCP content shapes to plain text.
8
+
9
+ Supported inputs include strings, Anthropic-style content block lists,
10
+ MCP TextContent-like objects exposing ``.text``, and message-like objects
11
+ exposing ``.content``. Unknown non-empty block lists fall back to ``str`` so
12
+ tool outputs are not silently lost.
13
+ """
14
+ if content is None:
15
+ return ""
16
+ if isinstance(content, str):
17
+ return content
18
+ if isinstance(content, list):
19
+ parts: list[str] = []
20
+ saw_known_block = False
21
+ for block in content:
22
+ if isinstance(block, str):
23
+ parts.append(block)
24
+ saw_known_block = True
25
+ continue
26
+ if isinstance(block, dict):
27
+ block_type = block.get("type")
28
+ if block_type == "text" and isinstance(block.get("text"), str):
29
+ parts.append(block["text"])
30
+ saw_known_block = True
31
+ elif block_type == "tool_result":
32
+ parts.append(
33
+ extract_text_content(block.get("content", ""), skip_tool_use=skip_tool_use)
34
+ )
35
+ saw_known_block = True
36
+ elif block_type == "tool_use":
37
+ saw_known_block = True
38
+ if not skip_tool_use:
39
+ parts.append(str(block.get("input", "")))
40
+ elif block_type in {"image", "image_url"}:
41
+ saw_known_block = True
42
+ continue
43
+
44
+ text_attr = getattr(block, "text", None)
45
+ if isinstance(text_attr, str):
46
+ parts.append(text_attr)
47
+ saw_known_block = True
48
+ continue
49
+
50
+ parts.append(str(block))
51
+
52
+ result = "".join(parts)
53
+ if not result and content and not saw_known_block:
54
+ return str(content)
55
+ return result
56
+
57
+ inner = getattr(content, "content", None)
58
+ if inner is not None and inner is not content:
59
+ return extract_text_content(inner, skip_tool_use=skip_tool_use)
60
+
61
+ text_attr = getattr(content, "text", None)
62
+ if isinstance(text_attr, str):
63
+ return text_attr
64
+
65
+ return str(content)
66
+
67
+
68
+ def normalize_tool_output(raw: Any, *, max_chars: int = 50_000) -> str:
69
+ """Extract a readable tool output string and truncate oversized payloads."""
70
+ text = extract_text_content(raw)
71
+ if len(text) <= max_chars:
72
+ return text
73
+ omitted = len(text) - max_chars
74
+ return text[:max_chars] + f"\n\n[...truncated {omitted} chars]"
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any
4
+
5
+ VISION_FORMAT_OPENAI = "openai"
6
+ VISION_FORMAT_ANTHROPIC_NATIVE = "anthropic_native"
7
+ VISION_FORMAT_NONE = "none"
8
+
9
+
10
+ def detect_vision_format(provider: str, model: str) -> str:
11
+ """Pick the multimodal block format expected by a provider/model pair."""
12
+ provider_name = (provider or "").lower()
13
+ model_name = (model or "").lower()
14
+
15
+ if provider_name == "anthropic":
16
+ return VISION_FORMAT_ANTHROPIC_NATIVE
17
+ if provider_name == "ollama":
18
+ if "llava" in model_name or "vision" in model_name:
19
+ return VISION_FORMAT_OPENAI
20
+ return VISION_FORMAT_NONE
21
+ return VISION_FORMAT_OPENAI
22
+
23
+
24
+ def image_block_for_format(image: dict[str, str], vision_format: str) -> dict[str, Any]:
25
+ """Render one image into the multimodal block expected by the target API."""
26
+ if vision_format == VISION_FORMAT_ANTHROPIC_NATIVE:
27
+ data_url = image.get("data_url", "")
28
+ header, _, b64_data = data_url.partition(",")
29
+ media_type = image.get("mime_type") or header.removeprefix("data:").split(";", 1)[0]
30
+ return {
31
+ "type": "image",
32
+ "source": {"type": "base64", "media_type": media_type, "data": b64_data},
33
+ }
34
+ return {"type": "image_url", "image_url": {"url": image["data_url"]}}
35
+
36
+
37
+ def build_human_message_content(
38
+ text: str,
39
+ images: list[dict[str, str]] | None = None,
40
+ vision_format: str = VISION_FORMAT_OPENAI,
41
+ ) -> str | list[dict[str, Any]]:
42
+ """Build text-only or multimodal human message content for LangChain."""
43
+ if not images or vision_format == VISION_FORMAT_NONE:
44
+ return text
45
+
46
+ content: list[dict[str, Any]] = []
47
+ if text:
48
+ content.append({"type": "text", "text": text})
49
+ for image in images:
50
+ content.append(image_block_for_format(image, vision_format))
51
+ return content
@@ -0,0 +1,83 @@
1
+ from __future__ import annotations
2
+
3
+ from lc_content_normalizer import extract_text_content, normalize_tool_output
4
+
5
+
6
+ class FakeMessage:
7
+ def __init__(self, content):
8
+ self.content = content
9
+
10
+
11
+ class FakeTextContent:
12
+ def __init__(self, text: str):
13
+ self.type = "text"
14
+ self.text = text
15
+
16
+
17
+ def test_string_passthrough():
18
+ assert extract_text_content("hello") == "hello"
19
+
20
+
21
+ def test_none_returns_empty():
22
+ assert extract_text_content(None) == ""
23
+
24
+
25
+ def test_anthropic_text_blocks_are_concatenated_and_tool_use_is_skipped():
26
+ content = [
27
+ {"type": "text", "text": "The file "},
28
+ {"type": "tool_use", "name": "read_file", "input": {"path": "app.py"}},
29
+ {"type": "text", "text": "is ready."},
30
+ ]
31
+
32
+ assert extract_text_content(content) == "The file is ready."
33
+
34
+
35
+ def test_tool_use_can_be_included_explicitly():
36
+ content = [{"type": "tool_use", "input": {"path": "app.py"}}]
37
+
38
+ assert "app.py" in extract_text_content(content, skip_tool_use=False)
39
+
40
+
41
+ def test_tool_result_nested_content_is_flattened():
42
+ content = [{"type": "tool_result", "content": [{"type": "text", "text": "inner"}]}]
43
+
44
+ assert extract_text_content(content) == "inner"
45
+
46
+
47
+ def test_mcp_text_content_object_is_extracted():
48
+ assert extract_text_content(FakeTextContent("from MCP")) == "from MCP"
49
+
50
+
51
+ def test_mcp_text_content_list_is_extracted():
52
+ assert extract_text_content([FakeTextContent("from MCP")]) == "from MCP"
53
+
54
+
55
+ def test_message_like_content_is_unwrapped():
56
+ assert extract_text_content(FakeMessage([{"type": "text", "text": "wrapped"}])) == "wrapped"
57
+
58
+
59
+ def test_unknown_dict_is_preserved_as_string():
60
+ result = extract_text_content({"status": "ok", "count": 2})
61
+
62
+ assert "status" in result
63
+ assert "2" in result
64
+
65
+
66
+ def test_unknown_block_list_does_not_silently_disappear():
67
+ result = extract_text_content([{"request_id": "abc", "message": "hello"}])
68
+
69
+ assert "request_id" in result
70
+ assert "hello" in result
71
+
72
+
73
+ def test_image_blocks_are_dropped():
74
+ content = [{"type": "text", "text": "screenshot:"}, {"type": "image", "source": {}}]
75
+
76
+ assert extract_text_content(content) == "screenshot:"
77
+
78
+
79
+ def test_normalize_tool_output_truncates_large_payloads():
80
+ result = normalize_tool_output("x" * 20, max_chars=10)
81
+
82
+ assert result.startswith("x" * 10)
83
+ assert "truncated 10 chars" in result
@@ -0,0 +1,60 @@
1
+ from __future__ import annotations
2
+
3
+ from lc_content_normalizer import (
4
+ VISION_FORMAT_ANTHROPIC_NATIVE,
5
+ VISION_FORMAT_NONE,
6
+ VISION_FORMAT_OPENAI,
7
+ build_human_message_content,
8
+ detect_vision_format,
9
+ image_block_for_format,
10
+ )
11
+
12
+ PNG = {"data_url": "data:image/png;base64,abc123", "mime_type": "image/png"}
13
+
14
+
15
+ def test_detect_vision_format_anthropic_native():
16
+ assert detect_vision_format("anthropic", "claude-3-5-sonnet") == VISION_FORMAT_ANTHROPIC_NATIVE
17
+
18
+
19
+ def test_detect_vision_format_ollama_vision_models_use_openai_blocks():
20
+ assert detect_vision_format("ollama", "llava:13b") == VISION_FORMAT_OPENAI
21
+ assert detect_vision_format("ollama", "model-with-vision") == VISION_FORMAT_OPENAI
22
+
23
+
24
+ def test_detect_vision_format_ollama_text_only():
25
+ assert detect_vision_format("ollama", "llama3") == VISION_FORMAT_NONE
26
+
27
+
28
+ def test_detect_vision_format_openai_compatible_default():
29
+ assert detect_vision_format("openrouter", "gpt-4o-mini") == VISION_FORMAT_OPENAI
30
+
31
+
32
+ def test_image_block_for_openai_format():
33
+ assert image_block_for_format(PNG, VISION_FORMAT_OPENAI) == {
34
+ "type": "image_url",
35
+ "image_url": {"url": "data:image/png;base64,abc123"},
36
+ }
37
+
38
+
39
+ def test_image_block_for_anthropic_native_format():
40
+ block = image_block_for_format(PNG, VISION_FORMAT_ANTHROPIC_NATIVE)
41
+
42
+ assert block["type"] == "image"
43
+ assert block["source"] == {"type": "base64", "media_type": "image/png", "data": "abc123"}
44
+
45
+
46
+ def test_build_human_message_content_returns_text_without_images():
47
+ assert build_human_message_content("hello", []) == "hello"
48
+
49
+
50
+ def test_build_human_message_content_drops_images_when_no_vision():
51
+ assert build_human_message_content("hello", [PNG], VISION_FORMAT_NONE) == "hello"
52
+
53
+
54
+ def test_build_human_message_content_returns_multimodal_blocks():
55
+ content = build_human_message_content("hello", [PNG], VISION_FORMAT_OPENAI)
56
+
57
+ assert content == [
58
+ {"type": "text", "text": "hello"},
59
+ {"type": "image_url", "image_url": {"url": "data:image/png;base64,abc123"}},
60
+ ]
@@ -0,0 +1,108 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.11"
4
+
5
+ [[package]]
6
+ name = "colorama"
7
+ version = "0.4.6"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
10
+ wheels = [
11
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
12
+ ]
13
+
14
+ [[package]]
15
+ name = "iniconfig"
16
+ version = "2.3.0"
17
+ source = { registry = "https://pypi.org/simple" }
18
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
19
+ wheels = [
20
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
21
+ ]
22
+
23
+ [[package]]
24
+ name = "langchain-content-normalizer"
25
+ version = "0.1.0"
26
+ source = { editable = "." }
27
+
28
+ [package.dev-dependencies]
29
+ dev = [
30
+ { name = "pytest" },
31
+ { name = "ruff" },
32
+ ]
33
+
34
+ [package.metadata]
35
+
36
+ [package.metadata.requires-dev]
37
+ dev = [
38
+ { name = "pytest", specifier = ">=8.0" },
39
+ { name = "ruff", specifier = ">=0.8" },
40
+ ]
41
+
42
+ [[package]]
43
+ name = "packaging"
44
+ version = "26.2"
45
+ source = { registry = "https://pypi.org/simple" }
46
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
47
+ wheels = [
48
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
49
+ ]
50
+
51
+ [[package]]
52
+ name = "pluggy"
53
+ version = "1.6.0"
54
+ source = { registry = "https://pypi.org/simple" }
55
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
56
+ wheels = [
57
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
58
+ ]
59
+
60
+ [[package]]
61
+ name = "pygments"
62
+ version = "2.20.0"
63
+ source = { registry = "https://pypi.org/simple" }
64
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
65
+ wheels = [
66
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
67
+ ]
68
+
69
+ [[package]]
70
+ name = "pytest"
71
+ version = "9.0.3"
72
+ source = { registry = "https://pypi.org/simple" }
73
+ dependencies = [
74
+ { name = "colorama", marker = "sys_platform == 'win32'" },
75
+ { name = "iniconfig" },
76
+ { name = "packaging" },
77
+ { name = "pluggy" },
78
+ { name = "pygments" },
79
+ ]
80
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
81
+ wheels = [
82
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
83
+ ]
84
+
85
+ [[package]]
86
+ name = "ruff"
87
+ version = "0.15.15"
88
+ source = { registry = "https://pypi.org/simple" }
89
+ sdist = { url = "https://files.pythonhosted.org/packages/84/6f/a76f7d96e5c962f5b69cee865e49c15c1116897c01990faa8a57edb62e7f/ruff-0.15.15.tar.gz", hash = "sha256:b8dff018130b46d8e5bf0f926ef6b60cf871d6d5ae45fc9334e09632daa741d6", size = 4706985, upload-time = "2026-05-28T14:16:57.784Z" }
90
+ wheels = [
91
+ { url = "https://files.pythonhosted.org/packages/fa/9d/3a45c05b8ab04b4705989de70a79008e27c8003296a0feaee9edc18dd7e9/ruff-0.15.15-py3-none-linux_armv6l.whl", hash = "sha256:cf93e5388f412e1b108b1f8b34a6e036b70fe8aff89393befad96fe48670311b", size = 10710652, upload-time = "2026-05-28T14:16:06.701Z" },
92
+ { url = "https://files.pythonhosted.org/packages/05/66/da974431624bf3b49f6ee1f9543c02d929ff1cba78b0d5a79c38cf21f744/ruff-0.15.15-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:ac5a646d1f6a7dadd5d50842dae2c1f9862ac887ef5d1b1375e02def791fde6e", size = 11096615, upload-time = "2026-05-28T14:16:23.313Z" },
93
+ { url = "https://files.pythonhosted.org/packages/8c/09/7443452e5d290230a712103f2fdceeef7184f3ec99a2bd01c8be78aaceb5/ruff-0.15.15-py3-none-macosx_11_0_arm64.whl", hash = "sha256:77d955a431430c66f72dd94e379ad38a16daea3d25094872ac4edf9e797be530", size = 10436683, upload-time = "2026-05-28T14:16:40.974Z" },
94
+ { url = "https://files.pythonhosted.org/packages/53/01/d330c26a57fa4f3943a14424904027428315b700fe4d14a84bb123a649e5/ruff-0.15.15-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7614ee79c69788cf6cedd568069ade9cecc22a1ad20494efe8d0c9ebb4b622d4", size = 10769064, upload-time = "2026-05-28T14:16:28.905Z" },
95
+ { url = "https://files.pythonhosted.org/packages/1d/85/cc8770f8bdff541b1da8392d1634141fe4a0e3f4ee596605959b7906c27f/ruff-0.15.15-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3cdb1679e06a1f6b47bc384714ae96f6e2fb65ca441eb78c43d2ca554176ce1f", size = 10511987, upload-time = "2026-05-28T14:16:43.732Z" },
96
+ { url = "https://files.pythonhosted.org/packages/7c/29/8c190c1472b63013583ba391f3342036e02010544c1270455ed8e519bdf3/ruff-0.15.15-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2728b93d7b23a603ea2c0ac6eb73d760bd38ec9de35f35fb41e18f7a3fee7622", size = 11275100, upload-time = "2026-05-28T14:16:55.244Z" },
97
+ { url = "https://files.pythonhosted.org/packages/9f/6b/7e145ce2cc8e63d6834eca03d83a0e18d121def5c69f91b4cf4011ed4879/ruff-0.15.15-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:be582fcc0db438902c7792b08d6ddf6c9b9e21addaa10092c2c741cfb09e5a45", size = 12176903, upload-time = "2026-05-28T14:16:14.368Z" },
98
+ { url = "https://files.pythonhosted.org/packages/80/a3/d5974637f68e451f7fadf015cf3101d1cd7d8ba5027cffe0b9e3826ebe6b/ruff-0.15.15-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7aa77465b8ecaf1a27bea098d696f7fed5e1eccbd10b321b682d6de586ae5627", size = 11404550, upload-time = "2026-05-28T14:16:20.138Z" },
99
+ { url = "https://files.pythonhosted.org/packages/fe/1c/e6e5e568f22be4fb05d6244234aba384c06b451252453b821e1a529263cf/ruff-0.15.15-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48decfa11d740de4889de623be1463308346312f2409a56e24aa280c86162dc4", size = 11382027, upload-time = "2026-05-28T14:16:46.615Z" },
100
+ { url = "https://files.pythonhosted.org/packages/1d/01/170921b49fcd2e8858825593f91cf7146c3e40a5c3e6df763e4bb0484dde/ruff-0.15.15-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a5015088452ca0081387063649ec67f06d3d1d6b8b936a1f836b5e9657ecd48c", size = 11366041, upload-time = "2026-05-28T14:16:26.247Z" },
101
+ { url = "https://files.pythonhosted.org/packages/87/54/a7bad711d7de93254e15e06a4c375b89a03d18de45d3e5dcc86a4472fb1a/ruff-0.15.15-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:f5294aab6356c81600fcdea3a62bb1b924dfd5e91767c12318d3f68f86af57cd", size = 10741795, upload-time = "2026-05-28T14:16:17.11Z" },
102
+ { url = "https://files.pythonhosted.org/packages/c9/31/38c075963668f8b41c6914ee0f6f318727fbe30ab9145cb29e6df464c5fa/ruff-0.15.15-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:db5bd4d802415cca656dc1616070b725952d6ae95eb5d4831e49fbd94a38f75f", size = 10511117, upload-time = "2026-05-28T14:16:31.767Z" },
103
+ { url = "https://files.pythonhosted.org/packages/9d/96/6ff689e1f7e375d1d97075eca022f74c2bab59554a432fe4d2e6f091986a/ruff-0.15.15-py3-none-musllinux_1_2_i686.whl", hash = "sha256:587a6278ed42059191c1a466e490bd7930fb50bd2e255398bc29616c895a61cb", size = 10994867, upload-time = "2026-05-28T14:16:35.149Z" },
104
+ { url = "https://files.pythonhosted.org/packages/c3/c2/5dce0ab9f92a8d534fa62b9bf9caca3eddb8c1a81b616f5e195ada4f0d6e/ruff-0.15.15-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:df0c1c084f5f4be9812f61518a45c440d3c30d69ce4bf6c5270e66d38338f02a", size = 11482101, upload-time = "2026-05-28T14:16:49.598Z" },
105
+ { url = "https://files.pythonhosted.org/packages/b1/c0/1003b60edd697c649faf61f1a34094b1abb38fb3d1181e3f895781250a08/ruff-0.15.15-py3-none-win32.whl", hash = "sha256:29428ea79694afbe756d45fd59b36f22b6b020dc0443cf7de0173046236964b9", size = 10716774, upload-time = "2026-05-28T14:16:52.337Z" },
106
+ { url = "https://files.pythonhosted.org/packages/02/a8/1269eddd6945a06c23f055ef7848886e37cf9d6a8bebb386a3115f01470c/ruff-0.15.15-py3-none-win_amd64.whl", hash = "sha256:8df0323902e15e24bc4bf246da830573d3cf3352bd0b9a164eab335d111ff4a4", size = 11868463, upload-time = "2026-05-28T14:16:11.333Z" },
107
+ { url = "https://files.pythonhosted.org/packages/4e/b2/920464c907b191e37469d477a1aa8bc048b8f36c4c1610dfa4ab87b39e18/ruff-0.15.15-py3-none-win_arm64.whl", hash = "sha256:3c8ceca6792f38196b8f589bc92eccd03eef286602da92e5dc05cc42ef6441b7", size = 11138498, upload-time = "2026-05-28T14:16:38.425Z" },
108
+ ]