toolfuncs 0.12.2__tar.gz → 0.12.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/.github/workflows/release.yml +2 -2
  2. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/PKG-INFO +9 -5
  3. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/README.md +7 -3
  4. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/docs/contract.md +7 -3
  5. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/pyproject.toml +2 -2
  6. toolfuncs-0.12.4/src/toolfuncs/_packages.py +217 -0
  7. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_setup.py +3 -5
  8. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_tools.py +4 -4
  9. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/sdk.py +12 -1
  10. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_packages.py +170 -31
  11. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/uv.lock +15 -5
  12. toolfuncs-0.12.2/src/toolfuncs/_packages.py +0 -335
  13. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/.gitignore +0 -0
  14. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/.python-version +0 -0
  15. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/__init__.py +0 -0
  16. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/__main__.py +0 -0
  17. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_descriptions.py +0 -0
  18. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_doctor.py +0 -0
  19. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_errors.py +0 -0
  20. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_import_path.py +0 -0
  21. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/_runtime.py +0 -0
  22. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/agent_hooks.py +0 -0
  23. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/src/toolfuncs/py.typed +0 -0
  24. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/__init__.py +0 -0
  25. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/conftest.py +0 -0
  26. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_agent_hooks.py +0 -0
  27. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_doctor.py +0 -0
  28. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_import_path.py +0 -0
  29. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_managed_runtime.py +0 -0
  30. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/tests/test_sdk_cli.py +0 -0
  31. {toolfuncs-0.12.2 → toolfuncs-0.12.4}/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: [self-hosted, Linux, X64]
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: [self-hosted, Linux, X64]
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.2
3
+ Version: 0.12.4
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.1
13
+ Requires-Dist: uv-launch<0.3,>=0.2.7
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, or with a normal package installer for an unmanaged command. 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.
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 the installed distribution's entry points, file ownership, and module docstring without importing or executing the target or preparing dependencies. It validates that the effective PATH launcher actually calls the declared `module:app`. Standard Python launchers, symlinks, installer-generated `/bin/sh` wrappers for long interpreter paths, and uv-launch managed commands are supported. The package must include inspectable Python source in its installation; arbitrary shell launchers and editable import-hook layouts are not inferred. The first docstring line is the static description.
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 installs uv-launch in a stable uv tool environment, 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.
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, or with a normal package installer for an unmanaged command. 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.
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 the installed distribution's entry points, file ownership, and module docstring without importing or executing the target or preparing dependencies. It validates that the effective PATH launcher actually calls the declared `module:app`. Standard Python launchers, symlinks, installer-generated `/bin/sh` wrappers for long interpreter paths, and uv-launch managed commands are supported. The package must include inspectable Python source in its installation; arbitrary shell launchers and editable import-hook layouts are not inferred. The first docstring line is the static description.
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 installs uv-launch in a stable uv tool environment, 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.
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 the snake-case facade name, with the same reserved-name checks and PATH precedence as scripts. Distribution RECORD ownership and the actual generated launcher target must agree with the metadata. Discovery statically reads the installed module's first docstring line for its description. It does not import target packages, execute the interpreter, install dependencies, or query the network. Python-shebang launchers, symlinks, and installer-generated `/bin/sh` long-path wrappers and validated uv-launch shims are recognized; installed Python source is required. Arbitrary shell launchers and editable import-hook layouts are not interpreted.
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 a stable uv-launch tool installation 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.
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. Normal `uv tool install` installations participate in PATH discovery, native Python loading, and `doctor` without a separate script. Existing standalone tools retain their interfaces.
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.2"
3
+ version = "0.12.4"
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.1,<0.3",
13
+ "uv-launch>=0.2.7,<0.3",
14
14
  "pydantic>=2,<3",
15
15
  "pydantic-core>=2,<3",
16
16
  ]
