toolfuncs 0.3.1__tar.gz → 0.3.3__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.
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/PKG-INFO +24 -2
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/README.md +23 -1
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/pyproject.toml +2 -1
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/__init__.py +4 -1
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/__main__.py +7 -0
- toolfuncs-0.3.3/src/toolfuncs/_import_path.py +258 -0
- toolfuncs-0.3.3/tests/test_import_path.py +185 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/tests/test_sdk_cli.py +9 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/uv.lock +3 -1
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/.github/workflows/release.yml +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/.gitignore +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/.python-version +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/docs/contract.md +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/_errors.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/_setup.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/_tools.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/agent_hooks.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/py.typed +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/src/toolfuncs/sdk.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/tests/__init__.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/tests/conftest.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/tests/test_agent_hooks.py +0 -0
- {toolfuncs-0.3.1 → toolfuncs-0.3.3}/tests/test_tools.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolfuncs
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.3
|
|
4
4
|
Summary: Executable typed Python function tools discovered through PATH
|
|
5
5
|
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
|
|
6
6
|
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
|
|
@@ -80,7 +80,29 @@ import toolfuncs
|
|
|
80
80
|
weather = toolfuncs.load("weather")
|
|
81
81
|
```
|
|
82
82
|
|
|
83
|
-
|
|
83
|
+
`import_path()` is the narrower utility for a local script whose path is already
|
|
84
|
+
known:
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
module = toolfuncs.import_path("scripts/prepare_data.py")
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
It accepts one ordinary `.py` file or extensionless script and derives the
|
|
91
|
+
module name from its filename. It does not require a shebang, executable bit,
|
|
92
|
+
`[tool.toolfuncs]` metadata, or `App`, and it does not import packages, projects,
|
|
93
|
+
distributions, or URLs. When an optional PEP 723 block exists, the default
|
|
94
|
+
installer installs all declared requirements together into the running Python
|
|
95
|
+
environment before import. A caller can replace that behavior with one callable
|
|
96
|
+
that accepts `list[str]` and returns `None`:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
module = toolfuncs.import_path(
|
|
100
|
+
"scripts/prepare_data.py",
|
|
101
|
+
dependency_installer=my_installer,
|
|
102
|
+
)
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Direct Python calls return ordinary Python objects and raise the original exceptions. `toolfuncs.load()` and `toolfuncs.import_path()` prepare declared requirements in the running Python environment before import. CLI calls use Cyclopts to parse annotated values and serialize successful results as strict JSON through `pydantic-core`.
|
|
84
106
|
|
|
85
107
|
## Source contract
|
|
86
108
|
|
|
@@ -62,7 +62,29 @@ import toolfuncs
|
|
|
62
62
|
weather = toolfuncs.load("weather")
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
`import_path()` is the narrower utility for a local script whose path is already
|
|
66
|
+
known:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
module = toolfuncs.import_path("scripts/prepare_data.py")
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
It accepts one ordinary `.py` file or extensionless script and derives the
|
|
73
|
+
module name from its filename. It does not require a shebang, executable bit,
|
|
74
|
+
`[tool.toolfuncs]` metadata, or `App`, and it does not import packages, projects,
|
|
75
|
+
distributions, or URLs. When an optional PEP 723 block exists, the default
|
|
76
|
+
installer installs all declared requirements together into the running Python
|
|
77
|
+
environment before import. A caller can replace that behavior with one callable
|
|
78
|
+
that accepts `list[str]` and returns `None`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
module = toolfuncs.import_path(
|
|
82
|
+
"scripts/prepare_data.py",
|
|
83
|
+
dependency_installer=my_installer,
|
|
84
|
+
)
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Direct Python calls return ordinary Python objects and raise the original exceptions. `toolfuncs.load()` and `toolfuncs.import_path()` prepare declared requirements in the running Python environment before import. CLI calls use Cyclopts to parse annotated values and serialize successful results as strict JSON through `pydantic-core`.
|
|
66
88
|
|
|
67
89
|
## Source contract
|
|
68
90
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "toolfuncs"
|
|
3
|
-
version = "0.3.
|
|
3
|
+
version = "0.3.3"
|
|
4
4
|
description = "Executable typed Python function tools discovered through PATH"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -38,6 +38,7 @@ dev = [
|
|
|
38
38
|
"pytest>=8",
|
|
39
39
|
"pytest-cov>=6",
|
|
40
40
|
"ruff>=0.12",
|
|
41
|
+
"tomli>=2.2,<3",
|
|
41
42
|
"typed-agent-hooks",
|
|
42
43
|
]
|
|
43
44
|
|
|
@@ -13,6 +13,7 @@ from ._errors import (
|
|
|
13
13
|
ToolfuncsError,
|
|
14
14
|
ToolNotFoundError,
|
|
15
15
|
)
|
|
16
|
+
from ._import_path import DependencyInstaller, import_path
|
|
16
17
|
from ._setup import SetupResult, setup
|
|
17
18
|
from ._tools import ALLOW_FLOATING_VCS_SHEBANG, TOOL_SHEBANG, Tool, discover, load
|
|
18
19
|
from .sdk import (
|
|
@@ -28,7 +29,7 @@ from .sdk import (
|
|
|
28
29
|
validators,
|
|
29
30
|
)
|
|
30
31
|
|
|
31
|
-
__version__ = "0.3.
|
|
32
|
+
__version__ = "0.3.3"
|
|
32
33
|
|
|
33
34
|
__all__ = [
|
|
34
35
|
"ALLOW_FLOATING_VCS_SHEBANG",
|
|
@@ -36,6 +37,7 @@ __all__ = [
|
|
|
36
37
|
"UNSET",
|
|
37
38
|
"App",
|
|
38
39
|
"DependencyError",
|
|
40
|
+
"DependencyInstaller",
|
|
39
41
|
"Group",
|
|
40
42
|
"InvalidToolError",
|
|
41
43
|
"Parameter",
|
|
@@ -49,6 +51,7 @@ __all__ = [
|
|
|
49
51
|
"ToolNotFoundError",
|
|
50
52
|
"ToolfuncsError",
|
|
51
53
|
"discover",
|
|
54
|
+
"import_path",
|
|
52
55
|
"json_result_action",
|
|
53
56
|
"load",
|
|
54
57
|
"setup",
|
|
@@ -11,6 +11,7 @@ from typing import Any
|
|
|
11
11
|
|
|
12
12
|
from pydantic_core import to_jsonable_python
|
|
13
13
|
|
|
14
|
+
from . import __version__
|
|
14
15
|
from ._setup import setup
|
|
15
16
|
from ._tools import (
|
|
16
17
|
ALLOW_FLOATING_VCS_SHEBANG,
|
|
@@ -24,6 +25,7 @@ from .sdk import _run
|
|
|
24
25
|
|
|
25
26
|
_HELP = f"""\
|
|
26
27
|
Usage:
|
|
28
|
+
toolfuncs --version
|
|
27
29
|
toolfuncs list
|
|
28
30
|
toolfuncs setup [--bin-dir DIRECTORY]
|
|
29
31
|
|
|
@@ -42,6 +44,11 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
42
44
|
if not tokens or tokens[0] in {"-h", "--help", "help"}:
|
|
43
45
|
print(_HELP, end="")
|
|
44
46
|
return 0
|
|
47
|
+
if tokens[0] in {"-V", "--version"}:
|
|
48
|
+
if len(tokens) != 1:
|
|
49
|
+
raise SystemExit("toolfuncs --version accepts no arguments")
|
|
50
|
+
print(__version__)
|
|
51
|
+
return 0
|
|
45
52
|
|
|
46
53
|
command = tokens.pop(0)
|
|
47
54
|
if command == "__run-source":
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
"""Import one local Python script into the running interpreter."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
import importlib.abc
|
|
7
|
+
import importlib.machinery
|
|
8
|
+
import importlib.util
|
|
9
|
+
import keyword
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import threading
|
|
15
|
+
import weakref
|
|
16
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from importlib.machinery import ModuleSpec
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from types import ModuleType
|
|
21
|
+
from typing import TypeAlias, cast
|
|
22
|
+
|
|
23
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
24
|
+
from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
|
25
|
+
from packaging.version import Version
|
|
26
|
+
|
|
27
|
+
from ._errors import DependencyError, ToolConflictError
|
|
28
|
+
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
import tomllib
|
|
31
|
+
else:
|
|
32
|
+
import tomli as tomllib
|
|
33
|
+
|
|
34
|
+
DependencyInstaller: TypeAlias = Callable[[list[str]], None]
|
|
35
|
+
|
|
36
|
+
_PEP723_BLOCK = re.compile(
|
|
37
|
+
r"(?m)^# /// (?P<type>[a-zA-Z0-9-]+)[ \t]*$[\r\n]"
|
|
38
|
+
r"(?P<content>(?:^#(?:| .*)$[\r\n]?)+)^# ///[ \t]*$"
|
|
39
|
+
)
|
|
40
|
+
_IMPORT_LOCK = threading.RLock()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class _ScriptDependencies:
|
|
45
|
+
requirements: tuple[str, ...]
|
|
46
|
+
requires_python: str | None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True, slots=True)
|
|
50
|
+
class _DirectImport:
|
|
51
|
+
name: str
|
|
52
|
+
source: Path
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
_BY_SOURCE: dict[Path, ModuleType] = {}
|
|
56
|
+
_SOURCE_BY_NAME: dict[str, Path] = {}
|
|
57
|
+
_DIRECT_IMPORTS: weakref.WeakKeyDictionary[ModuleType, _DirectImport] = weakref.WeakKeyDictionary()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class _DirectImportFinder(importlib.abc.MetaPathFinder):
|
|
61
|
+
"""Rediscover script-backed module specs for ``importlib.reload``."""
|
|
62
|
+
|
|
63
|
+
def find_spec(
|
|
64
|
+
self,
|
|
65
|
+
fullname: str,
|
|
66
|
+
path: Sequence[str] | None = None,
|
|
67
|
+
target: ModuleType | None = None,
|
|
68
|
+
) -> ModuleSpec | None:
|
|
69
|
+
del path
|
|
70
|
+
if target is None:
|
|
71
|
+
return None
|
|
72
|
+
with _IMPORT_LOCK:
|
|
73
|
+
direct_import = _DIRECT_IMPORTS.get(target)
|
|
74
|
+
if direct_import is None or direct_import.name != fullname:
|
|
75
|
+
return None
|
|
76
|
+
return _direct_import_spec(direct_import.name, direct_import.source)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
_DIRECT_IMPORT_FINDER = _DirectImportFinder()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def import_path(
|
|
83
|
+
source: str | os.PathLike[str],
|
|
84
|
+
*,
|
|
85
|
+
dependency_installer: DependencyInstaller | None = None,
|
|
86
|
+
) -> ModuleType:
|
|
87
|
+
"""Import one local Python script.
|
|
88
|
+
|
|
89
|
+
``source`` may be an ordinary ``.py`` file or an extensionless script. Its
|
|
90
|
+
filename determines the module name. When the source contains PEP 723
|
|
91
|
+
metadata, its requirements are installed as one list before import. Pass a
|
|
92
|
+
custom ``dependency_installer`` to replace the default current-interpreter
|
|
93
|
+
pip installation.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
path = _source_path(source)
|
|
97
|
+
name = _module_name(path)
|
|
98
|
+
source_key = path.resolve(strict=True)
|
|
99
|
+
with _IMPORT_LOCK:
|
|
100
|
+
if imported := _BY_SOURCE.get(source_key):
|
|
101
|
+
return imported
|
|
102
|
+
if previous_source := _SOURCE_BY_NAME.get(name):
|
|
103
|
+
raise ToolConflictError(
|
|
104
|
+
f"script name {name!r} was already imported from {previous_source}, "
|
|
105
|
+
f"not {source_key}"
|
|
106
|
+
)
|
|
107
|
+
if name in sys.modules:
|
|
108
|
+
raise ToolConflictError(
|
|
109
|
+
f"cannot import {source_key} as {name!r}: that Python module name is already in use"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
dependencies = _script_dependencies(path)
|
|
113
|
+
_require_compatible_python(dependencies, source=path)
|
|
114
|
+
if dependencies.requirements:
|
|
115
|
+
installer = dependency_installer or _default_dependency_installer
|
|
116
|
+
installer(list(dependencies.requirements))
|
|
117
|
+
importlib.invalidate_caches()
|
|
118
|
+
|
|
119
|
+
spec = _direct_import_spec(name, path)
|
|
120
|
+
assert spec.loader is not None
|
|
121
|
+
imported = importlib.util.module_from_spec(spec)
|
|
122
|
+
sys.modules[name] = imported
|
|
123
|
+
try:
|
|
124
|
+
spec.loader.exec_module(imported)
|
|
125
|
+
except BaseException:
|
|
126
|
+
if sys.modules.get(name) is imported:
|
|
127
|
+
sys.modules.pop(name, None)
|
|
128
|
+
raise
|
|
129
|
+
|
|
130
|
+
_BY_SOURCE[source_key] = imported
|
|
131
|
+
_SOURCE_BY_NAME[name] = source_key
|
|
132
|
+
_DIRECT_IMPORTS[imported] = _DirectImport(name, path)
|
|
133
|
+
if _DIRECT_IMPORT_FINDER not in sys.meta_path:
|
|
134
|
+
sys.meta_path.insert(0, _DIRECT_IMPORT_FINDER)
|
|
135
|
+
return imported
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _source_path(source: str | os.PathLike[str]) -> Path:
|
|
139
|
+
raw = os.fspath(source)
|
|
140
|
+
if isinstance(raw, bytes):
|
|
141
|
+
raise TypeError("source must be a string path, not bytes")
|
|
142
|
+
if not raw:
|
|
143
|
+
raise ValueError("source path must not be empty")
|
|
144
|
+
path = Path(os.path.abspath(Path(raw).expanduser()))
|
|
145
|
+
if not path.is_file():
|
|
146
|
+
raise FileNotFoundError(f"Python script does not exist: {path}")
|
|
147
|
+
return path
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _module_name(source: Path) -> str:
|
|
151
|
+
if source.suffix == ".py":
|
|
152
|
+
name = source.stem
|
|
153
|
+
elif not source.suffix:
|
|
154
|
+
name = source.name
|
|
155
|
+
else:
|
|
156
|
+
raise ValueError(f"Python script must be extensionless or end in '.py': {source}")
|
|
157
|
+
if not name.isidentifier() or keyword.iskeyword(name):
|
|
158
|
+
raise ValueError(
|
|
159
|
+
f"script filename does not define a valid Python module name: {source.name!r}"
|
|
160
|
+
)
|
|
161
|
+
return name
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _script_dependencies(source: Path) -> _ScriptDependencies:
|
|
165
|
+
try:
|
|
166
|
+
text = source.read_text(encoding="utf-8")
|
|
167
|
+
except (OSError, UnicodeError) as exc:
|
|
168
|
+
raise ValueError(f"could not read Python script {source}: {exc}") from exc
|
|
169
|
+
blocks = [match for match in _PEP723_BLOCK.finditer(text) if match.group("type") == "script"]
|
|
170
|
+
if not blocks:
|
|
171
|
+
return _ScriptDependencies((), None)
|
|
172
|
+
if len(blocks) > 1:
|
|
173
|
+
raise ValueError(f"Python script {source} contains more than one PEP 723 'script' block")
|
|
174
|
+
content = "\n".join(
|
|
175
|
+
line[2:] if line.startswith("# ") else line[1:]
|
|
176
|
+
for line in blocks[0].group("content").splitlines()
|
|
177
|
+
)
|
|
178
|
+
try:
|
|
179
|
+
table = tomllib.loads(content)
|
|
180
|
+
except tomllib.TOMLDecodeError as exc:
|
|
181
|
+
raise ValueError(f"Python script {source} has invalid PEP 723 metadata: {exc}") from exc
|
|
182
|
+
return _dependencies_from_table(table, source=source)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _dependencies_from_table(
|
|
186
|
+
table: Mapping[str, object],
|
|
187
|
+
*,
|
|
188
|
+
source: Path,
|
|
189
|
+
) -> _ScriptDependencies:
|
|
190
|
+
raw_dependencies = table.get("dependencies", [])
|
|
191
|
+
if not isinstance(raw_dependencies, list):
|
|
192
|
+
raise ValueError(f"{source}: PEP 723 'dependencies' must be a list of strings")
|
|
193
|
+
requirements: list[str] = []
|
|
194
|
+
for raw_dependency in cast(list[object], raw_dependencies):
|
|
195
|
+
if not isinstance(raw_dependency, str) or not raw_dependency.strip():
|
|
196
|
+
raise ValueError(f"{source}: PEP 723 dependencies must contain nonempty strings")
|
|
197
|
+
dependency = raw_dependency.strip()
|
|
198
|
+
try:
|
|
199
|
+
Requirement(dependency)
|
|
200
|
+
except InvalidRequirement as exc:
|
|
201
|
+
raise ValueError(f"{source}: invalid dependency {dependency!r}: {exc}") from exc
|
|
202
|
+
requirements.append(dependency)
|
|
203
|
+
|
|
204
|
+
requires_python: str | None = None
|
|
205
|
+
if (raw_requires_python := table.get("requires-python")) is not None:
|
|
206
|
+
if not isinstance(raw_requires_python, str) or not raw_requires_python.strip():
|
|
207
|
+
raise ValueError(f"{source}: PEP 723 'requires-python' must be a nonempty string")
|
|
208
|
+
requires_python = raw_requires_python.strip()
|
|
209
|
+
try:
|
|
210
|
+
SpecifierSet(requires_python)
|
|
211
|
+
except InvalidSpecifier as exc:
|
|
212
|
+
raise ValueError(
|
|
213
|
+
f"{source}: invalid 'requires-python' value {requires_python!r}"
|
|
214
|
+
) from exc
|
|
215
|
+
return _ScriptDependencies(tuple(requirements), requires_python)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _require_compatible_python(metadata: _ScriptDependencies, *, source: Path) -> None:
|
|
219
|
+
if metadata.requires_python is None:
|
|
220
|
+
return
|
|
221
|
+
interpreter = Version(".".join(str(part) for part in sys.version_info[:3]))
|
|
222
|
+
if not SpecifierSet(metadata.requires_python).contains(interpreter, prereleases=True):
|
|
223
|
+
raise ValueError(
|
|
224
|
+
f"{source}: 'requires-python' {metadata.requires_python!r} does not admit "
|
|
225
|
+
f"the running interpreter {interpreter}"
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _default_dependency_installer(requirements: list[str]) -> None:
|
|
230
|
+
completed = subprocess.run(
|
|
231
|
+
(
|
|
232
|
+
sys.executable,
|
|
233
|
+
"-m",
|
|
234
|
+
"pip",
|
|
235
|
+
"--disable-pip-version-check",
|
|
236
|
+
"install",
|
|
237
|
+
"--quiet",
|
|
238
|
+
*requirements,
|
|
239
|
+
),
|
|
240
|
+
capture_output=True,
|
|
241
|
+
text=True,
|
|
242
|
+
check=False,
|
|
243
|
+
)
|
|
244
|
+
if completed.returncode:
|
|
245
|
+
raise DependencyError(
|
|
246
|
+
f"could not install script dependencies (pip exited {completed.returncode})"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _direct_import_spec(name: str, source: Path) -> ModuleSpec:
|
|
251
|
+
loader = importlib.machinery.SourceFileLoader(name, str(source))
|
|
252
|
+
spec = importlib.util.spec_from_loader(name, loader)
|
|
253
|
+
if spec is None:
|
|
254
|
+
raise ImportError(f"could not create an import specification for {source}")
|
|
255
|
+
return spec
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
__all__ = ["DependencyInstaller", "import_path"]
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import importlib
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from types import ModuleType
|
|
8
|
+
from typing import Any, cast
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import toolfuncs._import_path as import_path_module
|
|
13
|
+
from toolfuncs import DependencyError, import_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_import_path_imports_plain_python_file_without_installing(
|
|
17
|
+
tmp_path: Path,
|
|
18
|
+
unique_name: str,
|
|
19
|
+
) -> None:
|
|
20
|
+
source = tmp_path / f"{unique_name}.py"
|
|
21
|
+
source.write_text("VALUE = 41\n", encoding="utf-8")
|
|
22
|
+
calls: list[list[str]] = []
|
|
23
|
+
|
|
24
|
+
imported = import_path(source, dependency_installer=calls.append)
|
|
25
|
+
|
|
26
|
+
assert imported.__name__ == unique_name
|
|
27
|
+
assert imported.__file__ == str(source)
|
|
28
|
+
assert imported.VALUE == 41
|
|
29
|
+
assert calls == []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def test_import_path_accepts_extensionless_script(
|
|
33
|
+
tmp_path: Path,
|
|
34
|
+
unique_name: str,
|
|
35
|
+
) -> None:
|
|
36
|
+
source = tmp_path / unique_name
|
|
37
|
+
source.write_text("VALUE = 43\n", encoding="utf-8")
|
|
38
|
+
|
|
39
|
+
imported = import_path(source)
|
|
40
|
+
|
|
41
|
+
assert imported.__name__ == unique_name
|
|
42
|
+
assert imported.VALUE == 43
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_pep723_dependencies_are_passed_as_one_list_before_import(
|
|
46
|
+
tmp_path: Path,
|
|
47
|
+
unique_name: str,
|
|
48
|
+
) -> None:
|
|
49
|
+
source = tmp_path / f"{unique_name}.py"
|
|
50
|
+
dependency_name = f"{unique_name}_dependency"
|
|
51
|
+
calls: list[list[str]] = []
|
|
52
|
+
|
|
53
|
+
def install(requirements: list[str]) -> None:
|
|
54
|
+
calls.append(requirements)
|
|
55
|
+
dependency = ModuleType(dependency_name)
|
|
56
|
+
cast(Any, dependency).VALUE = 47
|
|
57
|
+
sys.modules[dependency_name] = dependency
|
|
58
|
+
|
|
59
|
+
source.write_text(
|
|
60
|
+
"# /// script\n"
|
|
61
|
+
'# requires-python = ">=3.10"\n'
|
|
62
|
+
'# dependencies = ["demo-one>=1", "demo-two==2"]\n'
|
|
63
|
+
"# [tool.anything]\n"
|
|
64
|
+
'# ignored = "by import_path"\n'
|
|
65
|
+
"# ///\n"
|
|
66
|
+
f"import {dependency_name}\n"
|
|
67
|
+
f"VALUE = {dependency_name}.VALUE\n",
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
)
|
|
70
|
+
try:
|
|
71
|
+
imported = import_path(source, dependency_installer=install)
|
|
72
|
+
|
|
73
|
+
assert imported.VALUE == 47
|
|
74
|
+
assert calls == [["demo-one>=1", "demo-two==2"]]
|
|
75
|
+
finally:
|
|
76
|
+
sys.modules.pop(dependency_name, None)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_default_installer_uses_current_interpreter_pip(
|
|
80
|
+
tmp_path: Path,
|
|
81
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
82
|
+
unique_name: str,
|
|
83
|
+
) -> None:
|
|
84
|
+
source = tmp_path / f"{unique_name}.py"
|
|
85
|
+
source.write_text(
|
|
86
|
+
'# /// script\n# dependencies = ["demo-package>=1"]\n# ///\nVALUE = 53\n',
|
|
87
|
+
encoding="utf-8",
|
|
88
|
+
)
|
|
89
|
+
calls: list[tuple[str, ...]] = []
|
|
90
|
+
|
|
91
|
+
def successful_install(
|
|
92
|
+
argv: tuple[str, ...],
|
|
93
|
+
**_: object,
|
|
94
|
+
) -> subprocess.CompletedProcess[str]:
|
|
95
|
+
calls.append(argv)
|
|
96
|
+
return subprocess.CompletedProcess(argv, 0, "", "")
|
|
97
|
+
|
|
98
|
+
monkeypatch.setattr(import_path_module.subprocess, "run", successful_install)
|
|
99
|
+
|
|
100
|
+
assert import_path(source).VALUE == 53
|
|
101
|
+
assert calls == [
|
|
102
|
+
(
|
|
103
|
+
sys.executable,
|
|
104
|
+
"-m",
|
|
105
|
+
"pip",
|
|
106
|
+
"--disable-pip-version-check",
|
|
107
|
+
"install",
|
|
108
|
+
"--quiet",
|
|
109
|
+
"demo-package>=1",
|
|
110
|
+
)
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_dependency_failure_prevents_import(
|
|
115
|
+
tmp_path: Path,
|
|
116
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
117
|
+
unique_name: str,
|
|
118
|
+
) -> None:
|
|
119
|
+
source = tmp_path / f"{unique_name}.py"
|
|
120
|
+
source.write_text(
|
|
121
|
+
'# /// script\n# dependencies = ["missing-package"]\n# ///\nVALUE = 59\n',
|
|
122
|
+
encoding="utf-8",
|
|
123
|
+
)
|
|
124
|
+
monkeypatch.setattr(
|
|
125
|
+
import_path_module.subprocess,
|
|
126
|
+
"run",
|
|
127
|
+
lambda argv, **_: subprocess.CompletedProcess(argv, 3, "", "failed"),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
with pytest.raises(DependencyError, match="pip exited 3"):
|
|
131
|
+
import_path(source)
|
|
132
|
+
assert unique_name not in sys.modules
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_incompatible_python_fails_before_custom_installer(
|
|
136
|
+
tmp_path: Path,
|
|
137
|
+
unique_name: str,
|
|
138
|
+
) -> None:
|
|
139
|
+
source = tmp_path / f"{unique_name}.py"
|
|
140
|
+
source.write_text(
|
|
141
|
+
'# /// script\n# requires-python = "<3"\n# dependencies = ["demo"]\n# ///\n',
|
|
142
|
+
encoding="utf-8",
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def forbidden_install(_requirements: list[str]) -> None:
|
|
146
|
+
raise AssertionError("installer must not run")
|
|
147
|
+
|
|
148
|
+
with pytest.raises(ValueError, match="does not admit"):
|
|
149
|
+
import_path(source, dependency_installer=forbidden_install)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_import_path_caches_by_source_and_supports_reload(
|
|
153
|
+
tmp_path: Path,
|
|
154
|
+
unique_name: str,
|
|
155
|
+
) -> None:
|
|
156
|
+
source = tmp_path / f"{unique_name}.py"
|
|
157
|
+
source.write_text("COUNT = globals().get('COUNT', 0) + 1\n", encoding="utf-8")
|
|
158
|
+
|
|
159
|
+
imported = import_path(source)
|
|
160
|
+
again = import_path(source)
|
|
161
|
+
reloaded = importlib.reload(imported)
|
|
162
|
+
|
|
163
|
+
assert imported is again is reloaded
|
|
164
|
+
assert imported.COUNT == 2
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_import_failure_removes_partial_module(
|
|
168
|
+
tmp_path: Path,
|
|
169
|
+
unique_name: str,
|
|
170
|
+
) -> None:
|
|
171
|
+
source = tmp_path / f"{unique_name}.py"
|
|
172
|
+
source.write_text("raise RuntimeError('boom')\n", encoding="utf-8")
|
|
173
|
+
|
|
174
|
+
with pytest.raises(RuntimeError, match="boom"):
|
|
175
|
+
import_path(source)
|
|
176
|
+
|
|
177
|
+
assert unique_name not in sys.modules
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_import_path_rejects_non_script_suffix(tmp_path: Path, unique_name: str) -> None:
|
|
181
|
+
source = tmp_path / f"{unique_name}.txt"
|
|
182
|
+
source.write_text("VALUE = 1\n", encoding="utf-8")
|
|
183
|
+
|
|
184
|
+
with pytest.raises(ValueError, match=r"extensionless or end in '\.py'"):
|
|
185
|
+
import_path(source)
|
|
@@ -25,6 +25,15 @@ from .conftest import run_python
|
|
|
25
25
|
_INSTALL_HOOKS = setup_impl._install_hooks
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
@pytest.mark.parametrize("flag", ["--version", "-V"])
|
|
29
|
+
def test_cli_prints_installed_version(tmp_path: Path, flag: str) -> None:
|
|
30
|
+
completed = run_python(["-m", "toolfuncs", flag], cwd=tmp_path)
|
|
31
|
+
|
|
32
|
+
assert completed.returncode == 0
|
|
33
|
+
assert completed.stdout == "0.3.3\n"
|
|
34
|
+
assert completed.stderr == ""
|
|
35
|
+
|
|
36
|
+
|
|
28
37
|
@pytest.fixture(autouse=True)
|
|
29
38
|
def _avoid_installing_live_agent_hooks(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
30
39
|
monkeypatch.setattr(setup_impl, "_install_hooks", lambda _hook_wrapper: None)
|
|
@@ -601,7 +601,7 @@ wheels = [
|
|
|
601
601
|
|
|
602
602
|
[[package]]
|
|
603
603
|
name = "toolfuncs"
|
|
604
|
-
version = "0.3.
|
|
604
|
+
version = "0.3.3"
|
|
605
605
|
source = { editable = "." }
|
|
606
606
|
dependencies = [
|
|
607
607
|
{ name = "cyclopts" },
|
|
@@ -624,6 +624,7 @@ dev = [
|
|
|
624
624
|
{ name = "pytest" },
|
|
625
625
|
{ name = "pytest-cov" },
|
|
626
626
|
{ name = "ruff" },
|
|
627
|
+
{ name = "tomli" },
|
|
627
628
|
{ name = "typed-agent-hooks" },
|
|
628
629
|
]
|
|
629
630
|
|
|
@@ -646,6 +647,7 @@ dev = [
|
|
|
646
647
|
{ name = "pytest", specifier = ">=8" },
|
|
647
648
|
{ name = "pytest-cov", specifier = ">=6" },
|
|
648
649
|
{ name = "ruff", specifier = ">=0.12" },
|
|
650
|
+
{ name = "tomli", specifier = ">=2.2,<3" },
|
|
649
651
|
{ name = "typed-agent-hooks" },
|
|
650
652
|
]
|
|
651
653
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|