pdm-bin-dir 1.0.5__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Samuel J. McKelvie
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,182 @@
1
+ Metadata-Version: 2.1
2
+ Name: pdm-bin-dir
3
+ Version: 1.0.5
4
+ Summary: PDM plugin that allows additional directories listed in pyproject.toml to be added to environment PATH
5
+ Keywords: pdm,plugin,path,environment,bin,virtualenv,script,activate
6
+ Author-Email: Sam McKelvie <dev@emckelvie.org>
7
+ License: MIT License
8
+
9
+ Copyright (c) 2026 Samuel J. McKelvie
10
+
11
+ Permission is hereby granted, free of charge, to any person obtaining a copy
12
+ of this software and associated documentation files (the "Software"), to deal
13
+ in the Software without restriction, including without limitation the rights
14
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
15
+ copies of the Software, and to permit persons to whom the Software is
16
+ furnished to do so, subject to the following conditions:
17
+
18
+ The above copyright notice and this permission notice shall be included in all
19
+ copies or substantial portions of the Software.
20
+
21
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
22
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
23
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
24
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
25
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
26
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
27
+ SOFTWARE.
28
+
29
+ Classifier: Development Status :: 4 - Beta
30
+ Classifier: Environment :: Console
31
+ Classifier: Intended Audience :: Developers
32
+ Classifier: License :: OSI Approved :: MIT License
33
+ Classifier: Operating System :: OS Independent
34
+ Classifier: Programming Language :: Python :: 3
35
+ Classifier: Programming Language :: Python :: 3.10
36
+ Classifier: Programming Language :: Python :: 3.11
37
+ Classifier: Programming Language :: Python :: 3.12
38
+ Classifier: Programming Language :: Python :: 3.13
39
+ Classifier: Topic :: Software Development
40
+ Classifier: Topic :: Software Development :: Build Tools
41
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
42
+ Classifier: Topic :: Utilities
43
+ Classifier: Typing :: Typed
44
+ Requires-Python: >=3.10
45
+ Requires-Dist: pdm>=2.0
46
+ Requires-Dist: typing-extensions>=4.8; python_version < "3.12"
47
+ Description-Content-Type: text/markdown
48
+
49
+ # pdm-bin-dir
50
+
51
+ [![CI](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml/badge.svg)](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml)
52
+ [![PyPI version](https://img.shields.io/pypi/v/pdm-bin-dir.svg)](https://pypi.org/project/pdm-bin-dir/)
53
+ [![Python versions](https://img.shields.io/pypi/pyversions/pdm-bin-dir.svg)](https://pypi.org/project/pdm-bin-dir/)
54
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
55
+
56
+ `pdm-bin-dir` is a [PDM](https://pdm-project.org/) plugin that automatically prepends additional project directories to `PATH` when running commands via PDM. This lets you place helper scripts alongside your project and run them as plain commands — no prefix or activation needed.
57
+
58
+ ## Installation
59
+
60
+ Install the plugin into PDM's own environment:
61
+
62
+ ```bash
63
+ pdm plugin add pdm-bin-dir
64
+ ```
65
+
66
+ ## Usage
67
+
68
+ The plugin is **opt-in per project**: it has no effect unless `[tool.pdm.plugin.bin-dir]` is present in the project's `pyproject.toml`. Once configured, the listed directories are prepended to `PATH` before every `pdm run …` invocation.
69
+
70
+ ### Configuration
71
+
72
+ Override the directories in `pyproject.toml`:
73
+
74
+ ```toml
75
+ [tool.pdm.plugin.bin-dir]
76
+ dirs = ["bin", "scripts"]
77
+ ```
78
+
79
+ Paths are relative to the project root. Absolute paths are also accepted.
80
+
81
+ ### `pdm bin-dir` command
82
+
83
+ The plugin registers a `bin-dir` sub-command for inspecting and changing the configuration:
84
+
85
+ ```bash
86
+ # Show current configured directories (JSON array)
87
+ pdm bin-dir show
88
+
89
+ # Replace the list
90
+ pdm bin-dir set bin scripts
91
+
92
+ # Append to the list (duplicates are silently skipped)
93
+ pdm bin-dir add tools
94
+ ```
95
+
96
+ Changes made via `set` / `add` are written back to `pyproject.toml`.
97
+
98
+ ## Development
99
+
100
+ This project uses [PDM](https://pdm-project.org/) for dependency management,
101
+ linting, type checking, and testing.
102
+
103
+ ```bash
104
+ pdm install -G dev
105
+ pdm run lint # ruff check
106
+ pdm run typecheck # mypy
107
+ pdm run test # pytest
108
+ pdm build
109
+ ```
110
+
111
+ ## Publishing
112
+
113
+ Releases are managed through GitHub Actions using a three-channel model:
114
+
115
+ | Channel | Branch | Tag format | Index |
116
+ |---|---|---|---|
117
+ | dev | `main` | — (no publish) | — |
118
+ | rc | `rc/<x.y.z>` | `rc-v<x.y.z>-rc.<n>` | TestPyPI |
119
+ | prod | `prod/<x.y.z>` | `v<x.y.z>` | PyPI |
120
+
121
+ ### Version invariant
122
+
123
+ `main` always carries `X.Y.Z-dev.N`. The `x.y.z` portion of any RC or
124
+ production release always matches the commit on `main` from which it was cut —
125
+ only the qualifier suffix changes.
126
+
127
+ ### Release workflow
128
+
129
+ **Bump dev version** — increment the version on `main`.
130
+
131
+ ```bash
132
+ bin/bump-dev [dev|patch|minor|major] # edits pyproject.toml, does not commit
133
+ ```
134
+
135
+ | `bump_type` | Example |
136
+ |---|---|
137
+ | `dev` | `1.0.0-dev.1` → `1.0.0-dev.2` |
138
+ | `patch` | `1.0.0-dev.2` → `1.0.1-dev.1` |
139
+ | `minor` | `1.0.0-dev.2` → `1.1.0-dev.1` |
140
+ | `major` | `1.0.0-dev.2` → `2.0.0-dev.1` |
141
+
142
+ Also available remotely via `Actions → Bump dev version → Run workflow` for
143
+ cases where a local checkout is not convenient.
144
+
145
+ **`bin/cut-rc`** (run on `main`) — create a release candidate.
146
+
147
+ Reads `X.Y.Z-dev.N` from `pyproject.toml`, auto-increments the rc counter
148
+ from existing tags, creates branch `rc/X.Y.Z` with version `X.Y.Z-rc.N`,
149
+ and pushes — triggering `Publish TestPyPI`.
150
+
151
+ **`bin/cut-prod`** (run on `rc/<x.y.z>`) — promote to production.
152
+
153
+ Strips the rc qualifier, creates branch `prod/X.Y.Z` with the clean `X.Y.Z`
154
+ version, and pushes — triggering `Publish`, which tags the commit `vX.Y.Z`
155
+ and auto-bumps `main` to `X.Y.(Z+1)-dev.1` after a successful PyPI push.
156
+
157
+ ### Guards
158
+
159
+ Both publish workflows validate that:
160
+
161
+ - The branch version matches `pyproject.toml`'s version.
162
+ - The version format matches the target index (stable for PyPI, `-rc.N` for
163
+ TestPyPI).
164
+ - The version does not already exist on the target index.
165
+ - Lint, type checks, and tests pass.
166
+
167
+ ### Install-path smoke test
168
+
169
+ Use the **Install Smoke Test** workflow to verify an install without publishing
170
+ or bumping a version:
171
+
172
+ - `source=github` with a `git_ref` — installs directly from the repository.
173
+ - `source=testpypi` with a `version` — installs an already-uploaded TestPyPI
174
+ build.
175
+
176
+ ## Supported Python Versions
177
+
178
+ Python 3.10 and later.
179
+
180
+ ## License
181
+
182
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,134 @@
1
+ # pdm-bin-dir
2
+
3
+ [![CI](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml/badge.svg)](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml)
4
+ [![PyPI version](https://img.shields.io/pypi/v/pdm-bin-dir.svg)](https://pypi.org/project/pdm-bin-dir/)
5
+ [![Python versions](https://img.shields.io/pypi/pyversions/pdm-bin-dir.svg)](https://pypi.org/project/pdm-bin-dir/)
6
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
+
8
+ `pdm-bin-dir` is a [PDM](https://pdm-project.org/) plugin that automatically prepends additional project directories to `PATH` when running commands via PDM. This lets you place helper scripts alongside your project and run them as plain commands — no prefix or activation needed.
9
+
10
+ ## Installation
11
+
12
+ Install the plugin into PDM's own environment:
13
+
14
+ ```bash
15
+ pdm plugin add pdm-bin-dir
16
+ ```
17
+
18
+ ## Usage
19
+
20
+ The plugin is **opt-in per project**: it has no effect unless `[tool.pdm.plugin.bin-dir]` is present in the project's `pyproject.toml`. Once configured, the listed directories are prepended to `PATH` before every `pdm run …` invocation.
21
+
22
+ ### Configuration
23
+
24
+ Override the directories in `pyproject.toml`:
25
+
26
+ ```toml
27
+ [tool.pdm.plugin.bin-dir]
28
+ dirs = ["bin", "scripts"]
29
+ ```
30
+
31
+ Paths are relative to the project root. Absolute paths are also accepted.
32
+
33
+ ### `pdm bin-dir` command
34
+
35
+ The plugin registers a `bin-dir` sub-command for inspecting and changing the configuration:
36
+
37
+ ```bash
38
+ # Show current configured directories (JSON array)
39
+ pdm bin-dir show
40
+
41
+ # Replace the list
42
+ pdm bin-dir set bin scripts
43
+
44
+ # Append to the list (duplicates are silently skipped)
45
+ pdm bin-dir add tools
46
+ ```
47
+
48
+ Changes made via `set` / `add` are written back to `pyproject.toml`.
49
+
50
+ ## Development
51
+
52
+ This project uses [PDM](https://pdm-project.org/) for dependency management,
53
+ linting, type checking, and testing.
54
+
55
+ ```bash
56
+ pdm install -G dev
57
+ pdm run lint # ruff check
58
+ pdm run typecheck # mypy
59
+ pdm run test # pytest
60
+ pdm build
61
+ ```
62
+
63
+ ## Publishing
64
+
65
+ Releases are managed through GitHub Actions using a three-channel model:
66
+
67
+ | Channel | Branch | Tag format | Index |
68
+ |---|---|---|---|
69
+ | dev | `main` | — (no publish) | — |
70
+ | rc | `rc/<x.y.z>` | `rc-v<x.y.z>-rc.<n>` | TestPyPI |
71
+ | prod | `prod/<x.y.z>` | `v<x.y.z>` | PyPI |
72
+
73
+ ### Version invariant
74
+
75
+ `main` always carries `X.Y.Z-dev.N`. The `x.y.z` portion of any RC or
76
+ production release always matches the commit on `main` from which it was cut —
77
+ only the qualifier suffix changes.
78
+
79
+ ### Release workflow
80
+
81
+ **Bump dev version** — increment the version on `main`.
82
+
83
+ ```bash
84
+ bin/bump-dev [dev|patch|minor|major] # edits pyproject.toml, does not commit
85
+ ```
86
+
87
+ | `bump_type` | Example |
88
+ |---|---|
89
+ | `dev` | `1.0.0-dev.1` → `1.0.0-dev.2` |
90
+ | `patch` | `1.0.0-dev.2` → `1.0.1-dev.1` |
91
+ | `minor` | `1.0.0-dev.2` → `1.1.0-dev.1` |
92
+ | `major` | `1.0.0-dev.2` → `2.0.0-dev.1` |
93
+
94
+ Also available remotely via `Actions → Bump dev version → Run workflow` for
95
+ cases where a local checkout is not convenient.
96
+
97
+ **`bin/cut-rc`** (run on `main`) — create a release candidate.
98
+
99
+ Reads `X.Y.Z-dev.N` from `pyproject.toml`, auto-increments the rc counter
100
+ from existing tags, creates branch `rc/X.Y.Z` with version `X.Y.Z-rc.N`,
101
+ and pushes — triggering `Publish TestPyPI`.
102
+
103
+ **`bin/cut-prod`** (run on `rc/<x.y.z>`) — promote to production.
104
+
105
+ Strips the rc qualifier, creates branch `prod/X.Y.Z` with the clean `X.Y.Z`
106
+ version, and pushes — triggering `Publish`, which tags the commit `vX.Y.Z`
107
+ and auto-bumps `main` to `X.Y.(Z+1)-dev.1` after a successful PyPI push.
108
+
109
+ ### Guards
110
+
111
+ Both publish workflows validate that:
112
+
113
+ - The branch version matches `pyproject.toml`'s version.
114
+ - The version format matches the target index (stable for PyPI, `-rc.N` for
115
+ TestPyPI).
116
+ - The version does not already exist on the target index.
117
+ - Lint, type checks, and tests pass.
118
+
119
+ ### Install-path smoke test
120
+
121
+ Use the **Install Smoke Test** workflow to verify an install without publishing
122
+ or bumping a version:
123
+
124
+ - `source=github` with a `git_ref` — installs directly from the repository.
125
+ - `source=testpypi` with a `version` — installs an already-uploaded TestPyPI
126
+ build.
127
+
128
+ ## Supported Python Versions
129
+
130
+ Python 3.10 and later.
131
+
132
+ ## License
133
+
134
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,359 @@
1
+ """
2
+ PDM plugin that allows additional directories other than {VENV_BASE}/bin to be added to the front of the PATH environment variable when running commands in the project.
3
+
4
+ By default it will add the `{PROJECT_ROOT}/bin` directory. This can be overridden in pyproject.toml.
5
+
6
+ Example:
7
+ ```toml
8
+ [tool.pdm.plugin.bin-dir]
9
+ dirs=["bin", "scripts"]
10
+ ```
11
+
12
+ For convenience, the plugin also adds a `pdm bin-dir` command to display or set the configured paths.
13
+
14
+ Example usage:
15
+ ```bash
16
+ pdm bin-dir show # Display the current configured bin directories.
17
+ pdm bin-dir set <relpath...> # Set the bin directories to zero or more custom relative path(s) from the project root.
18
+ pdm bin-dir add <relpath...> # Add one or more custom relative path(s) from the project root to the end of the existing configured bin directories.
19
+ ```
20
+
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+ from argparse import ArgumentParser, Namespace
28
+ from collections.abc import Iterable, Mapping, MutableMapping, Sequence
29
+ from copy import deepcopy
30
+ from importlib.metadata import PackageNotFoundError
31
+ from importlib.metadata import version as package_version
32
+ from typing import Any, ClassVar
33
+
34
+ from pdm.cli.commands.base import BaseCommand
35
+ from pdm.core import Core
36
+ from pdm.project import Project
37
+ from pdm.signals import pre_invoke
38
+ from typing_extensions import override
39
+
40
+ try:
41
+ __version__ = package_version("pdm-bin-dir")
42
+ except PackageNotFoundError:
43
+ # Source tree import before installation.
44
+ __version__ = "0.0.0"
45
+
46
+ __all__ = [
47
+ "BinDirCommand",
48
+ "plugin",
49
+ "get_bin_reldirs",
50
+ "get_bin_dirs",
51
+ "CONFIG_GROUP",
52
+ "CONFIG_DIRS_SUBKEY",
53
+ "CONFIG_DIRS_KEY",
54
+ "DEFAULT_BIN_DIRS",
55
+ ]
56
+
57
+ CONFIG_GROUP = "tool.pdm.plugin.bin-dir"
58
+ CONFIG_DIRS_SUBKEY = "dirs"
59
+ CONFIG_DIRS_KEY = f"{CONFIG_GROUP}.{CONFIG_DIRS_SUBKEY}"
60
+
61
+ DEFAULT_BIN_DIRS: list[str] = []
62
+ """The default list of bin directories to add to PATH if not configured in pyproject.toml.
63
+
64
+ Defaults to empty so the plugin has no effect on projects that have not explicitly opted in
65
+ via ``[tool.pdm.plugin.bin-dir]`` in their ``pyproject.toml``.
66
+ """
67
+
68
+
69
+ def _read_pyproject_key(project: Project, key: str, default: Any=None) -> object:
70
+ """Helper function to read a key from the pyproject.toml configuration.
71
+ if the key contains dots, it will be treated as a nested key.
72
+ """
73
+ if key == "":
74
+ raise ValueError("Key cannot be empty")
75
+ cfg = project.pyproject.open_for_read()
76
+ val: object = cfg
77
+ for part in key.split("."):
78
+ if part == "":
79
+ raise ValueError(f"Invalid key with empty part: {key!r}")
80
+ if not isinstance(val, Mapping):
81
+ return default
82
+ val = val.get(part, default)
83
+ if isinstance(val, (Mapping, Sequence)):
84
+ val = deepcopy(val)
85
+ return val
86
+
87
+ def _write_pyproject_key(project: Project, key: str, value: Any, flush: bool=True, show_message: bool=True) -> None:
88
+ """Helper function to write a key to the pyproject.toml configuration.
89
+ if the key contains dots, it will be treated as a nested key and parent containers will be created as needed.
90
+ """
91
+ cfg = project.pyproject.open_for_write()
92
+ parent: object = cfg
93
+ if key == "":
94
+ raise ValueError("Key cannot be empty")
95
+ parts = key.split(".")
96
+ for part in parts[:-1]:
97
+ if part == "":
98
+ raise ValueError(f"Invalid key with empty part: {key!r}")
99
+ if not isinstance(parent, MutableMapping):
100
+ raise ValueError(f"Invalid pyproject.toml structure: expected MutableMapping above {part!r} in {key!r}")
101
+ if part not in parent:
102
+ parent[part] = {}
103
+ parent = parent[part]
104
+ if not isinstance(parent, MutableMapping):
105
+ raise ValueError(f"Invalid pyproject.toml structure: expected MutableMapping above {parts[-1]!r} in {key!r}")
106
+ parent[parts[-1]] = value
107
+ if flush:
108
+ project.pyproject.write(show_message=show_message)
109
+
110
+ def _delete_pyproject_key(project: Project, key: str, flush: bool=True, show_message: bool=True) -> None:
111
+ """Helper function to delete a key in the pyproject.toml if it exists.
112
+ if the key contains dots, it will be treated as a nested key.
113
+ Parent containers are not deleted, even if they become empty after deletion.
114
+ """
115
+ cfg = project.pyproject.open_for_write()
116
+ parent: object = cfg
117
+ if key == "":
118
+ raise ValueError("Key cannot be empty")
119
+ parts = key.split(".")
120
+ for part in parts[:-1]:
121
+ if part == "":
122
+ raise ValueError(f"Invalid key with empty part: {key!r}")
123
+ if not isinstance(parent, MutableMapping):
124
+ return
125
+ if part not in parent:
126
+ return
127
+ parent = parent[part]
128
+ if not isinstance(parent, MutableMapping):
129
+ return
130
+ if parts[-1] not in parent:
131
+ return
132
+ del parent[parts[-1]]
133
+ if flush:
134
+ project.pyproject.write(show_message=show_message)
135
+
136
+ def get_bin_reldirs(project: Project) -> list[str]:
137
+ """Returns the bin directories exactly as configured in pyproject.toml, or the default if not configured.
138
+ The paths are returned as-is without normalization, and may be absolute or relative."""
139
+ any_result = _read_pyproject_key(project, CONFIG_DIRS_KEY)
140
+ result: list[str]
141
+ if any_result is None:
142
+ result = DEFAULT_BIN_DIRS
143
+ elif isinstance(any_result, str):
144
+ result = [] if any_result == "" else [any_result]
145
+ elif isinstance(any_result, Sequence):
146
+ if len(any_result) > 0 and any(not isinstance(item, str) for item in any_result):
147
+ raise ValueError(f"Invalid {CONFIG_DIRS_KEY} in project configuration: {any_result}")
148
+ result = list(any_result)
149
+ else:
150
+ raise ValueError(f"Invalid {CONFIG_DIRS_KEY} in project configuration: {any_result}")
151
+
152
+ if not isinstance(result, list) or any(not isinstance(item, str) for item in result):
153
+ raise ValueError(f"Invalid {CONFIG_DIRS_KEY} in project configuration: {result}")
154
+ return result
155
+
156
+ def _get_abspath(project: Project, relpath: str) -> str:
157
+ """Resolves a path to absolute form using the project root as a base"""
158
+ return os.path.abspath(os.path.join(project.root, relpath))
159
+
160
+ def _get_abs_paths(project: Project, relpaths: Iterable[str]) -> list[str]:
161
+ """Resolves a sequence of paths to absolute form using the project root as a base"""
162
+ return [_get_abspath(project, relpath) for relpath in relpaths]
163
+
164
+ def _normalize_relpath(project: Project, relpath: str) -> str:
165
+ """Takes a path, which may either be absolute or relative to the project root, and returns a path that is absolute if it is
166
+ outsude the project, or relative if within."""
167
+ result = _get_abspath(project, relpath)
168
+ if result.startswith(str(project.root)):
169
+ result = os.path.relpath(result, project.root)
170
+ return result
171
+
172
+ def _normalize_relpaths(project: Project, relpaths: Iterable[str]) -> list[str]:
173
+ """Takes a sequence of paths, which may either be absolute or relative to the project root, and returns a list of paths that are absolute if they are
174
+ outsude the project, or relative if within."""
175
+ return [_normalize_relpath(project, relpath) for relpath in relpaths]
176
+
177
+ def get_bin_dirs(project: Project) -> list[str]:
178
+ """Returns the resolved configured list of absolute bin directories."""
179
+ reldirs = get_bin_reldirs(project)
180
+ return _get_abs_paths(project, reldirs)
181
+
182
+ def update_bin_dirs(project: Project, new_dirs: list[str] | None, flush: bool=True, show_message: bool=True) -> bool:
183
+ """Replaces the configured bin directories with the given list of new directories, and writes to pyproject.toml if flush is True.
184
+ The new_dirs should be relative paths from the project root, or absolute paths. They are recorded exactly as given. If
185
+ None is given, the configuration will be removed and the default will be used instead.
186
+
187
+ Args:
188
+ project (Project): The PDM project instance.
189
+ new_dirs (list[str] | None): The new list of bin directories to set, or None to remove the configuration and use the default.
190
+ flush (bool, optional): Whether to write the changes to pyproject.toml. Defaults to True.
191
+ show_message (bool, optional): Whether to display a message when writing to pyproject.toml. Defaults to True.
192
+
193
+ Raises:
194
+ ValueError: If the provided directory list is invalid.
195
+
196
+ Returns:
197
+ bool: True if the bin directories were changed, False otherwise.
198
+ """
199
+ if new_dirs is None:
200
+ old_value = _read_pyproject_key(project, CONFIG_DIRS_KEY)
201
+ if old_value is None:
202
+ return False
203
+ _delete_pyproject_key(project, CONFIG_DIRS_KEY, flush=flush, show_message=show_message)
204
+ else:
205
+ if not isinstance(new_dirs, list) or not all(isinstance(item, str) for item in new_dirs):
206
+ raise ValueError(f"Invalid directory list provided to `pdm bin-dir update`: {new_dirs}")
207
+ old_dirs = get_bin_reldirs(project)
208
+ if old_dirs == new_dirs:
209
+ return False
210
+ _write_pyproject_key(project, CONFIG_DIRS_KEY, new_dirs, flush=flush, show_message=show_message)
211
+
212
+ return True
213
+
214
+ class BinDirCommand(BaseCommand):
215
+ """
216
+ Display or configure additional environment search path directories for the project
217
+ """
218
+ # Note: the ArgParse help string for this command is extracted by pdm from the above docstring of this class, so it should be kept appropriate
219
+ # for that purpose rather than internal dev-facing.
220
+
221
+ cmd_name: ClassVar[str] = "bin-dir"
222
+
223
+ @override
224
+ def add_arguments(self, parser: ArgumentParser) -> None:
225
+ """Called by PDM at startup to configure the argument parser for this command."""
226
+
227
+ parser.epilog = "If no subcommand is given, the current configured directories will be displayed."
228
+ subparsers = parser.add_subparsers(dest="bindir_subcmd", title="subcommands", help="Action to perform on the project setting.")
229
+ subparsers.required = False
230
+
231
+ subparsers.add_parser("show", help="Display the configired bin directories as represented in pyproject.toml. By default, JSON list encoding is used")
232
+
233
+ set_parser = subparsers.add_parser("set", help="Set the bin directories to the given relative paths")
234
+ set_parser.add_argument("relpaths", nargs="*", help="Relative paths from the project root to set as bin directories, separated by space. If omitted, no dirs will be searched.")
235
+
236
+ add_parser = subparsers.add_parser("add", help="Add the given relative paths to the existing bin directories")
237
+ add_parser.add_argument("relpaths", nargs="*", help="Relative paths from the project root to add as bin directories, separated by space. If omitted, has no effect.")
238
+
239
+ def handle_show(self, project: Project, options: Namespace) -> int:
240
+ """`pdm bin-dir show` command handler. Display current configured bin directories to stdout.
241
+
242
+ Encodes as a JSON array of strings. Displays the relative paths as configured in pyproject.toml.
243
+
244
+ Args:
245
+ project (Project): The PDM project instance.
246
+ options (Namespace): The parsed command line options.
247
+
248
+ Returns:
249
+ int: The exit code of the command. 0 indicates success, non-zero indicates failure.
250
+ """
251
+ # TODO: Add command options to control the output format, and whether to display absolute or relative paths.
252
+ current_dirs = get_bin_reldirs(project)
253
+ print(json.dumps(current_dirs))
254
+ return 0
255
+
256
+ def handle_set(self, project: Project, options: Namespace) -> int:
257
+ """Handle the `pdm bin-dir set` command. Replace the configured bin directories with the given list of relative paths, and write to pyproject.toml.
258
+
259
+ By default, the given paths are normalized to be relative to the project root if they are within the project, or absolute if they are outside the project.
260
+
261
+ Args:
262
+ project (Project): The PDM project instance.
263
+ options (Namespace): The command line options, with a `relpaths` attribute containing the list of relative paths to set as bin directories.
264
+
265
+ Returns:
266
+ int: The exit code of the command. 0 indicates success, non-zero indicates failure.
267
+ """
268
+ relpaths: list[str] = options.relpaths
269
+ if not isinstance(relpaths, list) or not all(isinstance(item, str) for item in relpaths):
270
+ print(f"Invalid directory list provided to `pdm {self.cmd_name} set`: {relpaths}", file=sys.stderr)
271
+ return 1
272
+ relpaths = _normalize_relpaths(project, relpaths)
273
+ if update_bin_dirs(project, relpaths):
274
+ print(f"Bin directories set to: {relpaths}", file=sys.stderr)
275
+ return 0
276
+
277
+ def handle_add(self, project: Project, options: Namespace) -> int:
278
+ """Handle the `pdm bin-dir add` command. Add the given list of relative paths to the configured bin directories, and write to pyproject.toml.
279
+
280
+ By default, paths that resolve to the same absolute path as an existing configured directory will be ignored to avoid duplicates.
281
+ The new paths will be appended to the end of the existing list of directories.
282
+
283
+ By default, the given paths are normalized to be relative to the project root if they are within the project, or absolute if they are outside the project.
284
+
285
+ Args:
286
+ project (Project): The PDM project instance.
287
+ options (Namespace): The command line options, with a `relpaths` attribute containing the list of relative paths to set as bin directories.
288
+
289
+ Returns:
290
+ int: The exit code of the command. 0 indicates success, non-zero indicates failure.
291
+ """
292
+ new_relpaths: list[str] = options.relpaths
293
+ if not isinstance(new_relpaths, list) or not all(isinstance(item, str) for item in new_relpaths):
294
+ print(f"Invalid directory list provided to `pdm {self.cmd_name} add`: {new_relpaths}", file=sys.stderr)
295
+ return 1
296
+ new_relpaths = _normalize_relpaths(project, new_relpaths)
297
+ existing_abspaths = set(get_bin_dirs(project))
298
+ new_relpaths = [relpath for relpath in new_relpaths if _get_abspath(project, relpath) not in existing_abspaths]
299
+ new_paths = get_bin_reldirs(project) + new_relpaths
300
+ if update_bin_dirs(project, new_paths):
301
+ print(f"Bin directories set to: {new_paths}", file=sys.stderr)
302
+ return 0
303
+
304
+ @override
305
+ def handle(self, project: Project, options: Namespace) -> None:
306
+ """Handle the `pdm bin-dir` command and all subcommands.
307
+
308
+ Deals with raised exceptions and prints user-friendly messages to stderr. Returns appropriate exit codes for success or failure.
309
+
310
+ Args:
311
+ project (Project): The PDM project instance.
312
+ options (Namespace): The ArgParse command line options. The `BINDIR_SUBCMD` attribute indicates the subcommand
313
+ if any.
314
+ """
315
+ retcode = 1
316
+ try:
317
+ subcmd_name: str | None = options.bindir_subcmd
318
+ if subcmd_name is None or subcmd_name == "show":
319
+ retcode = self.handle_show(project, options)
320
+ elif subcmd_name == "set":
321
+ retcode = self.handle_set(project, options)
322
+ elif subcmd_name == "add":
323
+ retcode = self.handle_add(project, options)
324
+ else:
325
+ raise ValueError(f"Invalid subcommand for `pdm {self.cmd_name}`: {subcmd_name!r}")
326
+ except Exception:
327
+ raise
328
+ if retcode != 0:
329
+ sys.exit(retcode)
330
+
331
+ def _add_bin_dirs_to_path(project: Project, **_: object) -> None:
332
+ """Signal handler for the PDM `pre_invoke` signal.
333
+
334
+ called by PDM before invoking any command.
335
+
336
+ Adds the absolute form of the configured bin directories to the front of the PATH environment variable before any command is invoked.
337
+
338
+ Paths that are effectively already in PATH will not be added again to avoid duplicates. The directories appear in the order they are configured.
339
+ """
340
+ bin_dirs = get_bin_dirs(project)
341
+ if len(bin_dirs) == 0:
342
+ return
343
+ path = os.environ.get("PATH", "")
344
+ old_dir_list = [] if len(path) == 0 else path.split(os.pathsep)
345
+ existing_dirs = set(old_dir_list)
346
+ new_dirs = [bin_dir for bin_dir in bin_dirs if bin_dir not in existing_dirs]
347
+ if len(new_dirs) == 0:
348
+ return
349
+ new_path = os.pathsep.join(new_dirs + old_dir_list)
350
+ os.environ["PATH"] = new_path
351
+
352
+ def plugin(core: Core) -> None:
353
+ """PDM Plugin entry point called by PDM at startup to initialize the plugin.
354
+
355
+ Args:
356
+ core (Core): The PDM core instance.
357
+ """
358
+ pre_invoke.connect(_add_bin_dirs_to_path)
359
+ core.register_command(BinDirCommand, BinDirCommand.cmd_name)
File without changes
@@ -0,0 +1,104 @@
1
+ [build-system]
2
+ requires = [
3
+ "pdm-backend>=2.4.0",
4
+ ]
5
+ build-backend = "pdm.backend"
6
+
7
+ [project]
8
+ name = "pdm-bin-dir"
9
+ version = "1.0.5"
10
+ description = "PDM plugin that allows additional directories listed in pyproject.toml to be added to environment PATH"
11
+ readme = "README.md"
12
+ requires-python = ">=3.10"
13
+ authors = [
14
+ { name = "Sam McKelvie", email = "dev@emckelvie.org" },
15
+ ]
16
+ keywords = [
17
+ "pdm",
18
+ "plugin",
19
+ "path",
20
+ "environment",
21
+ "bin",
22
+ "virtualenv",
23
+ "script",
24
+ "activate",
25
+ ]
26
+ classifiers = [
27
+ "Development Status :: 4 - Beta",
28
+ "Environment :: Console",
29
+ "Intended Audience :: Developers",
30
+ "License :: OSI Approved :: MIT License",
31
+ "Operating System :: OS Independent",
32
+ "Programming Language :: Python :: 3",
33
+ "Programming Language :: Python :: 3.10",
34
+ "Programming Language :: Python :: 3.11",
35
+ "Programming Language :: Python :: 3.12",
36
+ "Programming Language :: Python :: 3.13",
37
+ "Topic :: Software Development",
38
+ "Topic :: Software Development :: Build Tools",
39
+ "Topic :: Software Development :: Libraries :: Python Modules",
40
+ "Topic :: Utilities",
41
+ "Typing :: Typed",
42
+ ]
43
+ dependencies = [
44
+ "pdm>=2.0",
45
+ "typing-extensions>=4.8; python_version < '3.12'",
46
+ ]
47
+
48
+ [project.license]
49
+ file = "LICENSE"
50
+
51
+ [project.entry-points.pdm]
52
+ bin-path = "pdm_bin_dir:plugin"
53
+
54
+ [dependency-groups]
55
+ dev = [
56
+ "mypy>=1.11.0",
57
+ "pytest>=8.3.0",
58
+ "ruff>=0.6.0",
59
+ "pdm>=2.27.0",
60
+ ]
61
+
62
+ [tool.pytest.ini_options]
63
+ addopts = "-ra"
64
+ testpaths = [
65
+ "tests",
66
+ ]
67
+
68
+ [tool.ruff]
69
+ target-version = "py310"
70
+ line-length = 100
71
+
72
+ [tool.ruff.lint]
73
+ select = [
74
+ "E",
75
+ "F",
76
+ "I",
77
+ "UP",
78
+ "B",
79
+ "SIM",
80
+ "C4",
81
+ ]
82
+ ignore = [
83
+ "E501",
84
+ ]
85
+
86
+ [tool.mypy]
87
+ python_version = "3.10"
88
+ strict = true
89
+ packages = [
90
+ "pdm_bin_dir",
91
+ ]
92
+
93
+ [tool.pdm]
94
+ distribution = true
95
+
96
+ [tool.pdm.build]
97
+ includes = [
98
+ "pdm_bin_dir",
99
+ ]
100
+
101
+ [tool.pdm.scripts]
102
+ test = "pytest"
103
+ lint = "ruff check ."
104
+ typecheck = "mypy pdm_bin_dir"
@@ -0,0 +1,244 @@
1
+ """Tests for pdm-bin-dir plugin."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ import tempfile
6
+ from pathlib import Path
7
+ from typing import Any
8
+ from unittest.mock import MagicMock
9
+
10
+ import pytest
11
+
12
+ import pdm_bin_dir
13
+ from pdm_bin_dir import (
14
+ CONFIG_DIRS_KEY,
15
+ CONFIG_DIRS_SUBKEY,
16
+ CONFIG_GROUP,
17
+ DEFAULT_BIN_DIRS,
18
+ BinDirCommand,
19
+ _add_bin_dirs_to_path,
20
+ _get_abspath,
21
+ _normalize_relpath,
22
+ get_bin_dirs,
23
+ get_bin_reldirs,
24
+ plugin,
25
+ update_bin_dirs,
26
+ )
27
+
28
+
29
+ def _mock_project(tmpdir: str, config: dict[str, Any]) -> MagicMock:
30
+ """Return a minimal mock PDM Project backed by the given config dict."""
31
+ project = MagicMock()
32
+ project.root = Path(tmpdir)
33
+ project.pyproject.open_for_read.return_value = config
34
+ project.pyproject.open_for_write.return_value = config
35
+ return project
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Module-level exports and constants
40
+ # ---------------------------------------------------------------------------
41
+
42
+ def test_version() -> None:
43
+ assert isinstance(pdm_bin_dir.__version__, str)
44
+ assert pdm_bin_dir.__version__ != ""
45
+
46
+
47
+ def test_constants() -> None:
48
+ assert CONFIG_GROUP == "tool.pdm.plugin.bin-dir"
49
+ assert CONFIG_DIRS_SUBKEY == "dirs"
50
+ assert CONFIG_DIRS_KEY == "tool.pdm.plugin.bin-dir.dirs"
51
+ assert DEFAULT_BIN_DIRS == []
52
+
53
+
54
+ def test_exports_callable() -> None:
55
+ assert callable(plugin)
56
+ assert callable(get_bin_reldirs)
57
+ assert callable(get_bin_dirs)
58
+ assert issubclass(BinDirCommand, object)
59
+
60
+
61
+ # ---------------------------------------------------------------------------
62
+ # Path utilities
63
+ # ---------------------------------------------------------------------------
64
+
65
+ def test_get_abspath_relative() -> None:
66
+ with tempfile.TemporaryDirectory() as tmpdir:
67
+ project = _mock_project(tmpdir, {})
68
+ result = _get_abspath(project, "bin")
69
+ assert result == os.path.join(tmpdir, "bin")
70
+ assert os.path.isabs(result)
71
+
72
+
73
+ def test_normalize_relpath_inside_project() -> None:
74
+ with tempfile.TemporaryDirectory() as tmpdir:
75
+ project = _mock_project(tmpdir, {})
76
+ assert _normalize_relpath(project, "bin") == "bin"
77
+ assert _normalize_relpath(project, "scripts/tools") == os.path.join("scripts", "tools")
78
+
79
+
80
+ def test_normalize_relpath_outside_project() -> None:
81
+ with tempfile.TemporaryDirectory() as tmpdir, tempfile.TemporaryDirectory() as outside:
82
+ project = _mock_project(tmpdir, {})
83
+ result = _normalize_relpath(project, outside)
84
+ assert os.path.isabs(result)
85
+
86
+
87
+ # ---------------------------------------------------------------------------
88
+ # get_bin_reldirs
89
+ # ---------------------------------------------------------------------------
90
+
91
+ def test_get_bin_reldirs_default() -> None:
92
+ with tempfile.TemporaryDirectory() as tmpdir:
93
+ project = _mock_project(tmpdir, {})
94
+ assert get_bin_reldirs(project) == []
95
+
96
+
97
+ def test_get_bin_reldirs_configured_list() -> None:
98
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["scripts", "tools"]}}}}}
99
+ with tempfile.TemporaryDirectory() as tmpdir:
100
+ project = _mock_project(tmpdir, config)
101
+ assert get_bin_reldirs(project) == ["scripts", "tools"]
102
+
103
+
104
+ def test_get_bin_reldirs_configured_empty() -> None:
105
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": []}}}}}
106
+ with tempfile.TemporaryDirectory() as tmpdir:
107
+ project = _mock_project(tmpdir, config)
108
+ assert get_bin_reldirs(project) == []
109
+
110
+
111
+ def test_get_bin_reldirs_configured_string() -> None:
112
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": "scripts"}}}}}
113
+ with tempfile.TemporaryDirectory() as tmpdir:
114
+ project = _mock_project(tmpdir, config)
115
+ assert get_bin_reldirs(project) == ["scripts"]
116
+
117
+
118
+ def test_get_bin_reldirs_invalid_raises() -> None:
119
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": 42}}}}}
120
+ with tempfile.TemporaryDirectory() as tmpdir:
121
+ project = _mock_project(tmpdir, config)
122
+ with pytest.raises(ValueError):
123
+ get_bin_reldirs(project)
124
+
125
+
126
+ # ---------------------------------------------------------------------------
127
+ # get_bin_dirs
128
+ # ---------------------------------------------------------------------------
129
+
130
+ def test_get_bin_dirs_default_empty() -> None:
131
+ with tempfile.TemporaryDirectory() as tmpdir:
132
+ project = _mock_project(tmpdir, {})
133
+ assert get_bin_dirs(project) == []
134
+
135
+
136
+ def test_get_bin_dirs_absolute() -> None:
137
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["bin"]}}}}}
138
+ with tempfile.TemporaryDirectory() as tmpdir:
139
+ project = _mock_project(tmpdir, config)
140
+ result = get_bin_dirs(project)
141
+ assert result == [os.path.join(tmpdir, "bin")]
142
+ assert all(os.path.isabs(p) for p in result)
143
+
144
+
145
+ def test_get_bin_dirs_custom() -> None:
146
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["scripts", "tools"]}}}}}
147
+ with tempfile.TemporaryDirectory() as tmpdir:
148
+ project = _mock_project(tmpdir, config)
149
+ result = get_bin_dirs(project)
150
+ assert result == [
151
+ os.path.join(tmpdir, "scripts"),
152
+ os.path.join(tmpdir, "tools"),
153
+ ]
154
+
155
+
156
+ # ---------------------------------------------------------------------------
157
+ # update_bin_dirs
158
+ # ---------------------------------------------------------------------------
159
+
160
+ def test_update_bin_dirs_set_new() -> None:
161
+ config: dict[str, Any] = {}
162
+ with tempfile.TemporaryDirectory() as tmpdir:
163
+ project = _mock_project(tmpdir, config)
164
+ changed = update_bin_dirs(project, ["scripts"], flush=False)
165
+ assert changed is True
166
+
167
+
168
+ def test_update_bin_dirs_no_change() -> None:
169
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["bin"]}}}}}
170
+ with tempfile.TemporaryDirectory() as tmpdir:
171
+ project = _mock_project(tmpdir, config)
172
+ changed = update_bin_dirs(project, ["bin"], flush=False)
173
+ assert changed is False
174
+
175
+
176
+ def test_update_bin_dirs_remove_config() -> None:
177
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["scripts"]}}}}}
178
+ with tempfile.TemporaryDirectory() as tmpdir:
179
+ project = _mock_project(tmpdir, config)
180
+ changed = update_bin_dirs(project, None, flush=False)
181
+ assert changed is True
182
+
183
+
184
+ def test_update_bin_dirs_remove_when_not_set() -> None:
185
+ with tempfile.TemporaryDirectory() as tmpdir:
186
+ project = _mock_project(tmpdir, {})
187
+ changed = update_bin_dirs(project, None, flush=False)
188
+ assert changed is False
189
+
190
+
191
+ # ---------------------------------------------------------------------------
192
+ # _add_bin_dirs_to_path
193
+ # ---------------------------------------------------------------------------
194
+
195
+ def test_add_bin_dirs_to_path_prepends() -> None:
196
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["bin"]}}}}}
197
+ with tempfile.TemporaryDirectory() as tmpdir:
198
+ project = _mock_project(tmpdir, config)
199
+ expected = os.path.join(tmpdir, "bin")
200
+ old_path = os.environ.get("PATH", "")
201
+ try:
202
+ _add_bin_dirs_to_path(project=project)
203
+ new_entries = os.environ["PATH"].split(os.pathsep)
204
+ assert new_entries[0] == expected
205
+ finally:
206
+ os.environ["PATH"] = old_path
207
+
208
+
209
+ def test_add_bin_dirs_to_path_no_op_when_unconfigured() -> None:
210
+ with tempfile.TemporaryDirectory() as tmpdir:
211
+ project = _mock_project(tmpdir, {})
212
+ old_path = os.environ.get("PATH", "")
213
+ try:
214
+ _add_bin_dirs_to_path(project=project)
215
+ assert os.environ.get("PATH", "") == old_path
216
+ finally:
217
+ os.environ["PATH"] = old_path
218
+
219
+
220
+ def test_add_bin_dirs_to_path_no_duplicate() -> None:
221
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": ["bin"]}}}}}
222
+ with tempfile.TemporaryDirectory() as tmpdir:
223
+ project = _mock_project(tmpdir, config)
224
+ bin_dir = os.path.join(tmpdir, "bin")
225
+ old_path = os.environ.get("PATH", "")
226
+ os.environ["PATH"] = bin_dir + os.pathsep + old_path
227
+ try:
228
+ _add_bin_dirs_to_path(project=project)
229
+ entries = os.environ["PATH"].split(os.pathsep)
230
+ assert entries.count(bin_dir) == 1
231
+ finally:
232
+ os.environ["PATH"] = old_path
233
+
234
+
235
+ def test_add_bin_dirs_to_path_empty_dirs() -> None:
236
+ config = {"tool": {"pdm": {"plugin": {"bin-dir": {"dirs": []}}}}}
237
+ with tempfile.TemporaryDirectory() as tmpdir:
238
+ project = _mock_project(tmpdir, config)
239
+ old_path = os.environ.get("PATH", "")
240
+ try:
241
+ _add_bin_dirs_to_path(project=project)
242
+ assert os.environ.get("PATH", "") == old_path
243
+ finally:
244
+ os.environ["PATH"] = old_path