@@ -0,0 +1,217 @@
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
+ distributions = list(importlib.metadata.distributions(path=[str(path) for path in paths]))
46
+ declarations = {
47
+ entry.name
48
+ for distribution in distributions
49
+ for entry in distribution.entry_points
50
+ if entry.group == "toolfuncs"
51
+ }
52
+ for distribution in distributions:
53
+ for file in distribution.files or ():
54
+ path = Path(str(distribution.locate_file(file)))
55
+ # A different leaf name can own an opted-in command only through
56
+ # a symlink. Avoid resolving every component of unrelated files.
57
+ if file.name not in declarations and not path.is_symlink():
58
+ continue
59
+ path = path.resolve()
60
+ owners.setdefault(path, []).append((distribution, file))
61
+ cache[paths] = _InstalledPackages(owners, declarations)
62
+ return cache[paths]
63
+
64
+
65
+ def _verify_file(source: Path, file: importlib.metadata.PackagePath) -> None:
66
+ if file.hash is None or file.hash.mode not in {"sha256", "sha384", "sha512"}:
67
+ raise InvalidToolError(f"{source}: installed launcher requires a strong RECORD checksum")
68
+ with source.open("rb") as stream:
69
+ digest = hashlib.file_digest(stream, file.hash.mode).digest()
70
+ actual = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
71
+ if actual != file.hash.value or (file.size is not None and source.stat().st_size != file.size):
72
+ raise InvalidToolError(f"{source}: installed launcher checksum does not match RECORD")
73
+
74
+
75
+ def inspect_package(
76
+ source: Path, cli_name: str, cache: PackageIndex | None = None
77
+ ) -> PackagedTool | None:
78
+ try:
79
+ return _inspect_package(source, cli_name, cache)
80
+ except (OSError, ValueError, KeyError, TypeError) as error:
81
+ raise InvalidToolError(f"{source}: invalid installed tool metadata: {error}") from error
82
+
83
+
84
+ def _inspect_package(
85
+ source: Path, cli_name: str, cache: PackageIndex | None
86
+ ) -> PackagedTool | None:
87
+ source = source.resolve(strict=True)
88
+ paths = metadata_paths(source)
89
+ installed_bin = (
90
+ paths is not None or source.parent == Path(sysconfig.get_path("scripts")).resolve()
91
+ )
92
+ if paths is None:
93
+ paths = tuple(
94
+ sorted({Path(value).resolve() for value in sys.path if value and Path(value).is_dir()})
95
+ )
96
+ index = _index(paths, {} if cache is None else cache)
97
+ owners = index.owners.get(source, [])
98
+ if not owners and installed_bin and source.name in index.declarations:
99
+ raise InvalidToolError(f"{source}: declared package tool has no RECORD ownership")
100
+
101
+ # Entry-point identity comes from the recorded executable, including when a
102
+ # public managed shim gives the installation a different name.
103
+ entry_name = source.name
104
+ if not any(
105
+ any(
106
+ entry.group == "toolfuncs" and entry.name == entry_name
107
+ for entry in distribution.entry_points
108
+ )
109
+ for distribution, _ in owners
110
+ ):
111
+ return None
112
+ if len(owners) != 1:
113
+ raise InvalidToolError(
114
+ f"{source}: toolfuncs package metadata does not uniquely own this launcher"
115
+ )
116
+ distribution, file = owners[0]
117
+ _verify_file(source, file)
118
+ entries = [entry for entry in distribution.entry_points if entry.name == entry_name]
119
+ tool = [entry for entry in entries if entry.group == "toolfuncs"]
120
+ console = [entry for entry in entries if entry.group == "console_scripts"]
121
+ if len(tool) != 1 or len(console) != 1 or tool[0].value != console[0].value:
122
+ raise InvalidToolError(f"{source}: toolfuncs and console_scripts entry points must match")
123
+ target = tool[0].value
124
+ module, _, attribute = target.partition(":")
125
+ if attribute != "app" or not all(part.isidentifier() for part in module.split(".")):
126
+ raise InvalidToolError(f"{source}: packaged tool entry point must name module:app")
127
+ sources = _module_sources(distribution, module)
128
+ if len(sources) != 1:
129
+ raise InvalidToolError(f"{source}: package must contain inspectable source for {module}")
130
+ try:
131
+ doc = ast.get_docstring(ast.parse(sources[0].read_text(encoding="utf-8")))
132
+ except (OSError, UnicodeError, SyntaxError) as error:
133
+ raise InvalidToolError(
134
+ f"{source}: cannot inspect package module source: {error}"
135
+ ) from error
136
+ if not doc or not doc.splitlines()[0].strip():
137
+ raise InvalidToolError(f"{source}: package module must have a descriptive docstring")
138
+ requires_python = None
139
+ if "Requires-Python" in distribution.metadata:
140
+ requires_python = distribution.metadata["Requires-Python"]
141
+ return PackagedTool(
142
+ module,
143
+ doc.splitlines()[0].strip(),
144
+ _requirement(distribution, source),
145
+ requires_python,
146
+ distribution.metadata["Name"],
147
+ distribution.version,
148
+ sources[0],
149
+ )
150
+
151
+
152
+ def _module_sources(distribution: importlib.metadata.Distribution, module: str) -> list[Path]:
153
+ candidates = {module.replace(".", "/") + ".py", module.replace(".", "/") + "/__init__.py"}
154
+ return [
155
+ Path(str(distribution.locate_file(file)))
156
+ for file in distribution.files or ()
157
+ if str(file) in candidates
158
+ ]
159
+
160
+
161
+ def _requirement(distribution: importlib.metadata.Distribution, source: Path) -> str:
162
+ try:
163
+ return distribution_requirement(distribution)
164
+ except ValueError as error:
165
+ raise InvalidToolError(f"{source}: {error}") from error
166
+
167
+
168
+ def load_package(
169
+ package: PackagedTool, source: Path, *, dependencies_prepared: bool = False
170
+ ) -> ModuleType:
171
+ """Prepare the owning distribution, then return its real module in this process."""
172
+ from ._tools import _Metadata, _prepare_environment
173
+
174
+ metadata = _Metadata(
175
+ (package.requirement,),
176
+ package.requires_python,
177
+ package.description,
178
+ source.name,
179
+ source.name.replace("-", "_"),
180
+ False,
181
+ )
182
+ try:
183
+ installed = importlib.metadata.distribution(package.distribution)
184
+ prepared = (
185
+ installed.version == package.version
186
+ and _requirement(installed, source) == package.requirement
187
+ and len(_module_sources(installed, package.module)) == 1
188
+ )
189
+ except importlib.metadata.PackageNotFoundError:
190
+ prepared = False
191
+ if package.module in sys.modules and not prepared:
192
+ raise ToolConflictError(
193
+ f"{package.module} is already imported from a different installation"
194
+ )
195
+ _prepare_environment(metadata, source=source, install_dependencies=not dependencies_prepared)
196
+ installed = importlib.metadata.distribution(package.distribution)
197
+ if (
198
+ installed.version != package.version
199
+ or _requirement(installed, source) != package.requirement
200
+ ):
201
+ raise ToolConflictError(
202
+ f"{package.module} does not match the selected distribution version and origin"
203
+ )
204
+ module = importlib.import_module(package.module)
205
+ sources = _module_sources(installed, package.module)
206
+ if (
207
+ len(sources) != 1
208
+ or module.__file__ is None
209
+ or Path(module.__file__).resolve() != sources[0].resolve()
210
+ ):
211
+ raise ToolConflictError(
212
+ f"{package.module} does not resolve to the selected installed distribution"
213
+ )
214
+ from .sdk import _application
215
+
216
+ _application(module)
217
+ 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
- subprocess.run([uv, "tool", "install", "--upgrade", "uv-launch>=0.2.1,<0.3"], check=True)
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
- str(manager),
81
+ *manager,
84
82
  "install",
