Skip to content

Commit 5152b6b

Browse files
committed
Merge branch 'master' of github.com:kcdodd/partis-pyproj
2 parents 2337e14 + 745e51b commit 5152b6b

5 files changed

Lines changed: 238 additions & 64 deletions

File tree

README.md

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,51 @@ The guiding principles adopted for ``partis.pyproj`` are:
1313
* A distribution is simply a collection of files,
1414
plus package meta-data for either source or binary (wheel) distribution formats.
1515

16+
### Quickstart
17+
18+
Below is a minimal example project structure for a pure Python package
19+
named `myproj`, and backend configuration in `pyproject.toml` to build
20+
source and binary (wheel) distributions for installation:
21+
22+
- `src/myproj/__init__.py`
23+
- `tests/test_everything.py`
24+
- `LICENSE.txt`
25+
- `pyproject.toml`
26+
- `README.md`
27+
28+
29+
```toml
30+
# pyproject.toml
31+
[project]
32+
name = "myproj"
33+
description = "Project myproj"
34+
version = "0.0.1"
35+
readme = { file = "README.md" }
36+
license = { file = "LICENSE.txt" }
37+
dependencies = ['typing-extensions']
38+
39+
[dependency-groups]
40+
test = ['pytest']
41+
42+
[build-system]
43+
requires = ["partis-pyproj"]
44+
# point the frontend to the backend partis-pyproj
45+
build-backend = "partis.pyproj.backend"
46+
47+
# configure the backend
48+
[tool.pyproj.dist]
49+
# patterns to ignore for both source and wheel distributions
50+
ignore = ['__pycache__', '*.py[cod]', '*.so', '*.egg-info', '.nox', '.pytest_cache', '.coverage']
51+
52+
[tool.pyproj.dist.source]
53+
# copy everything needed to re-distribute the source code (pyproject.toml, readme, and license are added automatically)
54+
copy = ["src", "tests"]
55+
56+
[tool.pyproj.dist.binary.purelib]
57+
# copy how it should appear installed in site-packages
58+
copy = [{ src = "src/myproj", dst = "myproj" }]
59+
```
60+
1661
The process of building a source or binary distribution is broken down into
1762
three general stages:
1863

@@ -24,7 +69,8 @@ three general stages:
2469
configuration, but otherwise avoids taking on the responsibility of a full build system.
2570
- **copy** - Copy files into the distribution.
2671

27-
72+
Running `python -m build` (or `pip wheel .`, `pip install .`, etc.) executes the `prepare`, `build`,
73+
and `copy` stages in order and writes the resulting sdist or wheel.
2874
The sequence of actions for a distribution is roughly:
2975

3076
- `tool.pyproj.prep`: Run before anything else, used to fill in dynamic metadata or
@@ -55,10 +101,18 @@ formats and behaviors.
55101
* `dst` is relative, specifically depending on whether it is a source or binary (wheel) distribution and which install scheme is desired (`purelib`, `platlib`, etc.).
56102
* Destination file paths are constructed from matched source paths roughly equivalent
57103
to `{scheme}/dst/match.relative_to(src)`.
104+
* Symlinks that resolve within the project root are preserved; links that point
105+
outside the tree or are dangling result in an error. Hidden files are treated
106+
like any other path unless ignored.
107+
* Pattern matching uses POSIX-style `/` separators. On case-insensitive file
108+
systems (e.g. Windows) different source files that would map to the same
109+
destination path are considered collisions and abort the copy.
110+
* If multiple include rules map to the same destination file, the backend raises
111+
an error to avoid silent overwrites.
58112

59113
**Include patterns**
60114

