toolfuncs 0.12.2__tar.gz → 0.12.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.12.2 → toolfuncs-0.12.3}/.github/workflows/release.yml +2 -2
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/PKG-INFO +9 -5
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/README.md +7 -3
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/docs/contract.md +7 -3
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/pyproject.toml +2 -2
- toolfuncs-0.12.3/src/toolfuncs/_packages.py +209 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_setup.py +3 -5
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_tools.py +4 -4
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/sdk.py +12 -1
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_packages.py +164 -31
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/uv.lock +15 -5
- toolfuncs-0.12.2/src/toolfuncs/_packages.py +0 -335
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/.gitignore +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/.python-version +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/__init__.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/__main__.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_descriptions.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_doctor.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_errors.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_import_path.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/_runtime.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/agent_hooks.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/src/toolfuncs/py.typed +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/__init__.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/conftest.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_agent_hooks.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_doctor.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_import_path.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_managed_runtime.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_sdk_cli.py +0 -0
- {toolfuncs-0.12.2 → toolfuncs-0.12.3}/tests/test_tools.py +0 -0
|
@@ -7,7 +7,7 @@ on:
|
|
|
7
7
|
jobs:
|
|
8
8
|
build:
|
|
9
9
|
name: Build distributions
|
|
10
|
-
runs-on:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
11
|
permissions:
|
|
12
12
|
contents: read
|
|
13
13
|
|
|
@@ -55,7 +55,7 @@ jobs:
|
|
|
55
55
|
publish:
|
|
56
56
|
name: Publish to PyPI
|
|
57
57
|
needs: build
|
|
58
|
-
runs-on:
|
|
58
|
+
runs-on: ubuntu-latest
|
|
59
59
|
environment:
|
|
60
60
|
name: pypi
|
|
61
61
|
url: https://pypi.org/project/toolfuncs/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: toolfuncs
|
|
3
|
-
Version: 0.12.
|
|
3
|
+
Version: 0.12.3
|
|
4
4
|
Summary: Executable typed Python function tools invoked by path or discovered through PATH
|
|
5
5
|
Project-URL: Repository, https://github.com/nimashoghi/toolfuncs
|
|
6
6
|
Project-URL: Issues, https://github.com/nimashoghi/toolfuncs/issues
|
|
@@ -10,7 +10,7 @@ Requires-Dist: cyclopts<5,>=4.25.1
|
|
|
10
10
|
Requires-Dist: packaging>=24
|
|
11
11
|
Requires-Dist: pydantic-core<3,>=2
|
|
12
12
|
Requires-Dist: pydantic<3,>=2
|
|
13
|
-
Requires-Dist: uv-launch<0.3,>=0.2.
|
|
13
|
+
Requires-Dist: uv-launch<0.3,>=0.2.6
|
|
14
14
|
Provides-Extra: hooks
|
|
15
15
|
Requires-Dist: typed-agent-hooks; extra == 'hooks'
|
|
16
16
|
Description-Content-Type: text/markdown
|
|
@@ -60,9 +60,13 @@ def describe(conversation: str) -> dict[str, str]:
|
|
|
60
60
|
return {"conversation": conversation}
|
|
61
61
|
```
|
|
62
62
|
|
|
63
|
-
Install with `uv-launch install agent-apps "agent-apps @ file:///absolute/path/to/project"` for managed launches
|
|
63
|
+
Install with `uv-launch install agent-apps "agent-apps @ file:///absolute/path/to/project"` for managed launches. Use uv-launch by default; it prepares and updates the command environment automatically. The installer generates the console script; no separate handwritten executable, self-dependent PEP 723 script, custom backend, or registration command is needed. `agent-apps describe example` calls the same decorated function exposed as `toolfuncs.agent_apps.describe("example")`. Direct Python lookup returns the real `agent_apps.tools` module and native objects/exceptions.
|
|
64
64
|
|
|
65
|
-
Discovery reads
|
|
65
|
+
Discovery reads installed entry points, unique RECORD ownership, a strong launcher checksum, and the module docstring. It never imports a target, starts an interpreter, prepares dependencies, or uses the network. uv-launch records locate foreign command environments and capture one immutable generation. Packages installed in the caller's own interpreter use its metadata search paths. Generated launcher bodies are opaque: Python shebangs, long-path shell wrappers, and future installer templates follow the same ownership and checksum checks. Unmanaged foreign environments must be migrated with uv-launch; their interpreter locations are not inferred from executable text. Installed Python source is required; editable import-hook layouts are not inferred. The first docstring line is the static description.
|
|
66
|
+
|
|
67
|
+
These checks detect inconsistent installations, not malicious package metadata. The package installer is trusted to generate the command declared by matching `console_scripts` and `toolfuncs` entry points. Discovery does not prove executable semantics or authenticate a publisher.
|
|
68
|
+
|
|
69
|
+
Python consumers can call `toolfuncs.sdk.discover(path=...)` for the same static `Catalog` used by listing and agent hooks. Omit `path` to use PATH; a supplied path has the same first-executable precedence. Read `catalog.tools` and `catalog.invalid_tools` without executing command help.
|
|
66
70
|
|
|
67
71
|
Direct Python loading installs the owning distribution into the caller's interpreter if needed, using its recorded exact Git origin, other direct origin, or published name/version. It then imports the actual module. This preserves native Python semantics rather than opening an RPC proxy into the tool environment. Use a suitable interpreter when tools have incompatible dependency requirements. `toolfuncs doctor /path/to/agent-apps` validates the installed module's registered commands and generated help. `import_path` retains its existing local-script contract and does not import package console wrappers.
|
|
68
72
|
|
|
@@ -277,7 +281,7 @@ Loading is intentional: the registered command graph and Cyclopts' effective par
|
|
|
277
281
|
uvx --from "toolfuncs>=0.12" toolfuncs setup
|
|
278
282
|
```
|
|
279
283
|
|
|
280
|
-
Setup
|
|
284
|
+
Setup bootstraps uv-launch through a transient `uv tool run` invocation, prepares managed `toolfuncs` and `toolfuncs-agent-hook` commands, and writes small forwarding wrappers at `~/.local/bin/toolfuncs` and `~/.agents/hooks/toolfuncs/hook`. These wrappers no longer invoke uvx on each call. The destination must already be on PATH; setup is idempotent, refuses to overwrite unmanaged commands, and does not modify shell profiles.
|
|
281
285
|
|
|
282
286
|
Both shebang invocation and `toolfuncs SOURCE` delegate requirements to uv-launch before executing the source in an immutable environment. First use waits for installation. Warm calls reuse an existing environment and check for dependency updates in the background. Source edits run immediately; changed requirements or interpreter constraints select or prepare a matching environment before execution. Failed background updates leave the last working environment available. `toolfuncs doctor SOURCE` uses the same isolated preparation.
|
|
283
287
|
|
|
@@ -43,9 +43,13 @@ def describe(conversation: str) -> dict[str, str]:
|
|
|
43
43
|
return {"conversation": conversation}
|
|
44
44
|
```
|
|
45
45
|
|
|
46
|
-
Install with `uv-launch install agent-apps "agent-apps @ file:///absolute/path/to/project"` for managed launches
|
|
46
|
+
Install with `uv-launch install agent-apps "agent-apps @ file:///absolute/path/to/project"` for managed launches. Use uv-launch by default; it prepares and updates the command environment automatically. The installer generates the console script; no separate handwritten executable, self-dependent PEP 723 script, custom backend, or registration command is needed. `agent-apps describe example` calls the same decorated function exposed as `toolfuncs.agent_apps.describe("example")`. Direct Python lookup returns the real `agent_apps.tools` module and native objects/exceptions.
|
|
47
47
|
|
|
48
|
-
Discovery reads
|
|
48
|
+
Discovery reads installed entry points, unique RECORD ownership, a strong launcher checksum, and the module docstring. It never imports a target, starts an interpreter, prepares dependencies, or uses the network. uv-launch records locate foreign command environments and capture one immutable generation. Packages installed in the caller's own interpreter use its metadata search paths. Generated launcher bodies are opaque: Python shebangs, long-path shell wrappers, and future installer templates follow the same ownership and checksum checks. Unmanaged foreign environments must be migrated with uv-launch; their interpreter locations are not inferred from executable text. Installed Python source is required; editable import-hook layouts are not inferred. The first docstring line is the static description.
|
|
49
|
+
|
|
50
|
+
These checks detect inconsistent installations, not malicious package metadata. The package installer is trusted to generate the command declared by matching `console_scripts` and `toolfuncs` entry points. Discovery does not prove executable semantics or authenticate a publisher.
|
|
51
|
+
|
|
52
|
+
Python consumers can call `toolfuncs.sdk.discover(path=...)` for the same static `Catalog` used by listing and agent hooks. Omit `path` to use PATH; a supplied path has the same first-executable precedence. Read `catalog.tools` and `catalog.invalid_tools` without executing command help.
|
|
49
53
|
|
|
50
54
|
Direct Python loading installs the owning distribution into the caller's interpreter if needed, using its recorded exact Git origin, other direct origin, or published name/version. It then imports the actual module. This preserves native Python semantics rather than opening an RPC proxy into the tool environment. Use a suitable interpreter when tools have incompatible dependency requirements. `toolfuncs doctor /path/to/agent-apps` validates the installed module's registered commands and generated help. `import_path` retains its existing local-script contract and does not import package console wrappers.
|
|
51
55
|
|
|
@@ -260,7 +264,7 @@ Loading is intentional: the registered command graph and Cyclopts' effective par
|
|
|
260
264
|
uvx --from "toolfuncs>=0.12" toolfuncs setup
|
|
261
265
|
```
|
|
262
266
|
|
|
263
|
-
Setup
|
|
267
|
+
Setup bootstraps uv-launch through a transient `uv tool run` invocation, prepares managed `toolfuncs` and `toolfuncs-agent-hook` commands, and writes small forwarding wrappers at `~/.local/bin/toolfuncs` and `~/.agents/hooks/toolfuncs/hook`. These wrappers no longer invoke uvx on each call. The destination must already be on PATH; setup is idempotent, refuses to overwrite unmanaged commands, and does not modify shell profiles.
|
|
264
268
|
|
|
265
269
|
Both shebang invocation and `toolfuncs SOURCE` delegate requirements to uv-launch before executing the source in an immutable environment. First use waits for installation. Warm calls reuse an existing environment and check for dependency updates in the background. Source edits run immediately; changed requirements or interpreter constraints select or prepare a matching environment before execution. Failed background updates leave the last working environment available. `toolfuncs doctor SOURCE` uses the same isolated preparation.
|
|
266
270
|
|
|
@@ -8,7 +8,9 @@ Toolfuncs projects one explicitly registered set of typed Python functions into
|
|
|
8
8
|
|
|
9
9
|
An installed distribution opts a console script into discovery by declaring the same command name and `module:app` value in its `console_scripts` and `toolfuncs` entry-point groups. Authors normally express these as `[project.scripts]` and `[project.entry-points.toolfuncs]` in `pyproject.toml`. The module contains the same module-level `app` and decorated typed functions used by script tools. There is no hand-maintained launcher: normal installers generate it.
|
|
10
10
|
|
|
11
|
-
The executable's kebab-case name determines
|
|
11
|
+
The public executable's kebab-case name determines its snake-case facade name, with the same reserved-name checks and PATH precedence as scripts. uv-launch's versioned installation records locate the actual executable and its metadata roots, capturing a single retained generation. The actual executable must have one distribution owner across all RECORD files, a matching strong checksum, and paired console-script and toolfuncs entry points. Discovery trusts the installer to implement those entry points. It does not parse generated Python or shell bodies or authenticate package metadata. Discovery statically reads the installed module's first docstring line and requires inspectable Python source.
|
|
12
|
+
|
|
13
|
+
Metadata in the current interpreter is also supported, including additive native dependency preparation. Unmanaged foreign environments are not inferred from launcher text; install those commands through uv-launch. Discovery never imports target packages, starts a subprocess, installs dependencies, or queries the network. `toolfuncs.sdk.discover(path=None)` returns the same `Catalog` as CLI listing, with `tools` and `invalid_tools`.
|
|
12
14
|
|
|
13
15
|
Python loading prepares the owning distribution in the caller's interpreter using its installed direct-origin metadata or name/version, then returns its real imported module. Git direct origins must identify a complete immutable commit. Returned functions, values, and exceptions are native Python objects; no remote proxy or subprocess API replaces them. `doctor` accepts the installed executable and validates the same registered interface. `import_path` remains a local-script importer.
|
|
14
16
|
|
|
@@ -90,7 +92,7 @@ Successful default CLI operations render one Python value through Rich on an int
|
|
|
90
92
|
|
|
91
93
|
## Setup and management
|
|
92
94
|
|
|
93
|
-
`uvx --from "toolfuncs>=0.12" toolfuncs setup` bootstraps
|
|
95
|
+
`uvx --from "toolfuncs>=0.12" toolfuncs setup` bootstraps uv-launch through transient `uv tool run` and prepares managed dispatcher and agent-hook commands. It installs forwarding wrappers in `~/.local/bin` or `--bin-dir`, and the agent hook location. The destination must already resolve from PATH. Setup is idempotent, refuses unmanaged commands, and never modifies shell profiles.
|
|
94
96
|
|
|
95
97
|
Setup does not discover, install, copy, link, register, synchronize, or remove tool sources. Adding and removing tools are ordinary filesystem operations in directories already on `PATH`.
|
|
96
98
|
|
|
@@ -98,7 +100,7 @@ The public management commands are `toolfuncs doctor SOURCE`, `toolfuncs list`,
|
|
|
98
100
|
|
|
99
101
|
## Compatibility
|
|
100
102
|
|
|
101
|
-
Version 0.11 adds packaged tools through matching console-script and `toolfuncs` entry points.
|
|
103
|
+
Version 0.11 adds packaged tools through matching console-script and `toolfuncs` entry points. In 0.12.3, use uv-launch for foreign command environments; unmanaged launcher inference from earlier releases has been removed. Existing standalone tools retain their interfaces.
|
|
102
104
|
|
|
103
105
|
Version 0.10 adds destination-adaptive CLI presentation. Non-terminal output retains the strict-JSON behavior of earlier releases. Interactive output now uses Rich's normal rendering for returned Python values, and explicit Cyclopts output policies remain unchanged.
|
|
104
106
|
|
|
@@ -106,3 +108,5 @@ Version 0.8 adds explicit opt-in last-known dynamic descriptions through `[tool.
|
|
|
106
108
|
|
|
107
109
|
|
|
108
110
|
Version 0.12 requires Python 3.11+ on macOS or Linux and delegates CLI environments and Python dependency preparation to uv-launch. Discovery recognizes uv-launch managed package commands through validated static launcher metadata. Existing installed distributions are preserved during Python imports; an incompatible dependency set is an explicit error.
|
|
111
|
+
|
|
112
|
+
Version 0.12.3 uses uv-launch 0.2.6 discovery records and installed RECORD checksums instead of parsing launcher bodies. Updating or reinstalling through uv-launch writes records automatically, including for retained generations. Existing source tools retain their interfaces.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "toolfuncs"
|
|
3
|
-
version = "0.12.
|
|
3
|
+
version = "0.12.3"
|
|
4
4
|
description = "Executable typed Python function tools invoked by path or discovered through PATH"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
@@ -10,7 +10,7 @@ requires-python = ">=3.11"
|
|
|
10
10
|
dependencies = [
|
|
11
11
|
"cyclopts>=4.25.1,<5",
|
|
12
12
|
"packaging>=24",
|
|
13
|
-
"uv-launch>=0.2.
|
|
13
|
+
"uv-launch>=0.2.6,<0.3",
|
|
14
14
|
"pydantic>=2,<3",
|
|
15
15
|
"pydantic-core>=2,<3",
|
|
16
16
|
]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Static discovery of opted-in, normally installed Python console scripts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import ast
|
|
6
|
+
import base64
|
|
7
|
+
import hashlib
|
|
8
|
+
import importlib
|
|
9
|
+
import importlib.metadata
|
|
10
|
+
import sys
|
|
11
|
+
import sysconfig
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from types import ModuleType
|
|
15
|
+
|
|
16
|
+
from uv_launch.dependencies import distribution_requirement
|
|
17
|
+
from uv_launch.records import metadata_paths
|
|
18
|
+
|
|
19
|
+
from ._errors import InvalidToolError, ToolConflictError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class PackagedTool:
|
|
24
|
+
module: str
|
|
25
|
+
description: str
|
|
26
|
+
requirement: str
|
|
27
|
+
requires_python: str | None
|
|
28
|
+
distribution: str
|
|
29
|
+
version: str
|
|
30
|
+
source: Path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _InstalledPackages:
|
|
35
|
+
owners: dict[Path, list[tuple[importlib.metadata.Distribution, importlib.metadata.PackagePath]]]
|
|
36
|
+
declarations: set[str]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
PackageIndex = dict[tuple[Path, ...], _InstalledPackages]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _index(paths: tuple[Path, ...], cache: PackageIndex) -> _InstalledPackages:
|
|
43
|
+
if paths not in cache:
|
|
44
|
+
owners = {}
|
|
45
|
+
declarations = set()
|
|
46
|
+
for distribution in importlib.metadata.distributions(path=[str(path) for path in paths]):
|
|
47
|
+
declarations.update(
|
|
48
|
+
entry.name for entry in distribution.entry_points if entry.group == "toolfuncs"
|
|
49
|
+
)
|
|
50
|
+
for file in distribution.files or ():
|
|
51
|
+
path = Path(str(distribution.locate_file(file))).resolve()
|
|
52
|
+
owners.setdefault(path, []).append((distribution, file))
|
|
53
|
+
cache[paths] = _InstalledPackages(owners, declarations)
|
|
54
|
+
return cache[paths]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _verify_file(source: Path, file: importlib.metadata.PackagePath) -> None:
|
|
58
|
+
if file.hash is None or file.hash.mode not in {"sha256", "sha384", "sha512"}:
|
|
59
|
+
raise InvalidToolError(f"{source}: installed launcher requires a strong RECORD checksum")
|
|
60
|
+
with source.open("rb") as stream:
|
|
61
|
+
digest = hashlib.file_digest(stream, file.hash.mode).digest()
|
|
62
|
+
actual = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
|
|
63
|
+
if actual != file.hash.value or (file.size is not None and source.stat().st_size != file.size):
|
|
64
|
+
raise InvalidToolError(f"{source}: installed launcher checksum does not match RECORD")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def inspect_package(
|
|
68
|
+
source: Path, cli_name: str, cache: PackageIndex | None = None
|
|
69
|
+
) -> PackagedTool | None:
|
|
70
|
+
try:
|
|
71
|
+
return _inspect_package(source, cli_name, cache)
|
|
72
|
+
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
73
|
+
raise InvalidToolError(f"{source}: invalid installed tool metadata: {error}") from error
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _inspect_package(
|
|
77
|
+
source: Path, cli_name: str, cache: PackageIndex | None
|
|
78
|
+
) -> PackagedTool | None:
|
|
79
|
+
source = source.resolve(strict=True)
|
|
80
|
+
paths = metadata_paths(source)
|
|
81
|
+
installed_bin = (
|
|
82
|
+
paths is not None or source.parent == Path(sysconfig.get_path("scripts")).resolve()
|
|
83
|
+
)
|
|
84
|
+
if paths is None:
|
|
85
|
+
paths = tuple(
|
|
86
|
+
sorted({Path(value).resolve() for value in sys.path if value and Path(value).is_dir()})
|
|
87
|
+
)
|
|
88
|
+
index = _index(paths, {} if cache is None else cache)
|
|
89
|
+
owners = index.owners.get(source, [])
|
|
90
|
+
if not owners and installed_bin and source.name in index.declarations:
|
|
91
|
+
raise InvalidToolError(f"{source}: declared package tool has no RECORD ownership")
|
|
92
|
+
|
|
93
|
+
# Entry-point identity comes from the recorded executable, including when a
|
|
94
|
+
# public managed shim gives the installation a different name.
|
|
95
|
+
entry_name = source.name
|
|
96
|
+
if not any(
|
|
97
|
+
any(
|
|
98
|
+
entry.group == "toolfuncs" and entry.name == entry_name
|
|
99
|
+
for entry in distribution.entry_points
|
|
100
|
+
)
|
|
101
|
+
for distribution, _ in owners
|
|
102
|
+
):
|
|
103
|
+
return None
|
|
104
|
+
if len(owners) != 1:
|
|
105
|
+
raise InvalidToolError(
|
|
106
|
+
f"{source}: toolfuncs package metadata does not uniquely own this launcher"
|
|
107
|
+
)
|
|
108
|
+
distribution, file = owners[0]
|
|
109
|
+
_verify_file(source, file)
|
|
110
|
+
entries = [entry for entry in distribution.entry_points if entry.name == entry_name]
|
|
111
|
+
tool = [entry for entry in entries if entry.group == "toolfuncs"]
|
|
112
|
+
console = [entry for entry in entries if entry.group == "console_scripts"]
|
|
113
|
+
if len(tool) != 1 or len(console) != 1 or tool[0].value != console[0].value:
|
|
114
|
+
raise InvalidToolError(f"{source}: toolfuncs and console_scripts entry points must match")
|
|
115
|
+
target = tool[0].value
|
|
116
|
+
module, _, attribute = target.partition(":")
|
|
117
|
+
if attribute != "app" or not all(part.isidentifier() for part in module.split(".")):
|
|
118
|
+
raise InvalidToolError(f"{source}: packaged tool entry point must name module:app")
|
|
119
|
+
sources = _module_sources(distribution, module)
|
|
120
|
+
if len(sources) != 1:
|
|
121
|
+
raise InvalidToolError(f"{source}: package must contain inspectable source for {module}")
|
|
122
|
+
try:
|
|
123
|
+
doc = ast.get_docstring(ast.parse(sources[0].read_text(encoding="utf-8")))
|
|
124
|
+
except (OSError, UnicodeError, SyntaxError) as error:
|
|
125
|
+
raise InvalidToolError(
|
|
126
|
+
f"{source}: cannot inspect package module source: {error}"
|
|
127
|
+
) from error
|
|
128
|
+
if not doc or not doc.splitlines()[0].strip():
|
|
129
|
+
raise InvalidToolError(f"{source}: package module must have a descriptive docstring")
|
|
130
|
+
requires_python = None
|
|
131
|
+
if "Requires-Python" in distribution.metadata:
|
|
132
|
+
requires_python = distribution.metadata["Requires-Python"]
|
|
133
|
+
return PackagedTool(
|
|
134
|
+
module,
|
|
135
|
+
doc.splitlines()[0].strip(),
|
|
136
|
+
_requirement(distribution, source),
|
|
137
|
+
requires_python,
|
|
138
|
+
distribution.metadata["Name"],
|
|
139
|
+
distribution.version,
|
|
140
|
+
sources[0],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _module_sources(distribution: importlib.metadata.Distribution, module: str) -> list[Path]:
|
|
145
|
+
candidates = {module.replace(".", "/") + ".py", module.replace(".", "/") + "/__init__.py"}
|
|
146
|
+
return [
|
|
147
|
+
Path(str(distribution.locate_file(file)))
|
|
148
|
+
for file in distribution.files or ()
|
|
149
|
+
if str(file) in candidates
|
|
150
|
+
]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _requirement(distribution: importlib.metadata.Distribution, source: Path) -> str:
|
|
154
|
+
try:
|
|
155
|
+
return distribution_requirement(distribution)
|
|
156
|
+
except ValueError as error:
|
|
157
|
+
raise InvalidToolError(f"{source}: {error}") from error
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def load_package(
|
|
161
|
+
package: PackagedTool, source: Path, *, dependencies_prepared: bool = False
|
|
162
|
+
) -> ModuleType:
|
|
163
|
+
"""Prepare the owning distribution, then return its real module in this process."""
|
|
164
|
+
from ._tools import _Metadata, _prepare_environment
|
|
165
|
+
|
|
166
|
+
metadata = _Metadata(
|
|
167
|
+
(package.requirement,),
|
|
168
|
+
package.requires_python,
|
|
169
|
+
package.description,
|
|
170
|
+
source.name,
|
|
171
|
+
source.name.replace("-", "_"),
|
|
172
|
+
False,
|
|
173
|
+
)
|
|
174
|
+
try:
|
|
175
|
+
installed = importlib.metadata.distribution(package.distribution)
|
|
176
|
+
prepared = (
|
|
177
|
+
installed.version == package.version
|
|
178
|
+
and _requirement(installed, source) == package.requirement
|
|
179
|
+
and len(_module_sources(installed, package.module)) == 1
|
|
180
|
+
)
|
|
181
|
+
except importlib.metadata.PackageNotFoundError:
|
|
182
|
+
prepared = False
|
|
183
|
+
if package.module in sys.modules and not prepared:
|
|
184
|
+
raise ToolConflictError(
|
|
185
|
+
f"{package.module} is already imported from a different installation"
|
|
186
|
+
)
|
|
187
|
+
_prepare_environment(metadata, source=source, install_dependencies=not dependencies_prepared)
|
|
188
|
+
installed = importlib.metadata.distribution(package.distribution)
|
|
189
|
+
if (
|
|
190
|
+
installed.version != package.version
|
|
191
|
+
or _requirement(installed, source) != package.requirement
|
|
192
|
+
):
|
|
193
|
+
raise ToolConflictError(
|
|
194
|
+
f"{package.module} does not match the selected distribution version and origin"
|
|
195
|
+
)
|
|
196
|
+
module = importlib.import_module(package.module)
|
|
197
|
+
sources = _module_sources(installed, package.module)
|
|
198
|
+
if (
|
|
199
|
+
len(sources) != 1
|
|
200
|
+
or module.__file__ is None
|
|
201
|
+
or Path(module.__file__).resolve() != sources[0].resolve()
|
|
202
|
+
):
|
|
203
|
+
raise ToolConflictError(
|
|
204
|
+
f"{package.module} does not resolve to the selected installed distribution"
|
|
205
|
+
)
|
|
206
|
+
from .sdk import _application
|
|
207
|
+
|
|
208
|
+
_application(module)
|
|
209
|
+
return module
|
|
@@ -73,17 +73,15 @@ def _prepare_launchers() -> tuple[Path, Path]:
|
|
|
73
73
|
if uv is None:
|
|
74
74
|
raise SetupError("uv must be installed and available on PATH")
|
|
75
75
|
try:
|
|
76
|
-
|
|
77
|
-
tools = subprocess.check_output([uv, "tool", "dir"], text=True).strip()
|
|
78
|
-
manager = Path(tools) / "uv-launch/bin/uv-launch"
|
|
76
|
+
manager = [uv, "tool", "run", "--from", "uv-launch>=0.2.6,<0.3", "uv-launch"]
|
|
79
77
|
destination = state_root() / "bin"
|
|
80
78
|
for name in ("toolfuncs", "toolfuncs-agent-hook"):
|
|
81
79
|
subprocess.run(
|
|
82
80
|
[
|
|
83
|
-
|
|
81
|
+
*manager,
|
|
84
82
|
"install",
|
|
85
83
|
name,
|
|
86
|
-
"toolfuncs[hooks]>=0.12.
|
|
84
|
+
"toolfuncs[hooks]>=0.12.3,<0.13",
|
|
87
85
|
"--bin-dir",
|
|
88
86
|
str(destination),
|
|
89
87
|
],
|
|
@@ -107,8 +107,8 @@ def _discover(*, path: str | None = None) -> Catalog:
|
|
|
107
107
|
target = _managed_source(entry)
|
|
108
108
|
if _has_tool_shebang(target):
|
|
109
109
|
tools[name] = _inspect(target, cli_name=name)
|
|
110
|
-
elif (package := inspect_package(
|
|
111
|
-
tools[name] = _packaged_tool(
|
|
110
|
+
elif (package := inspect_package(target, name, packages)) is not None:
|
|
111
|
+
tools[name] = _packaged_tool(target, name, package)
|
|
112
112
|
except InvalidToolError as exc:
|
|
113
113
|
error = str(exc)
|
|
114
114
|
prefix = f"{entry}: "
|
|
@@ -135,8 +135,8 @@ def _resolve(python_name: str, *, path: str | None = None) -> Tool:
|
|
|
135
135
|
continue
|
|
136
136
|
target = _managed_source(candidate)
|
|
137
137
|
if not _has_tool_shebang(target):
|
|
138
|
-
if (package := inspect_package(
|
|
139
|
-
return _packaged_tool(
|
|
138
|
+
if (package := inspect_package(target, cli_name)) is not None:
|
|
139
|
+
return _packaged_tool(target, cli_name, package)
|
|
140
140
|
raise ToolNotFoundError(
|
|
141
141
|
f"command {cli_name!r} resolves to {candidate}, which is not a toolfuncs script"
|
|
142
142
|
)
|
|
@@ -29,7 +29,7 @@ from rich.console import Console
|
|
|
29
29
|
|
|
30
30
|
from ._descriptions import _publish_description
|
|
31
31
|
from ._errors import ToolDefinitionError
|
|
32
|
-
from ._tools import _python_name_for_cli_name
|
|
32
|
+
from ._tools import Catalog, _discover, _python_name_for_cli_name
|
|
33
33
|
|
|
34
34
|
_OUTPUT_ENVIRONMENT_VARIABLE = "TOOLFUNCS_OUTPUT"
|
|
35
35
|
_OutputMode = Literal["terminal", "machine"]
|
|
@@ -37,6 +37,15 @@ _CONTROL_RESULT = object()
|
|
|
37
37
|
_T = TypeVar("_T", bound=Callable[..., Any])
|
|
38
38
|
|
|
39
39
|
|
|
40
|
+
def discover(*, path: str | None = None) -> Catalog:
|
|
41
|
+
"""Inspect effective PATH tools without imports, subprocesses, or preparation.
|
|
42
|
+
|
|
43
|
+
The result separates valid tools from invalid opted-in commands. The first
|
|
44
|
+
executable always claims its name, even when it is not a supported tool.
|
|
45
|
+
"""
|
|
46
|
+
return _discover(path=path)
|
|
47
|
+
|
|
48
|
+
|
|
40
49
|
def json_result_action() -> ResultAction:
|
|
41
50
|
"""Return the standard strict-JSON Cyclopts result action."""
|
|
42
51
|
|
|
@@ -217,10 +226,12 @@ def _run(module: ModuleType, tokens: str | Iterable[str] | None = None) -> Any:
|
|
|
217
226
|
__all__ = [
|
|
218
227
|
"UNSET",
|
|
219
228
|
"App",
|
|
229
|
+
"Catalog",
|
|
220
230
|
"Group",
|
|
221
231
|
"Parameter",
|
|
222
232
|
"ResultAction",
|
|
223
233
|
"Token",
|
|
234
|
+
"discover",
|
|
224
235
|
"json_result_action",
|
|
225
236
|
"publish_description",
|
|
226
237
|
"to_jsonable_python",
|
|
@@ -12,7 +12,7 @@ from importlib.metadata import distribution
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
import pytest
|
|
15
|
-
from uv_launch.dependencies import distribution_requirement
|
|
15
|
+
from uv_launch.dependencies import distribution_requirement, managed_target
|
|
16
16
|
|
|
17
17
|
from toolfuncs._errors import ToolNotFoundError
|
|
18
18
|
from toolfuncs._tools import _discover, _resolve
|
|
@@ -86,13 +86,25 @@ allow-direct-references = true
|
|
|
86
86
|
environment = {
|
|
87
87
|
**os.environ,
|
|
88
88
|
# Exercise installer-generated /bin/sh wrappers for long interpreter paths.
|
|
89
|
-
"
|
|
89
|
+
"UV_LAUNCH_HOME": str(
|
|
90
90
|
root / ("long-environment-" + "x" * 100 if request.param == "long" else "tools")
|
|
91
91
|
),
|
|
92
|
-
"UV_TOOL_BIN_DIR": str(binary),
|
|
93
92
|
}
|
|
94
93
|
result = subprocess.run(
|
|
95
|
-
[
|
|
94
|
+
[
|
|
95
|
+
sys.executable,
|
|
96
|
+
"-m",
|
|
97
|
+
"uv_launch",
|
|
98
|
+
"install",
|
|
99
|
+
"package-echo",
|
|
100
|
+
str(project),
|
|
101
|
+
"--python",
|
|
102
|
+
sys.executable,
|
|
103
|
+
"--bin-dir",
|
|
104
|
+
str(binary),
|
|
105
|
+
"--with",
|
|
106
|
+
distribution_requirement(distribution("uv-launch")),
|
|
107
|
+
],
|
|
96
108
|
env=environment,
|
|
97
109
|
capture_output=True,
|
|
98
110
|
text=True,
|
|
@@ -107,8 +119,7 @@ def test_real_uv_install_static_discovery_and_native_cli(installed_package, monk
|
|
|
107
119
|
marker = root / "import-marker"
|
|
108
120
|
monkeypatch.setenv("TOOLFUNCS_TEST_MARKER", str(marker))
|
|
109
121
|
command = binary / "package-echo"
|
|
110
|
-
assert command
|
|
111
|
-
assert command.read_text().startswith("#!/bin/sh\n") == (len(environment["UV_TOOL_DIR"]) > 100)
|
|
122
|
+
assert managed_target(command) is not None
|
|
112
123
|
catalog = _discover(path=str(binary))
|
|
113
124
|
assert catalog.invalid_tools == ()
|
|
114
125
|
assert len(catalog.tools) == 1
|
|
@@ -182,49 +193,66 @@ def test_normal_path_shadowing_and_tampered_launcher(installed_package, tmp_path
|
|
|
182
193
|
assert not _discover(path=path).tools
|
|
183
194
|
with pytest.raises(ToolNotFoundError, match="not a toolfuncs"):
|
|
184
195
|
_resolve("package_echo", path=path)
|
|
185
|
-
command = binary / "package-echo"
|
|
196
|
+
command = managed_target(binary / "package-echo")
|
|
197
|
+
assert command is not None
|
|
186
198
|
original = command.read_text()
|
|
187
199
|
try:
|
|
188
200
|
command.write_text(original.replace("sys.exit(app())", "sys.exit(other())"))
|
|
189
201
|
catalog = _discover(path=str(binary))
|
|
190
202
|
assert not catalog.tools
|
|
191
|
-
assert "
|
|
203
|
+
assert "checksum" in catalog.invalid_tools[0].error
|
|
192
204
|
finally:
|
|
193
205
|
command.write_text(original)
|
|
194
206
|
|
|
195
207
|
|
|
196
208
|
@pytest.mark.parametrize(
|
|
197
|
-
"
|
|
209
|
+
"body",
|
|
198
210
|
[
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
("sys.argv[0].removesuffix('.exe', extra())", False),
|
|
211
|
+
"#!/bin/sh\nexec arbitrary-unexecuted-wrapper\n",
|
|
212
|
+
"#!/some/future/installer\nthis is not Python syntax\n",
|
|
202
213
|
],
|
|
203
214
|
)
|
|
204
|
-
def
|
|
215
|
+
def test_recorded_wrapper_bytes_do_not_need_a_parser(installed_package, body, monkeypatch):
|
|
216
|
+
import base64
|
|
217
|
+
import csv
|
|
218
|
+
import hashlib
|
|
219
|
+
import io
|
|
220
|
+
|
|
205
221
|
root, binary, _ = installed_package
|
|
206
|
-
|
|
222
|
+
command = managed_target(binary / "package-echo")
|
|
223
|
+
assert command is not None
|
|
224
|
+
tool = _discover(path=str(binary)).tools[0]
|
|
225
|
+
assert tool.package is not None
|
|
226
|
+
record = tool.package.source.parents[1] / "fixture_packaged_tool-0.0.1.dist-info/RECORD"
|
|
227
|
+
original, original_record = command.read_bytes(), record.read_text()
|
|
228
|
+
marker = root / "unexecuted-wrapper-marker"
|
|
207
229
|
monkeypatch.setenv("TOOLFUNCS_TEST_MARKER", str(marker))
|
|
208
|
-
command = binary / "package-echo"
|
|
209
|
-
original = command.read_text()
|
|
210
|
-
prefix, _ = original.split("\nif __name__", 1)
|
|
211
230
|
try:
|
|
212
|
-
command.write_text(
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
231
|
+
command.write_text(body)
|
|
232
|
+
rows = list(csv.reader(io.StringIO(original_record)))
|
|
233
|
+
for row in rows:
|
|
234
|
+
if row[0].endswith("bin/package-echo"):
|
|
235
|
+
digest = (
|
|
236
|
+
base64.urlsafe_b64encode(hashlib.sha256(body.encode()).digest())
|
|
237
|
+
.rstrip(b"=")
|
|
238
|
+
.decode()
|
|
239
|
+
)
|
|
240
|
+
row[1:] = ["sha256=" + digest, str(len(body.encode()))]
|
|
241
|
+
output = io.StringIO()
|
|
242
|
+
csv.writer(output).writerows(rows)
|
|
243
|
+
record.write_text(output.getvalue())
|
|
244
|
+
|
|
245
|
+
def forbidden(*args, **kwargs):
|
|
246
|
+
raise AssertionError("discovery started a process")
|
|
247
|
+
|
|
248
|
+
monkeypatch.setattr(subprocess, "Popen", forbidden)
|
|
218
249
|
catalog = _discover(path=str(binary))
|
|
219
|
-
assert not
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
assert [tool.cli_name for tool in catalog.tools] == ["package-echo"]
|
|
223
|
-
else:
|
|
224
|
-
assert not catalog.tools
|
|
225
|
-
assert "launcher target" in catalog.invalid_tools[0].error
|
|
250
|
+
assert not catalog.invalid_tools
|
|
251
|
+
assert [tool.cli_name for tool in catalog.tools] == ["package-echo"]
|
|
252
|
+
assert not marker.exists()
|
|
226
253
|
finally:
|
|
227
|
-
command.
|
|
254
|
+
command.write_bytes(original)
|
|
255
|
+
record.write_text(original_record)
|
|
228
256
|
|
|
229
257
|
|
|
230
258
|
def test_package_metadata_must_opt_in_and_match(installed_package):
|
|
@@ -249,3 +277,108 @@ def test_package_metadata_must_opt_in_and_match(installed_package):
|
|
|
249
277
|
assert "entry points must match" in catalog.invalid_tools[0].error
|
|
250
278
|
finally:
|
|
251
279
|
metadata.write_text(original)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def test_current_interpreter_needs_no_managed_environment_record(installed_package):
|
|
283
|
+
_, binary, _ = installed_package
|
|
284
|
+
command = managed_target(binary / "package-echo")
|
|
285
|
+
assert command is not None
|
|
286
|
+
locator = command.parent / ".uv-launch.json"
|
|
287
|
+
original = locator.read_bytes()
|
|
288
|
+
try:
|
|
289
|
+
locator.unlink()
|
|
290
|
+
result = subprocess.run(
|
|
291
|
+
[
|
|
292
|
+
str(command.parent / "python"),
|
|
293
|
+
"-I",
|
|
294
|
+
"-c",
|
|
295
|
+
"from toolfuncs._tools import _discover; import sys; "
|
|
296
|
+
"c = _discover(path=sys.argv[1]); "
|
|
297
|
+
"assert not c.invalid_tools, c; "
|
|
298
|
+
"assert [t.cli_name for t in c.tools] == ['package-echo'], c",
|
|
299
|
+
str(command.parent),
|
|
300
|
+
],
|
|
301
|
+
capture_output=True,
|
|
302
|
+
text=True,
|
|
303
|
+
)
|
|
304
|
+
assert result.returncode == 0, result.stderr
|
|
305
|
+
finally:
|
|
306
|
+
locator.write_bytes(original)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def test_unrelated_distribution_cannot_also_own_launcher(installed_package):
|
|
310
|
+
_, binary, _ = installed_package
|
|
311
|
+
package = _discover(path=str(binary)).tools[0].package
|
|
312
|
+
assert package is not None
|
|
313
|
+
metadata_root = package.source.parents[1]
|
|
314
|
+
other = metadata_root / "unrelated_owner-0.1.dist-info"
|
|
315
|
+
other.mkdir()
|
|
316
|
+
original_record = (metadata_root / "fixture_packaged_tool-0.0.1.dist-info/RECORD").read_text()
|
|
317
|
+
try:
|
|
318
|
+
(other / "METADATA").write_text(
|
|
319
|
+
"Metadata-Version: 2.1\nName: unrelated-owner\nVersion: 0.1\n"
|
|
320
|
+
)
|
|
321
|
+
(other / "RECORD").write_text(
|
|
322
|
+
next(line for line in original_record.splitlines() if "bin/package-echo," in line)
|
|
323
|
+
+ "\n"
|
|
324
|
+
)
|
|
325
|
+
catalog = _discover(path=str(binary))
|
|
326
|
+
assert not catalog.tools
|
|
327
|
+
assert "uniquely own" in catalog.invalid_tools[0].error
|
|
328
|
+
finally:
|
|
329
|
+
shutil.rmtree(other)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@pytest.mark.parametrize("damage", ["absent", "empty-hash"])
|
|
333
|
+
def test_declared_package_requires_recorded_ownership_and_hash(installed_package, damage):
|
|
334
|
+
import csv
|
|
335
|
+
import io
|
|
336
|
+
|
|
337
|
+
_, binary, _ = installed_package
|
|
338
|
+
package = _discover(path=str(binary)).tools[0].package
|
|
339
|
+
assert package is not None
|
|
340
|
+
record = package.source.parents[1] / "fixture_packaged_tool-0.0.1.dist-info/RECORD"
|
|
341
|
+
original = record.read_text()
|
|
342
|
+
try:
|
|
343
|
+
if damage == "absent":
|
|
344
|
+
record.unlink()
|
|
345
|
+
else:
|
|
346
|
+
rows = list(csv.reader(io.StringIO(original)))
|
|
347
|
+
for row in rows:
|
|
348
|
+
if row[0].endswith("bin/package-echo"):
|
|
349
|
+
row[1] = ""
|
|
350
|
+
output = io.StringIO()
|
|
351
|
+
csv.writer(output).writerows(rows)
|
|
352
|
+
record.write_text(output.getvalue())
|
|
353
|
+
catalog = _discover(path=str(binary))
|
|
354
|
+
assert not catalog.tools
|
|
355
|
+
assert "RECORD" in catalog.invalid_tools[0].error
|
|
356
|
+
finally:
|
|
357
|
+
record.write_text(original)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def test_prepared_load_checks_selected_version_before_import(installed_package):
|
|
361
|
+
_, binary, _ = installed_package
|
|
362
|
+
command = managed_target(binary / "package-echo")
|
|
363
|
+
assert command is not None
|
|
364
|
+
code = """
|
|
365
|
+
import sys
|
|
366
|
+
from dataclasses import replace
|
|
367
|
+
from pathlib import Path
|
|
368
|
+
from toolfuncs._packages import inspect_package, load_package
|
|
369
|
+
from toolfuncs._errors import ToolConflictError
|
|
370
|
+
source = Path(sys.argv[1])
|
|
371
|
+
package = inspect_package(source, 'package-echo')
|
|
372
|
+
try:
|
|
373
|
+
load_package(replace(package, version='999'), source, dependencies_prepared=True)
|
|
374
|
+
except ToolConflictError:
|
|
375
|
+
assert 'fixture_packaged_tool.operations' not in sys.modules
|
|
376
|
+
else:
|
|
377
|
+
raise AssertionError('wrong version was imported')
|
|
378
|
+
"""
|
|
379
|
+
result = subprocess.run(
|
|
380
|
+
[str(command.parent / "python"), "-I", "-c", code, str(command)],
|
|
381
|
+
capture_output=True,
|
|
382
|
+
text=True,
|
|
383
|
+
)
|
|
384
|
+
assert result.returncode == 0, result.stderr
|
|
@@ -253,6 +253,15 @@ wheels = [
|
|
|
253
253
|
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
|
|
254
254
|
]
|
|
255
255
|
|
|
256
|
+
[[package]]
|
|
257
|
+
name = "platformdirs"
|
|
258
|
+
version = "4.13.0"
|
|
259
|
+
source = { registry = "https://pypi.org/simple" }
|
|
260
|
+
sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094, upload-time = "2026-10-11T02:05:24.109Z" }
|
|
261
|
+
wheels = [
|
|
262
|
+
{ url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724, upload-time = "2026-10-11T02:05:22.776Z" },
|
|
263
|
+
]
|
|
264
|
+
|
|
256
265
|
[[package]]
|
|
257
266
|
name = "pluggy"
|
|
258
267
|
version = "1.6.0"
|
|
@@ -534,7 +543,7 @@ wheels = [
|
|
|
534
543
|
|
|
535
544
|
[[package]]
|
|
536
545
|
name = "toolfuncs"
|
|
537
|
-
version = "0.12.
|
|
546
|
+
version = "0.12.3"
|
|
538
547
|
source = { editable = "." }
|
|
539
548
|
dependencies = [
|
|
540
549
|
{ name = "cyclopts" },
|
|
@@ -567,7 +576,7 @@ requires-dist = [
|
|
|
567
576
|
{ name = "pydantic", specifier = ">=2,<3" },
|
|
568
577
|
{ name = "pydantic-core", specifier = ">=2,<3" },
|
|
569
578
|
{ name = "typed-agent-hooks", marker = "extra == 'hooks'" },
|
|
570
|
-
{ name = "uv-launch", specifier = ">=0.2.
|
|
579
|
+
{ name = "uv-launch", specifier = ">=0.2.6,<0.3" },
|
|
571
580
|
]
|
|
572
581
|
provides-extras = ["hooks"]
|
|
573
582
|
|
|
@@ -619,12 +628,13 @@ wheels = [
|
|
|
619
628
|
|
|
620
629
|
[[package]]
|
|
621
630
|
name = "uv-launch"
|
|
622
|
-
version = "0.2.
|
|
631
|
+
version = "0.2.6"
|
|
623
632
|
source = { registry = "https://pypi.org/simple" }
|
|
624
633
|
dependencies = [
|
|
625
634
|
{ name = "packaging" },
|
|
635
|
+
{ name = "platformdirs" },
|
|
626
636
|
]
|
|
627
|
-
sdist = { url = "https://files.pythonhosted.org/packages/
|
|
637
|
+
sdist = { url = "https://files.pythonhosted.org/packages/06/b6/7d97e244b95d169309a16dce5dff852b20817874fc52ebd670d631778042/uv_launch-0.2.6.tar.gz", hash = "sha256:52920e830a68aebe89718a33d24b74fd708e82b7796f1ca9c984dacb096dbdcf", size = 25604, upload-time = "2026-10-13T15:51:53.769Z" }
|
|
628
638
|
wheels = [
|
|
629
|
-
{ url = "https://files.pythonhosted.org/packages/
|
|
639
|
+
{ url = "https://files.pythonhosted.org/packages/53/13/ae9ac8154b9bb24952eca30f5f51741b2ca4860d4c370c9733c2668bd4bc/uv_launch-0.2.6-py3-none-any.whl", hash = "sha256:7395aaaec3d5dcfb01a003baa92ba5a1517caaf3972009c2cdde9f97f1296c6d", size = 28053, upload-time = "2026-10-13T15:51:52.603Z" },
|
|
630
640
|
]
|
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
"""Static discovery of opted-in, normally installed Python console scripts."""
|
|
2
|
-
|
|
3
|
-
from __future__ import annotations
|
|
4
|
-
|
|
5
|
-
import ast
|
|
6
|
-
import importlib
|
|
7
|
-
import importlib.metadata
|
|
8
|
-
import re
|
|
9
|
-
import shlex
|
|
10
|
-
import shutil
|
|
11
|
-
import sys
|
|
12
|
-
from dataclasses import dataclass
|
|
13
|
-
from pathlib import Path
|
|
14
|
-
from types import ModuleType
|
|
15
|
-
|
|
16
|
-
from uv_launch.dependencies import distribution_requirement, managed_target
|
|
17
|
-
|
|
18
|
-
from ._errors import InvalidToolError, ToolConflictError
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
@dataclass(frozen=True, slots=True)
|
|
22
|
-
class PackagedTool:
|
|
23
|
-
module: str
|
|
24
|
-
description: str
|
|
25
|
-
requirement: str
|
|
26
|
-
requires_python: str | None
|
|
27
|
-
distribution: str
|
|
28
|
-
version: str
|
|
29
|
-
source: Path
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
PackageIndex = dict[Path, dict[str, list[importlib.metadata.Distribution]]]
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def _interpreter(source: Path) -> tuple[Path, str] | None:
|
|
36
|
-
try:
|
|
37
|
-
with source.open("rb") as stream:
|
|
38
|
-
first = stream.readline(4096)
|
|
39
|
-
if not first.startswith(b"#!"):
|
|
40
|
-
return None
|
|
41
|
-
if b"python" not in first and first.rstrip() != b"#!/bin/sh":
|
|
42
|
-
return None
|
|
43
|
-
prefix = first
|
|
44
|
-
if first.rstrip() == b"#!/bin/sh":
|
|
45
|
-
second = stream.readline(4096)
|
|
46
|
-
if not second.startswith(b"'''exec' "):
|
|
47
|
-
return None
|
|
48
|
-
prefix += second
|
|
49
|
-
raw = prefix + stream.read(65537 - len(prefix))
|
|
50
|
-
if len(raw) > 65536:
|
|
51
|
-
return None
|
|
52
|
-
text = raw.decode("utf-8")
|
|
53
|
-
except (OSError, UnicodeError):
|
|
54
|
-
return None
|
|
55
|
-
lines = text.splitlines()
|
|
56
|
-
if not lines or not lines[0].startswith("#!"):
|
|
57
|
-
return None
|
|
58
|
-
try:
|
|
59
|
-
words = shlex.split(lines[0][2:])
|
|
60
|
-
if words == ["/bin/sh"]:
|
|
61
|
-
if len(lines) < 3 or lines[2] != "' '''":
|
|
62
|
-
return None
|
|
63
|
-
match = re.fullmatch(r"'''exec' (.+) \"\$0\" \"\$@\"", lines[1])
|
|
64
|
-
if match is None:
|
|
65
|
-
return None
|
|
66
|
-
words = shlex.split(match[1])
|
|
67
|
-
if len(words) == 2 and words[0] == "/usr/bin/env":
|
|
68
|
-
resolved = shutil.which(words[1])
|
|
69
|
-
if resolved is None:
|
|
70
|
-
return None
|
|
71
|
-
words = [resolved]
|
|
72
|
-
if len(words) != 1 or not Path(words[0]).name.startswith("python"):
|
|
73
|
-
return None
|
|
74
|
-
interpreter = Path(words[0])
|
|
75
|
-
if not interpreter.is_absolute() or not interpreter.is_file():
|
|
76
|
-
return None
|
|
77
|
-
# Do not resolve this symlink: its containing venv owns the script.
|
|
78
|
-
return interpreter, text
|
|
79
|
-
except ValueError:
|
|
80
|
-
return None
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def _index(
|
|
84
|
-
interpreter: Path, cache: PackageIndex
|
|
85
|
-
) -> dict[str, list[importlib.metadata.Distribution]]:
|
|
86
|
-
prefix = interpreter.parent.parent
|
|
87
|
-
if prefix in cache:
|
|
88
|
-
return cache[prefix]
|
|
89
|
-
paths = list(prefix.glob("lib/python*/site-packages"))
|
|
90
|
-
paths.extend(prefix.glob("Lib/site-packages"))
|
|
91
|
-
if prefix == Path(sys.prefix):
|
|
92
|
-
paths.extend(Path(value) for value in sys.path if value)
|
|
93
|
-
index: dict[str, list[importlib.metadata.Distribution]] = {}
|
|
94
|
-
for distribution in importlib.metadata.distributions(
|
|
95
|
-
path=[str(path) for path in {path.resolve() for path in paths}]
|
|
96
|
-
):
|
|
97
|
-
for entry in distribution.entry_points:
|
|
98
|
-
if entry.group == "toolfuncs":
|
|
99
|
-
index.setdefault(entry.name, []).append(distribution)
|
|
100
|
-
cache[prefix] = index
|
|
101
|
-
return index
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def _launcher_target(text: str) -> str | None:
|
|
105
|
-
"""Recognize installer wrappers without evaluating them or their imports."""
|
|
106
|
-
try:
|
|
107
|
-
tree = ast.parse(text)
|
|
108
|
-
except SyntaxError:
|
|
109
|
-
return None
|
|
110
|
-
imported: dict[str, str] = {}
|
|
111
|
-
guards: list[ast.If] = []
|
|
112
|
-
for node in tree.body:
|
|
113
|
-
if (
|
|
114
|
-
isinstance(node, ast.Expr)
|
|
115
|
-
and isinstance(node.value, ast.Constant)
|
|
116
|
-
and isinstance(node.value.value, str)
|
|
117
|
-
):
|
|
118
|
-
continue
|
|
119
|
-
if isinstance(node, ast.Import) and all(
|
|
120
|
-
value.name in {"sys", "re"} and value.asname is None for value in node.names
|
|
121
|
-
):
|
|
122
|
-
continue
|
|
123
|
-
if isinstance(node, ast.ImportFrom) and node.module and node.level == 0:
|
|
124
|
-
for name in node.names:
|
|
125
|
-
if (name.asname or name.name) in {"sys", "re"}:
|
|
126
|
-
return None
|
|
127
|
-
imported[name.asname or name.name] = f"{node.module}:{name.name}"
|
|
128
|
-
continue
|
|
129
|
-
if isinstance(node, ast.If) and ast.dump(node.test) == ast.dump(
|
|
130
|
-
ast.parse("__name__ == '__main__'", mode="eval").body
|
|
131
|
-
):
|
|
132
|
-
guards.append(node)
|
|
133
|
-
continue
|
|
134
|
-
return None
|
|
135
|
-
if len(imported) != 1 or len(guards) != 1 or guards[0].orelse:
|
|
136
|
-
return None
|
|
137
|
-
body = guards[0].body
|
|
138
|
-
if not body or not isinstance(body[-1], ast.Expr):
|
|
139
|
-
return None
|
|
140
|
-
exit_call = body[-1].value
|
|
141
|
-
if (
|
|
142
|
-
not isinstance(exit_call, ast.Call)
|
|
143
|
-
or ast.unparse(exit_call.func) != "sys.exit"
|
|
144
|
-
or len(exit_call.args) != 1
|
|
145
|
-
or exit_call.keywords
|
|
146
|
-
):
|
|
147
|
-
return None
|
|
148
|
-
invocation = exit_call.args[0]
|
|
149
|
-
if (
|
|
150
|
-
not isinstance(invocation, ast.Call)
|
|
151
|
-
or invocation.args
|
|
152
|
-
or invocation.keywords
|
|
153
|
-
or not isinstance(invocation.func, ast.Name)
|
|
154
|
-
):
|
|
155
|
-
return None
|
|
156
|
-
# uv and pip/distlib normalize argv[0] before the single target call.
|
|
157
|
-
if not all(_argv_normalization(statement) for statement in body[:-1]):
|
|
158
|
-
return None
|
|
159
|
-
return imported.get(invocation.func.id)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
def _argv_normalization(node: ast.stmt) -> bool:
|
|
163
|
-
if isinstance(node, ast.If):
|
|
164
|
-
test = node.test
|
|
165
|
-
return (
|
|
166
|
-
isinstance(test, ast.Call)
|
|
167
|
-
and ast.unparse(test.func) == "sys.argv[0].endswith"
|
|
168
|
-
and len(test.args) == 1
|
|
169
|
-
and isinstance(test.args[0], ast.Constant)
|
|
170
|
-
and isinstance(test.args[0].value, str)
|
|
171
|
-
and not test.keywords
|
|
172
|
-
and all(_argv_normalization(child) for child in [*node.body, *node.orelse])
|
|
173
|
-
)
|
|
174
|
-
if (
|
|
175
|
-
not isinstance(node, ast.Assign)
|
|
176
|
-
or len(node.targets) != 1
|
|
177
|
-
or ast.unparse(node.targets[0]) != "sys.argv[0]"
|
|
178
|
-
):
|
|
179
|
-
return False
|
|
180
|
-
value = node.value
|
|
181
|
-
if isinstance(value, ast.Call):
|
|
182
|
-
if ast.unparse(value.func) == "sys.argv[0].removesuffix":
|
|
183
|
-
return (
|
|
184
|
-
len(value.args) == 1
|
|
185
|
-
and isinstance(value.args[0], ast.Constant)
|
|
186
|
-
and isinstance(value.args[0].value, str)
|
|
187
|
-
and not value.keywords
|
|
188
|
-
)
|
|
189
|
-
return (
|
|
190
|
-
ast.unparse(value.func) == "re.sub"
|
|
191
|
-
and len(value.args) == 3
|
|
192
|
-
and not value.keywords
|
|
193
|
-
and all(
|
|
194
|
-
isinstance(arg, ast.Constant) and isinstance(arg.value, str)
|
|
195
|
-
for arg in value.args[:2]
|
|
196
|
-
)
|
|
197
|
-
and ast.unparse(value.args[2]) == "sys.argv[0]"
|
|
198
|
-
)
|
|
199
|
-
return (
|
|
200
|
-
isinstance(value, ast.Subscript)
|
|
201
|
-
and ast.unparse(value.value) == "sys.argv[0]"
|
|
202
|
-
and all(
|
|
203
|
-
isinstance(child, (ast.Slice, ast.Constant, ast.UnaryOp, ast.USub, ast.Load))
|
|
204
|
-
for child in ast.walk(value.slice)
|
|
205
|
-
)
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
def inspect_package(
|
|
210
|
-
source: Path, cli_name: str, cache: PackageIndex | None = None
|
|
211
|
-
) -> PackagedTool | None:
|
|
212
|
-
try:
|
|
213
|
-
return _inspect_package(source, cli_name, cache)
|
|
214
|
-
except (OSError, ValueError, KeyError, TypeError) as error:
|
|
215
|
-
raise InvalidToolError(f"{source}: invalid installed tool metadata: {error}") from error
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
def _inspect_package(
|
|
219
|
-
source: Path, cli_name: str, cache: PackageIndex | None
|
|
220
|
-
) -> PackagedTool | None:
|
|
221
|
-
source = managed_target(source) or source
|
|
222
|
-
launcher = _interpreter(source)
|
|
223
|
-
if launcher is None:
|
|
224
|
-
return None
|
|
225
|
-
interpreter, text = launcher
|
|
226
|
-
distributions = _index(interpreter, {} if cache is None else cache).get(cli_name, [])
|
|
227
|
-
if not distributions:
|
|
228
|
-
return None
|
|
229
|
-
matches = []
|
|
230
|
-
for distribution in distributions:
|
|
231
|
-
files = distribution.files or ()
|
|
232
|
-
if any(
|
|
233
|
-
Path(str(distribution.locate_file(file))).resolve() == source.resolve()
|
|
234
|
-
for file in files
|
|
235
|
-
):
|
|
236
|
-
matches.append(distribution)
|
|
237
|
-
if len(matches) != 1:
|
|
238
|
-
raise InvalidToolError(
|
|
239
|
-
f"{source}: toolfuncs package metadata does not uniquely own this launcher"
|
|
240
|
-
)
|
|
241
|
-
distribution = matches[0]
|
|
242
|
-
entries = [entry for entry in distribution.entry_points if entry.name == cli_name]
|
|
243
|
-
tool = [entry for entry in entries if entry.group == "toolfuncs"]
|
|
244
|
-
console = [entry for entry in entries if entry.group == "console_scripts"]
|
|
245
|
-
if len(tool) != 1 or len(console) != 1 or tool[0].value != console[0].value:
|
|
246
|
-
raise InvalidToolError(f"{source}: toolfuncs and console_scripts entry points must match")
|
|
247
|
-
target = tool[0].value
|
|
248
|
-
if _launcher_target(text) != target:
|
|
249
|
-
raise InvalidToolError(
|
|
250
|
-
f"{source}: launcher target does not match its toolfuncs entry point"
|
|
251
|
-
)
|
|
252
|
-
module, _, attribute = target.partition(":")
|
|
253
|
-
if attribute != "app" or not all(part.isidentifier() for part in module.split(".")):
|
|
254
|
-
raise InvalidToolError(f"{source}: packaged tool entry point must name module:app")
|
|
255
|
-
sources = _module_sources(distribution, module)
|
|
256
|
-
if len(sources) != 1:
|
|
257
|
-
raise InvalidToolError(f"{source}: package must contain inspectable source for {module}")
|
|
258
|
-
try:
|
|
259
|
-
doc = ast.get_docstring(ast.parse(sources[0].read_text(encoding="utf-8")))
|
|
260
|
-
except (OSError, UnicodeError, SyntaxError) as error:
|
|
261
|
-
raise InvalidToolError(
|
|
262
|
-
f"{source}: cannot inspect package module source: {error}"
|
|
263
|
-
) from error
|
|
264
|
-
if not doc or not doc.splitlines()[0].strip():
|
|
265
|
-
raise InvalidToolError(f"{source}: package module must have a descriptive docstring")
|
|
266
|
-
return PackagedTool(
|
|
267
|
-
module,
|
|
268
|
-
doc.splitlines()[0].strip(),
|
|
269
|
-
_requirement(distribution, source),
|
|
270
|
-
distribution.metadata.get("Requires-Python"),
|
|
271
|
-
distribution.metadata["Name"],
|
|
272
|
-
distribution.version,
|
|
273
|
-
sources[0],
|
|
274
|
-
)
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
def _module_sources(distribution: importlib.metadata.Distribution, module: str) -> list[Path]:
|
|
278
|
-
candidates = {module.replace(".", "/") + ".py", module.replace(".", "/") + "/__init__.py"}
|
|
279
|
-
return [
|
|
280
|
-
Path(str(distribution.locate_file(file)))
|
|
281
|
-
for file in distribution.files or ()
|
|
282
|
-
if str(file) in candidates
|
|
283
|
-
]
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
def _requirement(distribution: importlib.metadata.Distribution, source: Path) -> str:
|
|
287
|
-
try:
|
|
288
|
-
return distribution_requirement(distribution)
|
|
289
|
-
except ValueError as error:
|
|
290
|
-
raise InvalidToolError(f"{source}: {error}") from error
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def load_package(
|
|
294
|
-
package: PackagedTool, source: Path, *, dependencies_prepared: bool = False
|
|
295
|
-
) -> ModuleType:
|
|
296
|
-
"""Prepare the owning distribution, then return its real module in this process."""
|
|
297
|
-
from ._tools import _Metadata, _prepare_environment
|
|
298
|
-
|
|
299
|
-
metadata = _Metadata(
|
|
300
|
-
(package.requirement,),
|
|
301
|
-
package.requires_python,
|
|
302
|
-
package.description,
|
|
303
|
-
source.name,
|
|
304
|
-
source.name.replace("-", "_"),
|
|
305
|
-
False,
|
|
306
|
-
)
|
|
307
|
-
try:
|
|
308
|
-
installed = importlib.metadata.distribution(package.distribution)
|
|
309
|
-
prepared = (
|
|
310
|
-
installed.version == package.version
|
|
311
|
-
and _requirement(installed, source) == package.requirement
|
|
312
|
-
and len(_module_sources(installed, package.module)) == 1
|
|
313
|
-
)
|
|
314
|
-
except importlib.metadata.PackageNotFoundError:
|
|
315
|
-
prepared = False
|
|
316
|
-
if package.module in sys.modules and not prepared:
|
|
317
|
-
raise ToolConflictError(
|
|
318
|
-
f"{package.module} is already imported from a different installation"
|
|
319
|
-
)
|
|
320
|
-
_prepare_environment(metadata, source=source, install_dependencies=not dependencies_prepared)
|
|
321
|
-
module = importlib.import_module(package.module)
|
|
322
|
-
installed = importlib.metadata.distribution(package.distribution)
|
|
323
|
-
sources = _module_sources(installed, package.module)
|
|
324
|
-
if (
|
|
325
|
-
len(sources) != 1
|
|
326
|
-
or module.__file__ is None
|
|
327
|
-
or Path(module.__file__).resolve() != sources[0].resolve()
|
|
328
|
-
):
|
|
329
|
-
raise ToolConflictError(
|
|
330
|
-
f"{package.module} does not resolve to the selected installed distribution"
|
|
331
|
-
)
|
|
332
|
-
from .sdk import _application
|
|
333
|
-
|
|
334
|
-
_application(module)
|
|
335
|
-
return module
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|