85
83
  name,
86
- "toolfuncs[hooks]>=0.12.1,<0.13",
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(entry, name, packages)) is not None:
111
- tools[name] = _packaged_tool(entry, name, package)
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(candidate, cli_name)) is not None:
139
- return _packaged_tool(candidate, cli_name, package)
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
- "UV_TOOL_DIR": str(
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
- [uv, "tool", "install", "--python", sys.executable, str(project)],
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.is_symlink()
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 "launcher target" in catalog.invalid_tools[0].error
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
- "normalization, valid",
209
+ "body",
198
210
  [
199
- ("sys.argv[0].removesuffix('.exe')", True),
200
- ("sys.argv[0].removesuffix(suffix())", False),
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 test_uv_removesuffix_launcher_discovery(installed_package, normalization, valid, monkeypatch):
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
- marker = root / "removesuffix-import-marker"
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
- prefix
214
- + "\nif __name__ == '__main__':\n"
215
- + f" sys.argv[0] = {normalization}\n"
216
- + " sys.exit(app())\n"
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 marker.exists(), "discovery executed package source"
220
- if valid:
221
- assert catalog.invalid_tools == ()
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.write_text(original)
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,114 @@ 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
+ @pytest.mark.parametrize("ownership", ["same-path", "leaf-symlink", "parent-symlink"])
310
+ def test_unrelated_distribution_cannot_also_own_launcher(installed_package, ownership):
311
+ _, binary, _ = installed_package
312
+ package = _discover(path=str(binary)).tools[0].package
313
+ assert package is not None
314
+ metadata_root = package.source.parents[1]
315
+ other = metadata_root / "unrelated_owner-0.1.dist-info"
316
+ other.mkdir()
317
+ original_record = (metadata_root / "fixture_packaged_tool-0.0.1.dist-info/RECORD").read_text()
318
+ try:
319
+ (other / "METADATA").write_text(
320
+ "Metadata-Version: 2.1\nName: unrelated-owner\nVersion: 0.1\n"
321
+ )
322
+ row = next(line for line in original_record.splitlines() if "bin/package-echo," in line)
323
+ if ownership != "same-path":
324
+ target = managed_target(binary / "package-echo")
325
+ assert target is not None
326
+ alias = other / "alias"
327
+ alias.symlink_to(target if ownership == "leaf-symlink" else target.parent)
328
+ claim = alias if ownership == "leaf-symlink" else alias / target.name
329
+ row = f"{claim.relative_to(metadata_root)},,"
330
+ (other / "RECORD").write_text(row + "\n")
331
+ catalog = _discover(path=str(binary))
332
+ assert not catalog.tools
333
+ assert "uniquely own" in catalog.invalid_tools[0].error
334
+ finally:
335
+ shutil.rmtree(other)
336
+
337
+
338
+ @pytest.mark.parametrize("damage", ["absent", "empty-hash"])
339
+ def test_declared_package_requires_recorded_ownership_and_hash(installed_package, damage):
340
+ import csv
341
+ import io
342
+
343
+ _, binary, _ = installed_package
344
+ package = _discover(path=str(binary)).tools[0].package
345
+ assert package is not None
346
+ record = package.source.parents[1] / "fixture_packaged_tool-0.0.1.dist-info/RECORD"
347
+ original = record.read_text()
348
+ try:
349
+ if damage == "absent":
350
+ record.unlink()
351
+ else:
352
+ rows = list(csv.reader(io.StringIO(original)))
353
+ for row in rows:
354
+ if row[0].endswith("bin/package-echo"):
355
+ row[1] = ""
356
+ output = io.StringIO()
357
+ csv.writer(output).writerows(rows)
358
+ record.write_text(output.getvalue())
359
+ catalog = _discover(path=str(binary))
360
+ assert not catalog.tools
361
+ assert "RECORD" in catalog.invalid_tools[0].error
362
+ finally:
363
+ record.write_text(original)
364
+
365
+
366
+ def test_prepared_load_checks_selected_version_before_import(installed_package):
367
+ _, binary, _ = installed_package
368
+ command = managed_target(binary / "package-echo")
369
+ assert command is not None
370
+ code = """
371
+ import sys
372
+ from dataclasses import replace
373
+ from pathlib import Path
374
+ from toolfuncs._packages import inspect_package, load_package
375
+ from toolfuncs._errors import ToolConflictError
376
+ source = Path(sys.argv[1])
377
+ package = inspect_package(source, 'package-echo')
378
+ try:
379
+ load_package(replace(package, version='999'), source, dependencies_prepared=True)
380
+ except ToolConflictError:
381
+ assert 'fixture_packaged_tool.operations' not in sys.modules
382
+ else:
383
+ raise AssertionError('wrong version was imported')
384
+ """
385
+ result = subprocess.run(
386
+ [str(command.parent / "python"), "-I", "-c", code, str(command)],
387
+ capture_output=True,
388
+ text=True,
389
+ )
390
+ 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.2"
546
+ version = "0.12.4"
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.1,<0.3" },
579
+ { name = "uv-launch", specifier = ">=0.2.7,<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.1"
631
+ version = "0.2.7"
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/1f/69/2dece5c99d309aaa4263bd8b0d01f29c6069ecec52bc44a267d2ad3e4f23/uv_launch-0.2.1.tar.gz", hash = "sha256:232259c49f3fa36fe65aaabe1898e71aaecda6bf849aa4878ceb256f0a6a7252", size = 14914, upload-time = "2026-10-02T23:52:26.887Z" }
637
+ sdist = { url = "https://files.pythonhosted.org/packages/53/d7/d37d4df64c61dd403729637f6a3797ddebfe6226851cb5285dfefec8aaaa/uv_launch-0.2.7.tar.gz", hash = "sha256:39fe10d1c06bde623ceed77e2ae128cf4d77b3602fce1c9f872aa39b809986ff", size = 25690, upload-time = "2026-10-13T16:21:33.966Z" }
628
638
  wheels = [
629
- { url = "https://files.pythonhosted.org/packages/f5/c1/fd02bf68cbf9064b837c5d488e7c109bcacb174841634ce5e3b59138cc73/uv_launch-0.2.1-py3-none-any.whl", hash = "sha256:cbc18ef98d347ee33aef283228ecafd43e6e2875694bd62ab67b5784b08f09e6", size = 17930, upload-time = "2026-10-02T23:52:25.659Z" },
639
+ { url = "https://files.pythonhosted.org/packages/6d/b1/e8fda697b8d8b01b7d34175a8a0138273df81ce27f7474e8ac5e34fd610f/uv_launch-0.2.7-py3-none-any.whl", hash = "sha256:d320f6ba61da8240e9c3ba58f955544b8b5d3d495ae6673e0423417d858babd4", size = 28126, upload-time = "2026-10-13T16:21:32.802Z" },
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