61-
* An `include` list is used to filter files or directories to be copied, expanded
115+
* An include list is used to filter files or directories to be copied, expanded
62116
to zero or more matches relative to `src`.
63117
* `glob` follows the format of [Path.glob](https://docs.python.org/3/library/pathlib.html#pathlib.Path.glob).
64118
If recursive pattern `**` is used, the glob will *not* match directories,
@@ -70,6 +124,14 @@ formats and behaviors.
70124
* `strip` can remove (up to) the given number of path components from the relative
71125
`src` path.
72126

127+
For example,
128+
129+
```toml
130+
include = [{ glob = "src/**/*.py", rematch = "src/(.*)", replace = "{0}", strip = 1 }]
131+
```
132+
133+
copies `src/pkg/mod.py` into the destination as `pkg/mod.py`.
134+
73135
**Ignore patterns**
74136

75137
* An `ignore` list follows the format of [git-ignore](https://git-scm.com/docs/gitignore#_pattern_format).
@@ -267,6 +329,11 @@ build_clean: BOOL? # control cleanup (ie for development builds)
267329
enabled: (BOOL|MARKER)? # environment marker
268330
```
269331

332+
Targets are executed sequentially. If a target fails or its entry point cannot
333+
be resolved, the remaining targets are skipped and the build aborts with an
334+
error message. Parallel execution is intentionally unsupported to keep ordering
335+
and cleanup deterministic.
336+
270337
There are several entry points available as-is:
271338

272339
- `partis.pyproj.builder:meson` - Support for [Meson Build system](https://mesonbuild.com/) with the 'extra' ``partis-pyproj[meson]``
@@ -468,6 +535,10 @@ are available in any processing hook.
468535
Combined with an entry-point `kwargs`, these can be used to keep all
469536
conditional dependencies listed in ``pyproject.toml``.
470537

538+
Passing an option that is not declared in `tool.pyproj.config` or providing a
539+
value of the wrong type results in a validation error before any build hooks are
540+
executed.
541+
471542

472543
The type is derived from the value parsed from ``pyproject.toml``.
473544
For example, the value of `3` is parsed as an integer, while ``3.0`` is parsed

src/pyproj/builder/__init__.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,4 @@
33
from .process import process
44
from .download import download
55
from .meson import meson
6-
from .cmake import cmake
7-
from .cargo import cargo
6+
from .cmake import cmake

src/pyproj/builder/cargo.py

Lines changed: 0 additions & 56 deletions
This file was deleted.

src/pyproj/cli/init_pyproj.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,13 +63,14 @@ def _init_pyproj(
6363
license_file: str = None
6464
copy_sources: list[str] = []
6565

66-
root.mkdir(exist_ok = True)
66+
if not root.exists():
67+
root.mkdir()
6768

68-
if not root.is_dir():
69-
raise ValueError(f"Project is not a directory: {root}")
69+
elif not root.is_dir():
70+
raise FileExistsError(f"Project is not a directory: {root}")
7071

7172
if pptoml_file.exists():
72-
raise ValueError(f"Project already exists: {pptoml_file}")
73+
raise FileExistsError(f"Project already exists: {pptoml_file}")
7374

7475
if project is None:
7576
project = root.name

tests/test_14_cli_init.py

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
import sys
5+
import tomli
6+
import pytest
7+
from partis.pyproj.cli.init_pyproj import _init_pyproj
8+
from partis.pyproj.cli import __main__ as cli
9+
10+
#==============================================================================
11+
12+
def _stub_metadata(_pkg: str):
13+
return {"version": "1.0.0"}
14+
15+
#==============================================================================
16+
17+
def test_init_pyproj_creates_files(tmp_path, monkeypatch):
18+
project_dir = tmp_path / "sample"
19+
project_dir.mkdir()
20+
pkg_dir = project_dir / "sample"
21+
pkg_dir.mkdir()
22+
(pkg_dir / "__init__.py").write_text("# pkg init\n")
23+
(project_dir / "extra.txt").write_text("data")
24+
25+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
26+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
27+
28+
_init_pyproj(path=project_dir, project="sample", version="0.1.0", description="example")
29+
30+
pptoml_path = project_dir / "pyproject.toml"
31+
assert pptoml_path.exists()
32+
data = tomli.loads(pptoml_path.read_text())
33+
assert data["project"]["name"] == "sample"
34+
assert data["project"]["version"] == "0.1.0"
35+
assert (project_dir / "README.md").exists()
36+
assert (project_dir / "LICENSE.txt").exists()
37+
38+
#==============================================================================
39+
40+
def test_init_pyproj_abort(tmp_path, monkeypatch):
41+
project_dir = tmp_path / "abort"
42+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
43+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "n")
44+
45+
_init_pyproj(path=project_dir, project="abort", version="0.1.0", description="")
46+
47+
assert not (project_dir / "pyproject.toml").exists()
48+
assert not (project_dir / "README.md").exists()
49+
assert not (project_dir / "LICENSE.txt").exists()
50+
51+
#==============================================================================
52+
def test_cli_main_help(tmp_path, monkeypatch):
53+
def _input(*args, **kwargs):
54+
assert False
55+
56+
monkeypatch.setattr("builtins.input", _input)
57+
monkeypatch.setattr(sys, "argv", ["partis-pyproj"])
58+
59+
cli.main()
60+
61+
#==============================================================================
62+
def test_cli_main_creates_project(tmp_path, monkeypatch):
63+
project_dir = tmp_path / "cli_proj"
64+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
65+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
66+
monkeypatch.setattr(sys, "argv", [
67+
"partis-pyproj", "init", "--name", "cli_proj", "--version", "2.0.0", "--desc", "cli", str(project_dir)
68+
])
69+
70+
cli.main()
71+
72+
pptoml_path = project_dir / "pyproject.toml"
73+
assert pptoml_path.exists()
74+
data = tomli.loads(pptoml_path.read_text())
75+
assert data["project"]["name"] == "cli_proj"
76+
assert data["project"]["version"] == "2.0.0"
77+
78+
#==============================================================================
79+
80+
81+
def test_init_pyproj_defaults(tmp_path, monkeypatch):
82+
project_dir = tmp_path / "auto"
83+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
84+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
85+
86+
_init_pyproj(path=project_dir, project=None, version="0.1.0", description="")
87+
88+
data = tomli.loads((project_dir / "pyproject.toml").read_text())
89+
assert data["project"]["name"] == "auto"
90+
assert data["project"]["description"] == "Package for auto"
91+
92+
93+
def test_init_pyproj_description_default_with_name(tmp_path, monkeypatch):
94+
project_dir = tmp_path / "desc_only"
95+
project_dir.mkdir()
96+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
97+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
98+
99+
_init_pyproj(path=project_dir, project="pkg", version="0.1.0", description="")
100+
101+
data = tomli.loads((project_dir / "pyproject.toml").read_text())
102+
assert data["project"]["name"] == "pkg"
103+
assert data["project"]["description"] == "Package for pkg"
104+
105+
106+
def test_init_pyproj_path_not_directory(tmp_path, monkeypatch):
107+
project_file = tmp_path / "notdir"
108+
project_file.write_text("data")
109+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
110+
111+
with pytest.raises(FileExistsError):
112+
_init_pyproj(path=project_file, project="x", version="0.1.0", description="x")
113+
114+
115+
def test_init_pyproj_existing_pyproject(tmp_path, monkeypatch):
116+
project_dir = tmp_path / "exists"
117+
project_dir.mkdir()
118+
(project_dir / "pyproject.toml").write_text("[project]\nname='exists'\n")
119+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
120+
121+
with pytest.raises(FileExistsError):
122+
_init_pyproj(path=project_dir, project="exists", version="0.1.0", description="")
123+
124+
125+
def test_init_pyproj_respects_gitignore(tmp_path, monkeypatch):
126+
project_dir = tmp_path / "gitignore"
127+
project_dir.mkdir()
128+
pkg_dir = project_dir / "gitignore"
129+
pkg_dir.mkdir()
130+
(pkg_dir / "__init__.py").write_text("# pkg init\n")
131+
(project_dir / ".gitignore").write_text("skip.txt\n")
132+
(project_dir / "skip.txt").write_text("data")
133+
(project_dir / "keep.txt").write_text("data")
134+
135+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
136+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
137+
138+
_init_pyproj(path=project_dir, project="gitignore", version="0.1.0", description="gi")
139+
140+
data = tomli.loads((project_dir / "pyproject.toml").read_text())
141+
sources = data["tool"]["pyproj"]["dist"]["source"]["copy"]
142+
assert "keep.txt" in sources
143+
assert "skip.txt" not in sources
144+
145+
146+
def test_init_pyproj_preserves_existing_readme_and_license(tmp_path, monkeypatch):
147+
project_dir = tmp_path / "preserve"
148+
project_dir.mkdir()
149+
(project_dir / "README.md").write_text("existing readme")
150+
(project_dir / "LICENSE.txt").write_text("existing license")
151+
monkeypatch.setattr("partis.pyproj.cli.init_pyproj.metadata", _stub_metadata)
152+
monkeypatch.setattr("builtins.input", lambda *args, **kwargs: "y")
153+
154+
_init_pyproj(path=project_dir, project="preserve", version="0.1.0", description="desc")
155+
156+
assert (project_dir / "README.md").read_text() == "existing readme"
157+
assert (project_dir / "LICENSE.txt").read_text() == "existing license"
158+
159+

0 commit comments

Comments
 (0)