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.
- pdm_bin_dir-1.0.5/LICENSE +21 -0
- pdm_bin_dir-1.0.5/PKG-INFO +182 -0
- pdm_bin_dir-1.0.5/README.md +134 -0
- pdm_bin_dir-1.0.5/pdm_bin_dir/__init__.py +359 -0
- pdm_bin_dir-1.0.5/pdm_bin_dir/py.typed +0 -0
- pdm_bin_dir-1.0.5/pyproject.toml +104 -0
- pdm_bin_dir-1.0.5/tests/test_pdm_bin_dir.py +244 -0
|
@@ -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
|
+
[](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml)
|
|
52
|
+
[](https://pypi.org/project/pdm-bin-dir/)
|
|
53
|
+
[](https://pypi.org/project/pdm-bin-dir/)
|
|
54
|
+
[](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
|
+
[](https://github.com/mckelvie-org/pdm-bin-dir/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/pdm-bin-dir/)
|
|
5
|
+
[](https://pypi.org/project/pdm-bin-dir/)
|
|
6
|
+
[](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
|