monty-haystack 1.0.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.
@@ -0,0 +1,146 @@
1
+ # Byte-compiled / optimized / DLL files
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+
6
+ # C extensions
7
+ *.so
8
+
9
+ # Distribution / packaging
10
+ .Python
11
+ build/
12
+ develop-eggs/
13
+ dist/
14
+ downloads/
15
+ eggs/
16
+ .eggs/
17
+ lib/
18
+ lib64/
19
+ parts/
20
+ sdist/
21
+ var/
22
+ wheels/
23
+ pip-wheel-metadata/
24
+ share/python-wheels/
25
+ *.egg-info/
26
+ .installed.cfg
27
+ *.egg
28
+ MANIFEST
29
+
30
+ # PyInstaller
31
+ # Usually these files are written by a python script from a template
32
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
33
+ *.manifest
34
+ *.spec
35
+
36
+ # Installer logs
37
+ pip-log.txt
38
+ pip-delete-this-directory.txt
39
+
40
+ # Unit test / coverage reports
41
+ htmlcov/
42
+ .tox/
43
+ .nox/
44
+ .coverage
45
+ .coverage.*
46
+ .cache
47
+ nosetests.xml
48
+ coverage.xml
49
+ *.cover
50
+ *.py,cover
51
+ .hypothesis/
52
+ .pytest_cache/
53
+ volumes/
54
+
55
+ # Translations
56
+ *.mo
57
+ *.pot
58
+
59
+ # Django stuff:
60
+ *.log
61
+ local_settings.py
62
+ db.sqlite3
63
+ db.sqlite3-journal
64
+
65
+ # Flask stuff:
66
+ instance/
67
+ .webassets-cache
68
+
69
+ # Scrapy stuff:
70
+ .scrapy
71
+
72
+ # Sphinx documentation
73
+ docs/_build/
74
+
75
+ # PyBuilder
76
+ target/
77
+
78
+ # Jupyter Notebook
79
+ .ipynb_checkpoints
80
+
81
+ # IPython
82
+ profile_default/
83
+ ipython_config.py
84
+
85
+ # pyenv
86
+ .python-version
87
+
88
+ # pipenv
89
+ # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
90
+ # However, in case of collaboration, if having platform-specific dependencies or dependencies
91
+ # having no cross-platform support, pipenv may install dependencies that don't work, or not
92
+ # install all needed dependencies.
93
+ #Pipfile.lock
94
+
95
+ # PEP 582; used by e.g. github.com/David-OConnor/pyflow
96
+ __pypackages__/
97
+
98
+ # Celery stuff
99
+ celerybeat-schedule
100
+ celerybeat.pid
101
+
102
+ # SageMath parsed files
103
+ *.sage.py
104
+
105
+ # Environments
106
+ .env
107
+ .venv
108
+ env/
109
+ venv/
110
+ ENV/
111
+ env.bak/
112
+ venv.bak/
113
+
114
+ # Spyder project settings
115
+ .spyderproject
116
+ .spyproject
117
+
118
+ # Rope project settings
119
+ .ropeproject
120
+
121
+ # mkdocs documentation
122
+ /site
123
+
124
+ # mypy
125
+ .mypy_cache/
126
+ .dmypy.json
127
+ dmypy.json
128
+
129
+ # Pyre type checker
130
+ .pyre/
131
+
132
+ # IDEs
133
+ .vscode
134
+
135
+ # Docs generation artifacts
136
+ _readme_*.md
137
+ .idea
138
+
139
+ # macOS
140
+ .DS_Store
141
+
142
+ # http cache (requests-cache)
143
+ **/http_cache.sqlite
144
+
145
+ # ruff
146
+ .ruff_cache
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2023-present deepset GmbH
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
@@ -0,0 +1,38 @@
1
+ Metadata-Version: 2.5
2
+ Name: monty-haystack
3
+ Version: 1.0.0
4
+ Summary: Sandboxed Python code execution tool for Haystack Agents, powered by Pydantic Monty
5
+ Project-URL: Documentation, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/monty#readme
6
+ Project-URL: Issues, https://github.com/deepset-ai/haystack-core-integrations/issues
7
+ Project-URL: Source, https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/monty
8
+ Author-email: deepset GmbH <info@deepset.ai>
9
+ License-Expression: Apache-2.0
10
+ License-File: LICENSE.txt
11
+ Keywords: agents,code-execution,haystack,monty,pydantic,sandbox,tools
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Programming Language :: Python :: 3.14
20
+ Classifier: Programming Language :: Python :: Implementation :: CPython
21
+ Requires-Python: >=3.10
22
+ Requires-Dist: haystack-ai>=3.0.0
23
+ Requires-Dist: pydantic-monty>=1.0.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # monty-haystack
27
+
28
+ [![PyPI - Version](https://img.shields.io/pypi/v/monty-haystack.svg)](https://pypi.org/project/monty-haystack)
29
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/monty-haystack.svg)](https://pypi.org/project/monty-haystack)
30
+
31
+ - [Integration page](https://haystack.deepset.ai/integrations/monty)
32
+ - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/monty/CHANGELOG.md)
33
+
34
+ ---
35
+
36
+ ## Contributing
37
+
38
+ Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
@@ -0,0 +1,13 @@
1
+ # monty-haystack
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/monty-haystack.svg)](https://pypi.org/project/monty-haystack)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/monty-haystack.svg)](https://pypi.org/project/monty-haystack)
5
+
6
+ - [Integration page](https://haystack.deepset.ai/integrations/monty)
7
+ - [Changelog](https://github.com/deepset-ai/haystack-core-integrations/blob/main/integrations/monty/CHANGELOG.md)
8
+
9
+ ---
10
+
11
+ ## Contributing
12
+
13
+ Refer to the general [Contribution Guidelines](https://github.com/deepset-ai/haystack-core-integrations/blob/main/CONTRIBUTING.md).
@@ -0,0 +1,13 @@
1
+ loaders:
2
+ - modules:
3
+ - haystack_integrations.tools.monty.python_tool
4
+ search_path: [../src]
5
+ processors:
6
+ - type: filter
7
+ documented_only: true
8
+ skip_empty_modules: true
9
+ renderer:
10
+ description: Monty integration for Haystack
11
+ id: integrations-monty
12
+ filename: monty.md
13
+ title: Monty
@@ -0,0 +1,161 @@
1
+ [build-system]
2
+ requires = ["hatchling", "hatch-vcs"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "monty-haystack"
7
+ dynamic = ["version"]
8
+ description = "Sandboxed Python code execution tool for Haystack Agents, powered by Pydantic Monty"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "Apache-2.0"
12
+ keywords = ["haystack", "monty", "pydantic", "sandbox", "code-execution", "agents", "tools"]
13
+ authors = [{ name = "deepset GmbH", email = "info@deepset.ai" }]
14
+ classifiers = [
15
+ "License :: OSI Approved :: Apache Software License",
16
+ "Development Status :: 4 - Beta",
17
+ "Programming Language :: Python",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ "Programming Language :: Python :: Implementation :: CPython",
24
+ ]
25
+ dependencies = ["haystack-ai>=3.0.0", "pydantic-monty>=1.0.0"]
26
+
27
+ [project.urls]
28
+ Documentation = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/monty#readme"
29
+ Issues = "https://github.com/deepset-ai/haystack-core-integrations/issues"
30
+ Source = "https://github.com/deepset-ai/haystack-core-integrations/tree/main/integrations/monty"
31
+
32
+ [tool.hatch.build.targets.wheel]
33
+ packages = ["src/haystack_integrations"]
34
+
35
+ [tool.hatch.version]
36
+ source = "vcs"
37
+ tag-pattern = 'integrations\/monty-v(?P<version>.*)'
38
+
39
+ [tool.hatch.version.raw-options]
40
+ root = "../.."
41
+ git_describe_command = 'git describe --tags --match="integrations/monty-v[0-9]*"'
42
+
43
+ [tool.hatch.envs.default]
44
+ installer = "uv"
45
+ dependencies = ["haystack-pydoc-tools", "ruff"]
46
+
47
+ [tool.hatch.envs.default.scripts]
48
+ docs = ["haystack-pydoc pydoc/config_docusaurus.yml"]
49
+ fmt = "ruff check --fix {args}; ruff format {args}"
50
+ fmt-check = "ruff check {args} && ruff format --check {args}"
51
+
52
+ [tool.hatch.envs.test]
53
+ dependencies = [
54
+ "pytest",
55
+ "pytest-asyncio",
56
+ "pytest-cov",
57
+ "pytest-rerunfailures",
58
+ "mypy",
59
+ "pip",
60
+ ]
61
+
62
+ [tool.hatch.envs.test.scripts]
63
+ unit = 'pytest -m "not integration" {args:tests}'
64
+ integration = 'pytest -m "integration" {args:tests}'
65
+ all = 'pytest {args:tests}'
66
+ unit-cov-retry = 'pytest --cov=haystack_integrations --reruns 3 --reruns-delay 30 -x -m "not integration" {args:tests}'
67
+ integration-cov-append-retry = 'pytest --cov=haystack_integrations --cov-append --reruns 3 --reruns-delay 30 -x -m "integration" {args:tests}'
68
+ types = "mypy -p haystack_integrations.tools.monty {args}"
69
+
70
+ [tool.mypy]
71
+ install_types = true
72
+ non_interactive = true
73
+ check_untyped_defs = true
74
+ disallow_incomplete_defs = true
75
+
76
+ [tool.ruff]
77
+ line-length = 120
78
+
79
+ [tool.ruff.lint]
80
+ select = [
81
+ "A",
82
+ "ANN",
83
+ "ARG",
84
+ "B",
85
+ "C",
86
+ "D102", # Missing docstring in public method
87
+ "D103", # Missing docstring in public function
88
+ "D205", # 1 blank line required between summary line and description
89
+ "D209", # Closing triple quotes go to new line
90
+ "D213", # summary lines must be positioned on the second physical line of the docstring
91
+ "D417", # Missing argument descriptions in the docstring
92
+ "D419", # Docstring is empty
93
+ "DTZ",
94
+ "E",
95
+ "EM",
96
+ "F",
97
+ "I",
98
+ "ICN",
99
+ "ISC",
100
+ "N",
101
+ "PLC",
102
+ "PLE",
103
+ "PLR",
104
+ "PLW",
105
+ "Q",
106
+ "RUF",
107
+ "S",
108
+ "T",
109
+ "TID",
110
+ "UP",
111
+ "W",
112
+ "YTT",
113
+ ]
114
+ ignore = [
115
+ # Allow non-abstract empty methods in abstract base classes
116
+ "B027",
117
+ # Allow function calls in argument defaults (common Haystack pattern for Secret.from_env_var)
118
+ "B008",
119
+ # Ignore checks for possible passwords
120
+ "S105",
121
+ "S106",
122
+ "S107",
123
+ # Ignore complexity
124
+ "C901",
125
+ "PLR0911",
126
+ "PLR0912",
127
+ "PLR0913",
128
+ "PLR0915",
129
+ "PLR0917",
130
+ # Allow `Any` type - used legitimately for dynamic types and SDK boundaries
131
+ "ANN401",
132
+ ]
133
+
134
+ [tool.ruff.lint.isort]
135
+ known-first-party = ["haystack_integrations"]
136
+
137
+ [tool.ruff.lint.flake8-tidy-imports]
138
+ ban-relative-imports = "parents"
139
+
140
+ [tool.ruff.lint.per-file-ignores]
141
+ # Tests can use magic values, assertions, relative imports, and don't need type annotations
142
+ "tests/**/*" = ["PLR2004", "S101", "TID252", "D", "ANN"]
143
+
144
+ [tool.coverage.run]
145
+ source = ["haystack_integrations"]
146
+ branch = true
147
+ parallel = false
148
+ relative_files = true
149
+
150
+ [tool.coverage.report]
151
+ omit = ["*/tests/*", "*/__init__.py"]
152
+ show_missing = true
153
+ exclude_lines = ["no cov", "if __name__ == .__main__.:", "if TYPE_CHECKING:"]
154
+
155
+ [tool.pytest.ini_options]
156
+ addopts = "--strict-markers"
157
+ markers = [
158
+ "integration: integration tests",
159
+ ]
160
+ log_cli = true
161
+ asyncio_default_fixture_loop_scope = "function"
@@ -0,0 +1,7 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from haystack_integrations.tools.monty.python_tool import MontyPythonTool
6
+
7
+ __all__ = ["MontyPythonTool"]
@@ -0,0 +1,223 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ import threading
6
+ from typing import Any
7
+
8
+ from haystack.core.serialization import generate_qualified_class_name
9
+ from haystack.tools import Tool
10
+ from pydantic_monty import (
11
+ CollectString,
12
+ Monty,
13
+ MontyCrashedError,
14
+ MontyRuntimeError,
15
+ MontySyntaxError,
16
+ MontyTypingError,
17
+ ResourceLimits,
18
+ )
19
+
20
+ _DEFAULT_RESOURCE_LIMITS: ResourceLimits = {"max_feed_duration_secs": 30.0, "max_memory": 256 * 1024 * 1024}
21
+
22
+ _DEFAULT_DESCRIPTION = (
23
+ "Run Python code in an isolated sandbox and get back what it printed and the value of its last expression. "
24
+ "Use it for calculations, data processing, and anything else that is more reliable to compute than to guess.\n"
25
+ "- Every call starts a fresh interpreter: variables, functions, and imports from earlier calls are gone, so each "
26
+ "snippet must be self-contained.\n"
27
+ "- The sandbox has no file system, network, environment variables, or subprocesses, and no third-party packages. "
28
+ "Only these standard library modules can be imported, some of them partially: asyncio, base64, binascii, "
29
+ "collections, copy, dataclasses, datetime, functools, itertools, json, math, random, re, sys, time, typing, "
30
+ "unicodedata.\n"
31
+ "- The interpreter supports a subset of Python: functions, lambdas, closures, comprehensions, simple classes, "
32
+ "dataclasses, try/except, f-strings, and async/await. Class inheritance (including custom exception classes), "
33
+ "generators (`yield`), `match`, `del`, and method decorators such as `@property` or `@staticmethod` are not "
34
+ "supported.\n"
35
+ "- Use `print()` for intermediate output. Errors come back as a traceback, so you can fix the code and retry."
36
+ )
37
+
38
+
39
+ def _truncate(text: str, max_chars: int, *, keep_tail: bool = False) -> str:
40
+ if len(text) <= max_chars:
41
+ return text
42
+ note = f"[... {len(text) - max_chars} characters truncated]"
43
+ if keep_tail:
44
+ return f"{note}\n{text[-max_chars:]}"
45
+ return f"{text[:max_chars]}\n{note}"
46
+
47
+
48
+ def _format_output(*, output: str, result: Any, error: str | None, max_chars: int) -> str:
49
+ """Render the printed output, the final expression value, and any error as text for the LLM."""
50
+ sections = []
51
+ if output:
52
+ sections.append(f"output:\n{_truncate(output.rstrip(), max_chars)}")
53
+ if result is not None:
54
+ sections.append(f"result:\n{_truncate(repr(result), max_chars)}")
55
+ if error is not None:
56
+ # The exception type and message are on the last line of a traceback, so keep the tail
57
+ sections.append(f"error:\n{_truncate(error, max_chars, keep_tail=True)}")
58
+ return "\n\n".join(sections) or "The code ran successfully and produced no output."
59
+
60
+
61
+ class MontyPythonTool(Tool):
62
+ """
63
+ A Haystack `Tool` that lets an `Agent` run Python code in a [Monty](https://pydantic.dev/docs/monty/) sandbox.
64
+
65
+ Monty is a minimal Python interpreter written in Rust. The tool keeps a pool of Monty worker processes and runs
66
+ every call in a fresh interpreter, so nothing leaks between calls, users, or concurrent tool invocations. The
67
+ tool returns what the code printed, the `repr()` of its last expression, and any error as a traceback, so the
68
+ LLM can read the result and fix its code.
69
+
70
+ ### Security model
71
+
72
+ Monty is a language-level sandbox: its interpreter implements no operation that reaches the host, so code has
73
+ no access to files, the network, environment variables, or subprocesses. It runs in worker subprocesses started
74
+ with an empty environment, so a crash never takes down the host process. The tool mounts no directories and
75
+ exposes no host functions to the sandbox. Execution time and heap memory are capped by `resource_limits`.
76
+
77
+ ### Usage example
78
+
79
+ ```python
80
+ from haystack.components.agents import Agent
81
+ from haystack.components.generators.chat import OpenAIChatGenerator
82
+ from haystack.dataclasses import ChatMessage
83
+ from haystack_integrations.tools.monty import MontyPythonTool
84
+
85
+ # Requires the OPENAI_API_KEY environment variable
86
+ agent = Agent(chat_generator=OpenAIChatGenerator(), tools=[MontyPythonTool()])
87
+ result = agent.run(messages=[ChatMessage.from_user("What is the sum of the first 100 prime numbers?")])
88
+ print(result["last_message"].text)
89
+ # >> The sum of the first 100 prime numbers is 24133.
90
+ ```
91
+ """
92
+
93
+ def __init__(
94
+ self,
95
+ *,
96
+ name: str = "run_python",
97
+ description: str | None = None,
98
+ resource_limits: ResourceLimits | None = None,
99
+ type_check: bool = False,
100
+ max_output_chars: int = 20_000,
101
+ ) -> None:
102
+ """
103
+ Create a MontyPythonTool.
104
+
105
+ :param name: Tool name exposed to the LLM.
106
+ :param description: Tool description exposed to the LLM. If `None`, a description of the sandbox and the
107
+ supported Python subset is used.
108
+ :param resource_limits: Monty resource limits for each call, merged over the defaults of 30 seconds of
109
+ execution time (`max_feed_duration_secs`) and 256 MiB of heap memory (`max_memory`). Set a key to `None`
110
+ to disable that limit. See `pydantic_monty.ResourceLimits` for the available keys.
111
+ :param type_check: If `True`, type-check the code with Monty's bundled type checker before running it, and
112
+ return type errors to the LLM instead of executing the code.
113
+ :param max_output_chars: Maximum number of characters kept from each of the printed output, the result, and
114
+ the error before they are returned to the LLM. The output and the result keep their beginning, the error
115
+ keeps its end, where the exception is.
116
+ :raises ValueError: If `resource_limits` contains a key that Monty doesn't support, or `max_output_chars` is
117
+ less than 1.
118
+ """
119
+ unknown_limits = set(resource_limits or {}) - set(ResourceLimits.__annotations__)
120
+ if unknown_limits:
121
+ msg = (
122
+ f"Unknown resource limits: {sorted(unknown_limits)}. "
123
+ f"Supported keys are: {sorted(ResourceLimits.__annotations__)}."
124
+ )
125
+ raise ValueError(msg)
126
+ if max_output_chars < 1:
127
+ msg = f"max_output_chars must be at least 1, got {max_output_chars}."
128
+ raise ValueError(msg)
129
+
130
+ self._resource_limits: ResourceLimits = {**_DEFAULT_RESOURCE_LIMITS}
131
+ self._resource_limits.update(resource_limits or {})
132
+ self._type_check = type_check
133
+ self._max_output_chars = max_output_chars
134
+ self._pool: Monty | None = None
135
+ self._pool_lock = threading.Lock()
136
+
137
+ parameters = {
138
+ "type": "object",
139
+ "properties": {
140
+ "code": {
141
+ "type": "string",
142
+ "description": "The Python code to run. The value of the last expression is returned.",
143
+ }
144
+ },
145
+ "required": ["code"],
146
+ }
147
+ super().__init__(
148
+ name=name,
149
+ description=description or _DEFAULT_DESCRIPTION,
150
+ parameters=parameters,
151
+ function=self._invoke,
152
+ )
153
+
154
+ def warm_up(self) -> None:
155
+ """Start the pool of Monty worker processes. Called by `Agent.warm_up()`; safe to call more than once."""
156
+ self._get_pool()
157
+
158
+ def close(self) -> None:
159
+ """
160
+ Shut down the pool of Monty worker processes.
161
+
162
+ Safe to call more than once. The tool starts a new pool if it is invoked again.
163
+ """
164
+ with self._pool_lock:
165
+ if self._pool is not None:
166
+ self._pool.__exit__(None, None, None)
167
+ self._pool = None
168
+
169
+ def _get_pool(self) -> Monty:
170
+ with self._pool_lock:
171
+ if self._pool is None:
172
+ pool = Monty()
173
+ # Monty only exposes its pool lifecycle as a context manager. The tool keeps the pool open across
174
+ # calls, so it enters the context here and exits it in `close()`.
175
+ pool.__enter__()
176
+ self._pool = pool
177
+ return self._pool
178
+
179
+ def _invoke(self, code: str) -> str:
180
+ pool = self._get_pool()
181
+ collector = CollectString()
182
+ result = None
183
+ error = None
184
+ try:
185
+ with pool.checkout(limits=self._resource_limits, type_check=self._type_check) as session:
186
+ result = session.feed_run(code, print_callback=collector)
187
+ except (MontySyntaxError, MontyRuntimeError, MontyTypingError) as e:
188
+ error = e.display()
189
+ except MontyCrashedError as e:
190
+ # Code stuck inside a long-running builtin, such as a huge integer power, can't be interrupted by the
191
+ # sandbox's own time limit; the pool then kills the worker and replaces it.
192
+ if e.timed_out:
193
+ error = f"TimeoutError: the code exceeded the time limit and the sandbox was stopped ({e})"
194
+ else:
195
+ error = f"The sandbox process crashed and was restarted ({e})"
196
+ return _format_output(output=collector.output, result=result, error=error, max_chars=self._max_output_chars)
197
+
198
+ def to_dict(self) -> dict[str, Any]:
199
+ """
200
+ Serialize the tool to a dictionary.
201
+
202
+ :returns: Dictionary with serialized data.
203
+ """
204
+ return {
205
+ "type": generate_qualified_class_name(type(self)),
206
+ "data": {
207
+ "name": self.name,
208
+ "description": self.description,
209
+ "resource_limits": self._resource_limits,
210
+ "type_check": self._type_check,
211
+ "max_output_chars": self._max_output_chars,
212
+ },
213
+ }
214
+
215
+ @classmethod
216
+ def from_dict(cls, data: dict[str, Any]) -> "MontyPythonTool":
217
+ """
218
+ Deserialize the tool from a dictionary.
219
+
220
+ :param data: Dictionary to deserialize from.
221
+ :returns: Deserialized tool.
222
+ """
223
+ return cls(**data["data"])
@@ -0,0 +1,3 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
@@ -0,0 +1,310 @@
1
+ # SPDX-FileCopyrightText: 2022-present deepset GmbH <info@deepset.ai>
2
+ #
3
+ # SPDX-License-Identifier: Apache-2.0
4
+
5
+ from concurrent.futures import ThreadPoolExecutor
6
+ from typing import Any
7
+
8
+ import pytest
9
+ from haystack import component
10
+ from haystack.components.agents import Agent
11
+ from haystack.components.generators.chat import OpenAIChatGenerator
12
+ from haystack.dataclasses import ChatMessage, ToolCall
13
+ from haystack.tools import Tool
14
+
15
+ from haystack_integrations.tools.monty import MontyPythonTool
16
+ from haystack_integrations.tools.monty.python_tool import _DEFAULT_DESCRIPTION
17
+
18
+
19
+ @pytest.fixture
20
+ def tool():
21
+ tool = MontyPythonTool()
22
+ yield tool
23
+ tool.close()
24
+
25
+
26
+ @component
27
+ class _ScriptedChatGenerator:
28
+ """Asks for one `run_python` call, then answers with the tool result it received."""
29
+
30
+ def __init__(self, code: str) -> None:
31
+ self.code = code
32
+
33
+ @component.output_types(replies=list[ChatMessage])
34
+ def run(self, messages: list[ChatMessage], tools: Any = None) -> dict[str, Any]: # noqa: ARG002
35
+ tool_result = messages[-1].tool_call_result
36
+ if tool_result is not None:
37
+ return {"replies": [ChatMessage.from_assistant(f"The sandbox said: {tool_result.result}")]}
38
+ tool_call = ToolCall(tool_name="run_python", arguments={"code": self.code})
39
+ return {"replies": [ChatMessage.from_assistant(tool_calls=[tool_call])]}
40
+
41
+
42
+ class TestInit:
43
+ def test_defaults(self):
44
+ tool = MontyPythonTool()
45
+
46
+ assert isinstance(tool, Tool)
47
+ assert tool.name == "run_python"
48
+ assert tool.description == _DEFAULT_DESCRIPTION
49
+ assert "fresh interpreter" in tool.description
50
+ assert "no file system, network" in tool.description
51
+ assert tool.parameters["properties"]["code"]["type"] == "string"
52
+ assert tool.parameters["required"] == ["code"]
53
+ assert tool.to_dict()["data"]["resource_limits"] == {
54
+ "max_feed_duration_secs": 30.0,
55
+ "max_memory": 256 * 1024 * 1024,
56
+ }
57
+
58
+ def test_custom_name_and_description(self):
59
+ tool = MontyPythonTool(name="python", description="Run some Python.")
60
+
61
+ assert tool.name == "python"
62
+ assert tool.description == "Run some Python."
63
+
64
+ def test_resource_limits_merged_over_defaults(self):
65
+ tool = MontyPythonTool(resource_limits={"max_memory": None, "max_recursion_depth": 200})
66
+
67
+ assert tool.to_dict()["data"]["resource_limits"] == {
68
+ "max_feed_duration_secs": 30.0,
69
+ "max_memory": None,
70
+ "max_recursion_depth": 200,
71
+ }
72
+
73
+ def test_unknown_resource_limit_raises(self):
74
+ with pytest.raises(ValueError, match="Unknown resource limits: \\['max_duration_secs'\\]"):
75
+ MontyPythonTool(resource_limits={"max_duration_secs": 5.0})
76
+
77
+ def test_invalid_max_output_chars_raises(self):
78
+ with pytest.raises(ValueError, match="max_output_chars must be at least 1"):
79
+ MontyPythonTool(max_output_chars=0)
80
+
81
+
82
+ class TestInvoke:
83
+ @pytest.mark.parametrize(
84
+ ("code", "expected"),
85
+ [
86
+ ("1 + 2", "result:\n3"),
87
+ ("print('hello')", "output:\nhello"),
88
+ ("x = [i * i for i in range(4)]\nprint(len(x))\nx", "output:\n4\n\nresult:\n[0, 1, 4, 9]"),
89
+ ("'text'", "result:\n'text'"),
90
+ ("x = 1", "The code ran successfully and produced no output."),
91
+ ],
92
+ )
93
+ def test_success(self, tool, code, expected):
94
+ assert tool.invoke(code=code) == expected
95
+
96
+ def test_syntax_error(self, tool):
97
+ output = tool.invoke(code="def")
98
+
99
+ assert output.startswith("error:\nTraceback (most recent call last):")
100
+ assert "SyntaxError" in output
101
+
102
+ def test_runtime_error_keeps_output_before_it(self, tool):
103
+ output = tool.invoke(code="print('before')\n1 / 0")
104
+
105
+ assert output.startswith("output:\nbefore\n\nerror:\nTraceback (most recent call last):")
106
+ assert output.endswith("ZeroDivisionError: division by zero")
107
+
108
+ @pytest.mark.parametrize(
109
+ ("code", "expected_error"),
110
+ [
111
+ ("import socket", "ModuleNotFoundError: No module named 'socket'"),
112
+ ("open('/etc/passwd').read()", "PermissionError"),
113
+ ("import os\nos.getenv('HOME')", "'os.getenv' is not supported"),
114
+ ],
115
+ )
116
+ def test_no_host_access(self, tool, code, expected_error):
117
+ assert expected_error in tool.invoke(code=code)
118
+
119
+ def test_time_limit(self):
120
+ tool = MontyPythonTool(resource_limits={"max_feed_duration_secs": 0.5})
121
+
122
+ output = tool.invoke(code="while True:\n pass")
123
+ tool.close()
124
+
125
+ assert output.startswith("error:\nTimeoutError: feed time limit exceeded")
126
+
127
+ def test_time_limit_inside_builtin_stops_worker(self):
128
+ # The sandbox can't interrupt a huge integer power, so the pool kills and replaces the worker instead
129
+ tool = MontyPythonTool(resource_limits={"max_feed_duration_secs": 0.2})
130
+
131
+ output = tool.invoke(code="7 ** 50_000_000")
132
+ follow_up = tool.invoke(code="1 + 1")
133
+ tool.close()
134
+
135
+ assert output.startswith("error:\nTimeoutError: the code exceeded the time limit and the sandbox was stopped")
136
+ assert follow_up == "result:\n2"
137
+
138
+ def test_memory_limit(self):
139
+ tool = MontyPythonTool(resource_limits={"max_memory": 16 * 1024 * 1024})
140
+
141
+ output = tool.invoke(code="'a' * 32 * 1024 * 1024")
142
+ tool.close()
143
+
144
+ assert output.startswith("error:\nTraceback (most recent call last):")
145
+ assert "MemoryError: memory limit exceeded" in output
146
+
147
+ def test_type_check(self):
148
+ tool = MontyPythonTool(type_check=True)
149
+
150
+ output = tool.invoke(code="x: int = 'not an int'")
151
+ tool.close()
152
+
153
+ assert output.startswith("error:\n")
154
+ assert "invalid-assignment" in output
155
+
156
+ def test_no_state_between_calls(self, tool):
157
+ tool.invoke(code="leaked = 1")
158
+
159
+ assert "NameError: name 'leaked' is not defined" in tool.invoke(code="leaked")
160
+
161
+ def test_output_truncated(self):
162
+ tool = MontyPythonTool(max_output_chars=10)
163
+
164
+ output = tool.invoke(code="print('a' * 25)\n'b' * 25")
165
+ tool.close()
166
+
167
+ assert output == (
168
+ f"output:\n{'a' * 10}\n[... 15 characters truncated]\n\nresult:\n'{'b' * 9}\n[... 17 characters truncated]"
169
+ )
170
+
171
+ def test_error_truncated_keeps_tail(self):
172
+ tool = MontyPythonTool(max_output_chars=100)
173
+
174
+ # A deep call stack makes a long traceback that ends with the exception
175
+ output = tool.invoke(code="def f(n):\n return f(n - 1) if n else 1 / 0\nf(50)")
176
+ tool.close()
177
+
178
+ assert output.startswith("error:\n[... ")
179
+ assert "characters truncated]\n" in output
180
+ assert output.endswith("ZeroDivisionError: division by zero")
181
+ assert len(output.split("truncated]\n", 1)[1]) == 100
182
+
183
+ def test_concurrent_calls(self, tool):
184
+ with ThreadPoolExecutor(max_workers=4) as executor:
185
+ outputs = list(executor.map(lambda n: tool.invoke(code=f"sum(range({n}))"), range(10, 18)))
186
+
187
+ assert outputs == [f"result:\n{sum(range(n))}" for n in range(10, 18)]
188
+
189
+ @pytest.mark.asyncio
190
+ async def test_invoke_async(self, tool):
191
+ assert await tool.invoke_async(code="2 ** 10") == "result:\n1024"
192
+
193
+
194
+ class TestDefaultDescriptionMatchesMonty:
195
+ """
196
+ Pin the Python subset that `_DEFAULT_DESCRIPTION` tells the LLM about.
197
+
198
+ When a new Monty release supports more of Python, these tests fail in the nightly run, which is the signal to
199
+ update the description and the tests.
200
+ """
201
+
202
+ @pytest.mark.parametrize(
203
+ "code",
204
+ [
205
+ pytest.param("class A:\n pass\nclass B(A):\n pass", id="class inheritance"),
206
+ pytest.param("class MyError(Exception):\n pass", id="custom exception class"),
207
+ pytest.param("def gen():\n yield 1", id="generators"),
208
+ pytest.param("match 1:\n case 1:\n pass", id="match"),
209
+ pytest.param("x = 1\ndel x", id="del"),
210
+ pytest.param("class A:\n @property\n def x(self):\n return 1", id="property"),
211
+ pytest.param("class A:\n @staticmethod\n def x():\n return 1", id="staticmethod"),
212
+ ],
213
+ )
214
+ def test_unsupported_features(self, tool, code):
215
+ assert "NotImplementedError: The monty syntax parser does not yet support" in tool.invoke(code=code)
216
+
217
+ def test_listed_modules_are_importable(self, tool):
218
+ listed = _DEFAULT_DESCRIPTION.split("some of them partially: ", 1)[1].split(".\n", 1)[0].split(", ")
219
+
220
+ for module in listed:
221
+ assert tool.invoke(code=f"import {module}") == "The code ran successfully and produced no output."
222
+
223
+ # Commonly used standard library modules that Monty doesn't implement yet
224
+ @pytest.mark.parametrize("module", ["bisect", "decimal", "enum", "heapq", "operator", "statistics", "string"])
225
+ def test_unlisted_modules_are_not_importable(self, tool, module):
226
+ assert f"ModuleNotFoundError: No module named '{module}'" in tool.invoke(code=f"import {module}")
227
+
228
+
229
+ class TestLifecycle:
230
+ def test_warm_up_is_idempotent(self, tool):
231
+ tool.warm_up()
232
+ pool = tool._pool
233
+ tool.warm_up()
234
+
235
+ assert pool is not None
236
+ assert tool._pool is pool
237
+
238
+ def test_invoke_warms_up_lazily(self, tool):
239
+ assert tool._pool is None
240
+
241
+ assert tool.invoke(code="1") == "result:\n1"
242
+ assert tool._pool is not None
243
+
244
+ def test_close_is_idempotent_and_invoke_restarts_pool(self, tool):
245
+ tool.warm_up()
246
+ tool.close()
247
+ tool.close()
248
+
249
+ assert tool._pool is None
250
+ assert tool.invoke(code="1") == "result:\n1"
251
+
252
+
253
+ class TestSerialization:
254
+ def test_to_dict_defaults(self):
255
+ assert MontyPythonTool().to_dict() == {
256
+ "type": "haystack_integrations.tools.monty.python_tool.MontyPythonTool",
257
+ "data": {
258
+ "name": "run_python",
259
+ "description": _DEFAULT_DESCRIPTION,
260
+ "resource_limits": {"max_feed_duration_secs": 30.0, "max_memory": 256 * 1024 * 1024},
261
+ "type_check": False,
262
+ "max_output_chars": 20_000,
263
+ },
264
+ }
265
+
266
+ def test_round_trip_with_custom_params(self):
267
+ tool = MontyPythonTool(
268
+ name="python",
269
+ description="Run some Python.",
270
+ resource_limits={"max_feed_duration_secs": 5.0, "max_recursion_depth": 200},
271
+ type_check=True,
272
+ max_output_chars=500,
273
+ )
274
+
275
+ restored = MontyPythonTool.from_dict(tool.to_dict())
276
+
277
+ assert restored.to_dict() == tool.to_dict()
278
+ assert restored.name == "python"
279
+ assert restored.description == "Run some Python."
280
+ assert restored.to_dict()["data"]["resource_limits"] == {
281
+ "max_feed_duration_secs": 5.0,
282
+ "max_memory": 256 * 1024 * 1024,
283
+ "max_recursion_depth": 200,
284
+ }
285
+ assert restored.to_dict()["data"]["type_check"] is True
286
+ assert restored.to_dict()["data"]["max_output_chars"] == 500
287
+
288
+ def test_agent_round_trip(self, monkeypatch):
289
+ monkeypatch.setenv("OPENAI_API_KEY", "test-api-key")
290
+ agent = Agent(
291
+ chat_generator=OpenAIChatGenerator(),
292
+ tools=[MontyPythonTool(resource_limits={"max_feed_duration_secs": 5.0})],
293
+ )
294
+
295
+ restored = Agent.from_dict(agent.to_dict())
296
+
297
+ assert isinstance(restored.tools[0], MontyPythonTool)
298
+ assert restored.tools[0].to_dict() == agent.tools[0].to_dict()
299
+
300
+
301
+ def test_agent_runs_code(tool):
302
+ agent = Agent(chat_generator=_ScriptedChatGenerator(code="print(sum(range(101)))"), tools=[tool])
303
+ agent.warm_up()
304
+
305
+ result = agent.run(messages=[ChatMessage.from_user("What is the sum of the numbers from 0 to 100?")])
306
+
307
+ tool_message = result["messages"][-2]
308
+ assert tool_message.tool_call_result.result == "output:\n5050"
309
+ assert tool_message.tool_call_result.error is False
310
+ assert result["last_message"].text == "The sandbox said: output:\n5050"