python-package-copier-template 0.4.3__tar.gz → 0.6.0__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.
- {python_package_copier_template-0.4.3 → python_package_copier_template-0.6.0}/PKG-INFO +22 -17
- {python_package_copier_template-0.4.3 → python_package_copier_template-0.6.0}/README.md +20 -14
- python_package_copier_template-0.6.0/pyproject.toml +97 -0
- {python_package_copier_template-0.4.3 → python_package_copier_template-0.6.0}/python_package_copier_template/__init__.py +2 -0
- {python_package_copier_template-0.4.3 → python_package_copier_template-0.6.0}/python_package_copier_template/cli.py +16 -2
- python_package_copier_template-0.6.0/python_package_copier_template/extensions.py +197 -0
- python_package_copier_template-0.4.3/pyproject.toml +0 -44
- python_package_copier_template-0.4.3/python_package_copier_template/extensions.py +0 -140
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: python-package-copier-template
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.6.0
|
|
4
4
|
Summary: A Copier template for modern Python projects.
|
|
5
5
|
Author: Martín Gaitán
|
|
6
6
|
Author-email: Martín Gaitán <gaitan@gmail.com>
|
|
7
|
-
Requires-Dist: copier>=9.
|
|
7
|
+
Requires-Dist: copier>=9.17.0
|
|
8
8
|
Requires-Dist: copier-templates-extensions>=0.3.2
|
|
9
|
-
Requires-Dist: ruff>=0.14.9
|
|
10
9
|
Requires-Python: >=3.12
|
|
11
10
|
Description-Content-Type: text/markdown
|
|
12
11
|
|
|
@@ -14,18 +13,22 @@ Description-Content-Type: text/markdown
|
|
|
14
13
|
|
|
15
14
|
[](https://github.com/copier-org/copier)
|
|
16
15
|
[](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml)
|
|
16
|
+
[](https://pypi.org/project/python-package-copier-template/)
|
|
17
17
|
[](https://github.com/mgaitan/python-package-copier-template/releases)
|
|
18
18
|
|
|
19
19
|
A [Copier](https://github.com/copier-org/copier) template
|
|
20
20
|
for modern Python projects.
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
Canonical example repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
|
|
23
|
+
|
|
24
|
+
Documentation: <https://mgaitan.github.io/python-package-copier-template/>
|
|
23
25
|
|
|
24
26
|
## Features
|
|
25
27
|
|
|
26
28
|
- 🐍 Modern Python package (3.12+)
|
|
27
29
|
- 📦 Build and dependency management with [uv](https://docs.astral.sh/uv/), split by groups (dev/qa/docs)
|
|
28
30
|
- 🧊 Dependency cooldowns enabled by default in `uv` (`[tool.uv].exclude-newer = "1 week"`), with targeted overrides when needed (for example `ty`) to reduce supply-chain risk without blocking QA tools
|
|
31
|
+
- 🛡️ uv malware checks enabled in Make targets and GitHub Actions to reject locked dependencies with known malicious-package advisories
|
|
29
32
|
- 🧹 Linting and formatting via [Ruff](https://docs.astral.sh/ruff/) with a broad set of rules enabled
|
|
30
33
|
- ✅ Type checking via [ty](https://github.com/astral-sh/ty)
|
|
31
34
|
- 🪝 Optional pre-commit style QA orchestration via [prek](https://github.com/j178/prek) as an external tool (`uv tool install prek`; hooks include `check-ast`, `check-yaml`, `check-toml`, Ruff, Ty)
|
|
@@ -38,23 +41,17 @@ Demo repo generated from this template: [mgaitan/yet-another-demo](https://githu
|
|
|
38
41
|
- 🚀 Automated releases to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
|
|
39
42
|
- 🧠 Sensible defaults via introspection to minimize answers during the initial setup
|
|
40
43
|
- 🛠️ Makefile with shortcuts for common tasks
|
|
41
|
-
- 📄 Generation of generic docs such as `
|
|
44
|
+
- 📄 Generation of generic docs such as `CODE_OF_CONDUCT`, plus an optional `LICENSE`
|
|
42
45
|
- 🤖 Heavily curated [AGENTS.md](https://agents.md/)
|
|
43
46
|
- 🌀 Initial setup of the development environment and git repo
|
|
44
47
|
- 🔁 Scheduled template refresh workflow that opens a PR every 20 days when updates are available
|
|
45
48
|
- ♻️ Projects updatable with [`copier update`](https://copier.readthedocs.io/en/stable/updating/)
|
|
46
49
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
+
> [!NOTE]
|
|
51
|
+
> Sister project for Django internal management systems:
|
|
52
|
+
> [mgaitan/django-unfold-copier-template](https://github.com/mgaitan/django-unfold-copier-template).
|
|
50
53
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
- [Tutorial: Getting started](docs/getting_started.md)
|
|
54
|
-
- [How-to: Maintain the template](docs/maintain_template.md)
|
|
55
|
-
- [Reference: Configuration](docs/configuration.md)
|
|
56
|
-
- [Explanation: About these docs](docs/about_the_docs.md)
|
|
57
|
-
- [Explanation: Design decisions](docs/design_decisions.md)
|
|
54
|
+
Please read [my blog post](https://mgaitan.github.io/en/posts/opinionated-python-project-scaffolding/) to learn about the details of the decisions I made and the alternatives I considered.
|
|
58
55
|
|
|
59
56
|
## Quick setup and usage
|
|
60
57
|
|
|
@@ -88,7 +85,11 @@ To upgrade an existing project created from this template to the latest version,
|
|
|
88
85
|
uvx --with=copier-template-extensions copier update . --trust
|
|
89
86
|
```
|
|
90
87
|
|
|
91
|
-
This
|
|
88
|
+
This fetches the latest template version and guides you through updating your project.
|
|
89
|
+
Existing `README.md` and `docs/**` files are left untouched so project-specific
|
|
90
|
+
documentation is not replaced. New documentation files are still added when the
|
|
91
|
+
template introduces them; adopt later changes to existing pages manually from the
|
|
92
|
+
template diff when they are relevant to your project.
|
|
92
93
|
|
|
93
94
|
The generated project also ships a `Template Update` GitHub Actions workflow that runs every 20 days (or on manual dispatch) to execute `uvx copier update --trust --defaults .` and open a pull request with the changes and template version bump.
|
|
94
95
|
|
|
@@ -99,7 +100,11 @@ uv sync
|
|
|
99
100
|
uv run copier copy --trust --vcs-ref=HEAD . /path/to/your/test/project
|
|
100
101
|
```
|
|
101
102
|
|
|
102
|
-
If you create the GitHub repository via the `gh` CLI prompt, the template will
|
|
103
|
+
If you create the GitHub repository via the `gh` CLI prompt, the template will
|
|
104
|
+
attempt to enable immutable releases and GitHub Pages (using the Actions build
|
|
105
|
+
type). Immutability protects assets and tags for future releases. If Pages is
|
|
106
|
+
unavailable (for example, with some private repositories or account policies),
|
|
107
|
+
the docs workflow will keep failing until Pages is allowed.
|
|
103
108
|
|
|
104
109
|
|
|
105
110
|
To publish a release of your project to PyPI, you need to [register the project with trusted publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). Read more about how this workflow works [here](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/).
|
|
@@ -2,18 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/copier-org/copier)
|
|
4
4
|
[](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml)
|
|
5
|
+
[](https://pypi.org/project/python-package-copier-template/)
|
|
5
6
|
[](https://github.com/mgaitan/python-package-copier-template/releases)
|
|
6
7
|
|
|
7
8
|
A [Copier](https://github.com/copier-org/copier) template
|
|
8
9
|
for modern Python projects.
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
Canonical example repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
|
|
12
|
+
|
|
13
|
+
Documentation: <https://mgaitan.github.io/python-package-copier-template/>
|
|
11
14
|
|
|
12
15
|
## Features
|
|
13
16
|
|
|
14
17
|
- 🐍 Modern Python package (3.12+)
|
|
15
18
|
- 📦 Build and dependency management with [uv](https://docs.astral.sh/uv/), split by groups (dev/qa/docs)
|
|
16
19
|
- 🧊 Dependency cooldowns enabled by default in `uv` (`[tool.uv].exclude-newer = "1 week"`), with targeted overrides when needed (for example `ty`) to reduce supply-chain risk without blocking QA tools
|
|
20
|
+
- 🛡️ uv malware checks enabled in Make targets and GitHub Actions to reject locked dependencies with known malicious-package advisories
|
|
17
21
|
- 🧹 Linting and formatting via [Ruff](https://docs.astral.sh/ruff/) with a broad set of rules enabled
|
|
18
22
|
- ✅ Type checking via [ty](https://github.com/astral-sh/ty)
|
|
19
23
|
- 🪝 Optional pre-commit style QA orchestration via [prek](https://github.com/j178/prek) as an external tool (`uv tool install prek`; hooks include `check-ast`, `check-yaml`, `check-toml`, Ruff, Ty)
|
|
@@ -26,23 +30,17 @@ Demo repo generated from this template: [mgaitan/yet-another-demo](https://githu
|
|
|
26
30
|
- 🚀 Automated releases to PyPI via [Trusted Publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/)
|
|
27
31
|
- 🧠 Sensible defaults via introspection to minimize answers during the initial setup
|
|
28
32
|
- 🛠️ Makefile with shortcuts for common tasks
|
|
29
|
-
- 📄 Generation of generic docs such as `
|
|
33
|
+
- 📄 Generation of generic docs such as `CODE_OF_CONDUCT`, plus an optional `LICENSE`
|
|
30
34
|
- 🤖 Heavily curated [AGENTS.md](https://agents.md/)
|
|
31
35
|
- 🌀 Initial setup of the development environment and git repo
|
|
32
36
|
- 🔁 Scheduled template refresh workflow that opens a PR every 20 days when updates are available
|
|
33
37
|
- ♻️ Projects updatable with [`copier update`](https://copier.readthedocs.io/en/stable/updating/)
|
|
34
38
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
39
|
+
> [!NOTE]
|
|
40
|
+
> Sister project for Django internal management systems:
|
|
41
|
+
> [mgaitan/django-unfold-copier-template](https://github.com/mgaitan/django-unfold-copier-template).
|
|
38
42
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
- [Tutorial: Getting started](docs/getting_started.md)
|
|
42
|
-
- [How-to: Maintain the template](docs/maintain_template.md)
|
|
43
|
-
- [Reference: Configuration](docs/configuration.md)
|
|
44
|
-
- [Explanation: About these docs](docs/about_the_docs.md)
|
|
45
|
-
- [Explanation: Design decisions](docs/design_decisions.md)
|
|
43
|
+
Please read [my blog post](https://mgaitan.github.io/en/posts/opinionated-python-project-scaffolding/) to learn about the details of the decisions I made and the alternatives I considered.
|
|
46
44
|
|
|
47
45
|
## Quick setup and usage
|
|
48
46
|
|
|
@@ -76,7 +74,11 @@ To upgrade an existing project created from this template to the latest version,
|
|
|
76
74
|
uvx --with=copier-template-extensions copier update . --trust
|
|
77
75
|
```
|
|
78
76
|
|
|
79
|
-
This
|
|
77
|
+
This fetches the latest template version and guides you through updating your project.
|
|
78
|
+
Existing `README.md` and `docs/**` files are left untouched so project-specific
|
|
79
|
+
documentation is not replaced. New documentation files are still added when the
|
|
80
|
+
template introduces them; adopt later changes to existing pages manually from the
|
|
81
|
+
template diff when they are relevant to your project.
|
|
80
82
|
|
|
81
83
|
The generated project also ships a `Template Update` GitHub Actions workflow that runs every 20 days (or on manual dispatch) to execute `uvx copier update --trust --defaults .` and open a pull request with the changes and template version bump.
|
|
82
84
|
|
|
@@ -87,7 +89,11 @@ uv sync
|
|
|
87
89
|
uv run copier copy --trust --vcs-ref=HEAD . /path/to/your/test/project
|
|
88
90
|
```
|
|
89
91
|
|
|
90
|
-
If you create the GitHub repository via the `gh` CLI prompt, the template will
|
|
92
|
+
If you create the GitHub repository via the `gh` CLI prompt, the template will
|
|
93
|
+
attempt to enable immutable releases and GitHub Pages (using the Actions build
|
|
94
|
+
type). Immutability protects assets and tags for future releases. If Pages is
|
|
95
|
+
unavailable (for example, with some private repositories or account policies),
|
|
96
|
+
the docs workflow will keep failing until Pages is allowed.
|
|
91
97
|
|
|
92
98
|
|
|
93
99
|
To publish a release of your project to PyPI, you need to [register the project with trusted publishing](https://docs.pypi.org/trusted-publishers/creating-a-project-through-oidc/). Read more about how this workflow works [here](https://packaging.python.org/en/latest/guides/publishing-package-distribution-releases-using-github-actions-ci-cd-workflows/).
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "python-package-copier-template"
|
|
3
|
+
version = "0.6.0"
|
|
4
|
+
description = "A Copier template for modern Python projects."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [{ name = "Martín Gaitán", email = "gaitan@gmail.com" }]
|
|
7
|
+
requires-python = ">=3.12"
|
|
8
|
+
dependencies = [
|
|
9
|
+
"copier>=9.17.0",
|
|
10
|
+
"copier-templates-extensions>=0.3.2",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[project.scripts]
|
|
14
|
+
python-package-copier-template = "python_package_copier_template.cli:main"
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.32,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv.build-backend]
|
|
21
|
+
module-name = "python_package_copier_template"
|
|
22
|
+
module-root = ""
|
|
23
|
+
|
|
24
|
+
[tool.ty.src]
|
|
25
|
+
exclude = [".worktrees/", ".external-worktrees/"]
|
|
26
|
+
|
|
27
|
+
[tool.ruff]
|
|
28
|
+
line-length = 120
|
|
29
|
+
|
|
30
|
+
[tool.ruff.lint]
|
|
31
|
+
select = [
|
|
32
|
+
"E", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
|
33
|
+
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
|
34
|
+
"F", # https://docs.astral.sh/ruff/rules/#pyflakes-f
|
|
35
|
+
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
|
36
|
+
"C90", # https://docs.astral.sh/ruff/rules/#mccabe-c90
|
|
37
|
+
"A", # https://docs.astral.sh/ruff/rules/#flake8-builtins-a
|
|
38
|
+
"ANN", # https://docs.astral.sh/ruff/rules/#flake8-annotations-ann
|
|
39
|
+
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
|
40
|
+
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
|
41
|
+
"T10", # https://docs.astral.sh/ruff/rules/#flake8-debugger-t10
|
|
42
|
+
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
|
43
|
+
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
|
44
|
+
"ASYNC", # https://docs.astral.sh/ruff/rules/#flake8-async-async
|
|
45
|
+
"ERA", # https://docs.astral.sh/ruff/rules/#eradicate-era
|
|
46
|
+
"TRY", # https://docs.astral.sh/ruff/rules/#tryceratops-try
|
|
47
|
+
"YTT", # https://docs.astral.sh/ruff/rules/#flake8-2020-ytt
|
|
48
|
+
"BLE", # https://docs.astral.sh/ruff/rules/#flake8-blind-except-ble
|
|
49
|
+
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
|
|
50
|
+
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
|
51
|
+
"FA", # https://docs.astral.sh/ruff/rules/#flake8-future-annotations-fa
|
|
52
|
+
"C4", # https://docs.astral.sh/ruff/rules/#flake8-comprehensions-c4
|
|
53
|
+
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
|
|
54
|
+
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
|
|
55
|
+
"INT", # https://docs.astral.sh/ruff/rules/#flake8-gettext-int
|
|
56
|
+
"LOG", # https://docs.astral.sh/ruff/rules/#flake8-logging-log
|
|
57
|
+
"S", # https://docs.astral.sh/ruff/rules/#flake8-bandit-s
|
|
58
|
+
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
|
59
|
+
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
|
60
|
+
"N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
|
|
61
|
+
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
|
62
|
+
"PYI", # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi
|
|
63
|
+
"PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
|
|
64
|
+
"TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
|
|
65
|
+
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
|
66
|
+
"PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
|
|
67
|
+
"D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d
|
|
68
|
+
"PGH", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh
|
|
69
|
+
"PL", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
|
70
|
+
"FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
|
|
71
|
+
"RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret
|
|
72
|
+
"TID252", # https://docs.astral.sh/ruff/rules/relative-imports/
|
|
73
|
+
]
|
|
74
|
+
ignore = ["D203", "D213"]
|
|
75
|
+
|
|
76
|
+
[tool.ruff.lint.per-file-ignores]
|
|
77
|
+
"tests/**/*.py" = ["ANN", "D", "S101", "S108", "S603", "S607"]
|
|
78
|
+
"docs/conf.py" = ["A", "D100"]
|
|
79
|
+
|
|
80
|
+
[dependency-groups]
|
|
81
|
+
dev = [
|
|
82
|
+
"pytest>=9.1.1",
|
|
83
|
+
]
|
|
84
|
+
docs = [
|
|
85
|
+
"myst-parser>=5.1.0",
|
|
86
|
+
"richterm[sphinx]>=0.2.0",
|
|
87
|
+
"sphinx>=9.1.0",
|
|
88
|
+
"sphinx-book-theme>=1.4.0",
|
|
89
|
+
"sphinxcontrib-mermaid>=2.1.0",
|
|
90
|
+
]
|
|
91
|
+
lint = [
|
|
92
|
+
"ruff>=0.16.0",
|
|
93
|
+
]
|
|
94
|
+
qa = [
|
|
95
|
+
{ include-group = "lint" },
|
|
96
|
+
"ty>=0.0.63",
|
|
97
|
+
]
|
|
@@ -1,6 +1,9 @@
|
|
|
1
|
+
"""Command-line wrapper for copying and updating projects."""
|
|
2
|
+
|
|
1
3
|
import argparse
|
|
2
4
|
import json
|
|
3
5
|
import os
|
|
6
|
+
import shutil
|
|
4
7
|
import subprocess
|
|
5
8
|
from dataclasses import dataclass
|
|
6
9
|
from importlib.metadata import PackageNotFoundError, distribution, version
|
|
@@ -17,11 +20,14 @@ ANSWER_FILES: tuple[str, ...] = (".copier-answers.yml", ".copier-answers.yaml")
|
|
|
17
20
|
|
|
18
21
|
@dataclass(frozen=True)
|
|
19
22
|
class TemplateTarget:
|
|
23
|
+
"""Template source and optional version control reference."""
|
|
24
|
+
|
|
20
25
|
src_path: str
|
|
21
26
|
vcs_ref: str | None = None
|
|
22
27
|
|
|
23
28
|
|
|
24
29
|
def get_version() -> str:
|
|
30
|
+
"""Return the installed package version."""
|
|
25
31
|
try:
|
|
26
32
|
return version("python-package-copier-template")
|
|
27
33
|
except PackageNotFoundError:
|
|
@@ -29,13 +35,18 @@ def get_version() -> str:
|
|
|
29
35
|
|
|
30
36
|
|
|
31
37
|
def has_answers(dst: Path) -> bool:
|
|
38
|
+
"""Return whether a destination contains Copier answers."""
|
|
32
39
|
return any((dst / filename).exists() for filename in ANSWER_FILES)
|
|
33
40
|
|
|
34
41
|
|
|
35
42
|
def get_local_git_head(path: str) -> str | None:
|
|
43
|
+
"""Return the current commit for a local Git repository."""
|
|
44
|
+
if not (git := shutil.which("git")):
|
|
45
|
+
return None
|
|
46
|
+
|
|
36
47
|
try:
|
|
37
|
-
result = subprocess.run(
|
|
38
|
-
[
|
|
48
|
+
result = subprocess.run( # noqa: S603 - arguments are passed without a shell
|
|
49
|
+
[git, "-C", path, "rev-parse", "HEAD"],
|
|
39
50
|
check=True,
|
|
40
51
|
capture_output=True,
|
|
41
52
|
text=True,
|
|
@@ -47,6 +58,7 @@ def get_local_git_head(path: str) -> str | None:
|
|
|
47
58
|
|
|
48
59
|
|
|
49
60
|
def resolve_template_target() -> TemplateTarget:
|
|
61
|
+
"""Resolve the template source matching the installed package."""
|
|
50
62
|
try:
|
|
51
63
|
dist = distribution("python-package-copier-template")
|
|
52
64
|
except PackageNotFoundError:
|
|
@@ -72,6 +84,7 @@ def resolve_template_target() -> TemplateTarget:
|
|
|
72
84
|
|
|
73
85
|
|
|
74
86
|
def build_parser() -> argparse.ArgumentParser:
|
|
87
|
+
"""Build the package command-line parser."""
|
|
75
88
|
parser = argparse.ArgumentParser(
|
|
76
89
|
prog="python-package-copier-template",
|
|
77
90
|
description=(
|
|
@@ -89,6 +102,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
89
102
|
|
|
90
103
|
|
|
91
104
|
def main(argv: list[str] | None = None) -> int:
|
|
105
|
+
"""Copy or update a project based on its Copier answers file."""
|
|
92
106
|
parser = build_parser()
|
|
93
107
|
args = parser.parse_args(argv)
|
|
94
108
|
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""Jinja extensions and filters used while rendering the template."""
|
|
2
|
+
|
|
3
|
+
import platform
|
|
4
|
+
import re
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import unicodedata
|
|
9
|
+
import urllib.error
|
|
10
|
+
import urllib.request
|
|
11
|
+
from collections.abc import Iterator
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from contextvars import ContextVar
|
|
14
|
+
from datetime import UTC, datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import cast
|
|
17
|
+
|
|
18
|
+
from jinja2 import Environment
|
|
19
|
+
from jinja2.ext import Extension
|
|
20
|
+
|
|
21
|
+
_UPDATE_MODE: ContextVar[bool] = ContextVar("copier_template_update_mode", default=False)
|
|
22
|
+
MAX_PYPI_SUFFIX = 50
|
|
23
|
+
MIN_PROJECT_PYTHON = (3, 12)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class UnsupportedPythonError(RuntimeError):
|
|
27
|
+
"""Raised when Copier runs below the generated project's Python minimum."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, minimum: str, running: str) -> None:
|
|
30
|
+
"""Describe the required and running Python versions."""
|
|
31
|
+
super().__init__(
|
|
32
|
+
f"Python {minimum} or newer is required to generate a project; Copier is running on Python {running}."
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def update_mode() -> Iterator[None]:
|
|
38
|
+
"""Mark the current execution context as an update operation."""
|
|
39
|
+
token = _UPDATE_MODE.set(True)
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
_UPDATE_MODE.reset(token)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def git_user_name(default: str) -> str:
|
|
47
|
+
"""Return the configured Git author name or a fallback."""
|
|
48
|
+
return _git_config_value("user.name", default)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def git_user_email(default: str) -> str:
|
|
52
|
+
"""Return the configured Git author email or a fallback."""
|
|
53
|
+
return _git_config_value("user.email", default)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _git_config_value(key: str, default: str) -> str:
|
|
57
|
+
if not (git := shutil.which("git")):
|
|
58
|
+
return default
|
|
59
|
+
|
|
60
|
+
try:
|
|
61
|
+
completed = subprocess.run( # noqa: S603 - arguments are passed without a shell
|
|
62
|
+
[git, "config", key],
|
|
63
|
+
check=True,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
)
|
|
67
|
+
except subprocess.CalledProcessError:
|
|
68
|
+
return default
|
|
69
|
+
|
|
70
|
+
return completed.stdout.strip() or default
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def gh_user_login(default: str) -> str:
|
|
74
|
+
"""Return the authenticated GitHub username via the GH CLI when available."""
|
|
75
|
+
if not (gh := shutil.which("gh")):
|
|
76
|
+
return default
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
completed = subprocess.run( # noqa: S603 - arguments are passed without a shell
|
|
80
|
+
[gh, "api", "user", "-q", ".login"],
|
|
81
|
+
check=True,
|
|
82
|
+
capture_output=True,
|
|
83
|
+
text=True,
|
|
84
|
+
)
|
|
85
|
+
login = completed.stdout.strip()
|
|
86
|
+
if login:
|
|
87
|
+
return login
|
|
88
|
+
except subprocess.CalledProcessError:
|
|
89
|
+
return default
|
|
90
|
+
|
|
91
|
+
return default
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def command_available(command: str) -> bool:
|
|
95
|
+
"""Return True if the command exists on PATH."""
|
|
96
|
+
return shutil.which(command) is not None
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def slugify(value: object, separator: str = "-") -> str:
|
|
100
|
+
"""Normalize a value for use in package and repository names."""
|
|
101
|
+
value = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
|
|
102
|
+
value = re.sub(r"[^\w\s-]", "", value.lower())
|
|
103
|
+
return re.sub(r"[-_\s]+", separator, value).strip("-_")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def path_exists(path: str) -> bool:
|
|
107
|
+
"""Return True when ``path`` exists relative to the destination."""
|
|
108
|
+
return Path(path).expanduser().exists()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def is_update(defaults: bool | None = None) -> bool: # noqa: FBT001 - Jinja filters receive positional values
|
|
112
|
+
"""Return True when running under `copier update`."""
|
|
113
|
+
return bool(defaults)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pypi_distribution_exists(name: str) -> bool:
|
|
117
|
+
"""Return True if a distribution with ``name`` is present on PyPI.
|
|
118
|
+
|
|
119
|
+
Uses the lightweight JSON endpoint and handles network failures gracefully
|
|
120
|
+
by treating them as "not found" so template execution is not blocked.
|
|
121
|
+
"""
|
|
122
|
+
# During updates we keep the previously selected distribution name and
|
|
123
|
+
# should not block on global PyPI availability checks.
|
|
124
|
+
if _UPDATE_MODE.get():
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
if not name:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
url = f"https://pypi.org/pypi/{name}/json"
|
|
131
|
+
request = urllib.request.Request(url, method="HEAD")
|
|
132
|
+
try:
|
|
133
|
+
with urllib.request.urlopen(request, timeout=3): # noqa: S310 - request URL is restricted above
|
|
134
|
+
return True
|
|
135
|
+
except (urllib.error.HTTPError, OSError):
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def suggest_pypi_distribution_name(name: str) -> str:
|
|
140
|
+
"""Return a PyPI-safe distribution name, adding a suffix if needed."""
|
|
141
|
+
base = slugify(name)
|
|
142
|
+
if not base:
|
|
143
|
+
base = "package"
|
|
144
|
+
|
|
145
|
+
candidate = base
|
|
146
|
+
for suffix in range(1, MAX_PYPI_SUFFIX + 1):
|
|
147
|
+
if not pypi_distribution_exists(candidate):
|
|
148
|
+
return candidate
|
|
149
|
+
candidate = f"{base}-{suffix}"
|
|
150
|
+
return candidate
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
class GitExtension(Extension):
|
|
154
|
+
"""Register Git, GitHub, command, and path filters."""
|
|
155
|
+
|
|
156
|
+
def __init__(self, environment: Environment) -> None:
|
|
157
|
+
"""Register filters in a Jinja environment."""
|
|
158
|
+
super().__init__(environment)
|
|
159
|
+
environment.filters["git_user_name"] = git_user_name
|
|
160
|
+
environment.filters["git_user_email"] = git_user_email
|
|
161
|
+
environment.filters["gh_user_login"] = gh_user_login
|
|
162
|
+
environment.filters["command_available"] = command_available
|
|
163
|
+
environment.filters["path_exists"] = path_exists
|
|
164
|
+
environment.filters["is_update"] = is_update
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class SlugifyExtension(Extension):
|
|
168
|
+
"""Register slug and PyPI name filters."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, environment: Environment) -> None:
|
|
171
|
+
"""Register filters in a Jinja environment."""
|
|
172
|
+
super().__init__(environment)
|
|
173
|
+
environment.filters["slugify"] = slugify
|
|
174
|
+
environment.filters["pypi_exists"] = pypi_distribution_exists
|
|
175
|
+
environment.filters["pypi_suggest_name"] = suggest_pypi_distribution_name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class CurrentYearExtension(Extension):
|
|
179
|
+
"""Expose the current UTC year to templates."""
|
|
180
|
+
|
|
181
|
+
def __init__(self, environment: Environment) -> None:
|
|
182
|
+
"""Register the current year in a Jinja environment."""
|
|
183
|
+
super().__init__(environment)
|
|
184
|
+
cast("dict[str, object]", environment.globals)["current_year"] = datetime.now(tz=UTC).year
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class PythonVersionExtension(Extension):
|
|
188
|
+
"""Expose the running Python version to templates."""
|
|
189
|
+
|
|
190
|
+
def __init__(self, environment: Environment) -> None:
|
|
191
|
+
"""Register the running Python version in a Jinja environment."""
|
|
192
|
+
super().__init__(environment)
|
|
193
|
+
running_python = (sys.version_info.major, sys.version_info.minor)
|
|
194
|
+
if running_python < MIN_PROJECT_PYTHON:
|
|
195
|
+
minimum = ".".join(str(part) for part in MIN_PROJECT_PYTHON)
|
|
196
|
+
raise UnsupportedPythonError(minimum, platform.python_version())
|
|
197
|
+
cast("dict[str, object]", environment.globals)["python_version"] = platform.python_version()
|
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
[project]
|
|
2
|
-
name = "python-package-copier-template"
|
|
3
|
-
version = "0.4.3"
|
|
4
|
-
description = "A Copier template for modern Python projects."
|
|
5
|
-
readme = "README.md"
|
|
6
|
-
authors = [{ name = "Martín Gaitán", email = "gaitan@gmail.com" }]
|
|
7
|
-
requires-python = ">=3.12"
|
|
8
|
-
dependencies = [
|
|
9
|
-
"copier>=9.11.0",
|
|
10
|
-
"copier-templates-extensions>=0.3.2",
|
|
11
|
-
"ruff>=0.14.9",
|
|
12
|
-
]
|
|
13
|
-
|
|
14
|
-
[project.scripts]
|
|
15
|
-
python-package-copier-template = "python_package_copier_template.cli:main"
|
|
16
|
-
|
|
17
|
-
[build-system]
|
|
18
|
-
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
19
|
-
build-backend = "uv_build"
|
|
20
|
-
|
|
21
|
-
[tool.uv.build-backend]
|
|
22
|
-
module-name = "python_package_copier_template"
|
|
23
|
-
module-root = ""
|
|
24
|
-
|
|
25
|
-
[tool.ty.src]
|
|
26
|
-
exclude = [".worktrees/", ".external-worktrees/"]
|
|
27
|
-
|
|
28
|
-
[tool.ruff]
|
|
29
|
-
line-length = 120
|
|
30
|
-
|
|
31
|
-
[dependency-groups]
|
|
32
|
-
dev = [
|
|
33
|
-
"pytest>=9.0.2",
|
|
34
|
-
]
|
|
35
|
-
docs = [
|
|
36
|
-
"myst-parser>=4.0.1",
|
|
37
|
-
"richterm[sphinx]>=0.2.0",
|
|
38
|
-
"sphinx>=8.2.3",
|
|
39
|
-
"sphinx-book-theme>=1.1.4",
|
|
40
|
-
"sphinxcontrib-mermaid>=1.0.0",
|
|
41
|
-
]
|
|
42
|
-
qa = [
|
|
43
|
-
"ty>=0.0.27",
|
|
44
|
-
]
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
import re
|
|
2
|
-
import shutil
|
|
3
|
-
import subprocess
|
|
4
|
-
import unicodedata
|
|
5
|
-
import urllib.error
|
|
6
|
-
import urllib.request
|
|
7
|
-
from contextlib import contextmanager
|
|
8
|
-
from contextvars import ContextVar
|
|
9
|
-
from datetime import date
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
from jinja2.ext import Extension
|
|
13
|
-
|
|
14
|
-
_UPDATE_MODE: ContextVar[bool] = ContextVar("copier_template_update_mode", default=False)
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
@contextmanager
|
|
18
|
-
def update_mode():
|
|
19
|
-
"""Mark the current execution context as an update operation."""
|
|
20
|
-
|
|
21
|
-
token = _UPDATE_MODE.set(True)
|
|
22
|
-
try:
|
|
23
|
-
yield
|
|
24
|
-
finally:
|
|
25
|
-
_UPDATE_MODE.reset(token)
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def git_user_name(default: str) -> str:
|
|
29
|
-
return subprocess.getoutput("git config user.name").strip() or default
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def git_user_email(default: str) -> str:
|
|
33
|
-
return subprocess.getoutput("git config user.email").strip() or default
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def gh_user_login(default: str) -> str:
|
|
37
|
-
"""Return the authenticated GitHub username via the GH CLI when available."""
|
|
38
|
-
|
|
39
|
-
try:
|
|
40
|
-
completed = subprocess.run(
|
|
41
|
-
["gh", "api", "user", "-q", ".login"],
|
|
42
|
-
check=True,
|
|
43
|
-
capture_output=True,
|
|
44
|
-
text=True,
|
|
45
|
-
)
|
|
46
|
-
login = completed.stdout.strip()
|
|
47
|
-
if login:
|
|
48
|
-
return login
|
|
49
|
-
except (FileNotFoundError, subprocess.CalledProcessError):
|
|
50
|
-
return default
|
|
51
|
-
|
|
52
|
-
return default
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
def command_available(command: str) -> bool:
|
|
56
|
-
"""Return True if the command exists on PATH."""
|
|
57
|
-
|
|
58
|
-
return shutil.which(command) is not None
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
def slugify(value, separator="-"):
|
|
62
|
-
value = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
|
|
63
|
-
value = re.sub(r"[^\w\s-]", "", value.lower())
|
|
64
|
-
return re.sub(r"[-_\s]+", separator, value).strip("-_")
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
def path_exists(path: str) -> bool:
|
|
68
|
-
"""Return True when ``path`` exists relative to the destination."""
|
|
69
|
-
|
|
70
|
-
return Path(path).expanduser().exists()
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
def is_update(defaults: bool | None = None) -> bool:
|
|
74
|
-
"""Return True when running under `copier update`."""
|
|
75
|
-
|
|
76
|
-
return bool(defaults)
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def pypi_distribution_exists(name: str) -> bool:
|
|
80
|
-
"""Return True if a distribution with ``name`` is present on PyPI.
|
|
81
|
-
|
|
82
|
-
Uses the lightweight JSON endpoint and handles network failures gracefully
|
|
83
|
-
by treating them as "not found" so template execution is not blocked.
|
|
84
|
-
"""
|
|
85
|
-
|
|
86
|
-
# During updates we keep the previously selected distribution name and
|
|
87
|
-
# should not block on global PyPI availability checks.
|
|
88
|
-
if _UPDATE_MODE.get():
|
|
89
|
-
return False
|
|
90
|
-
|
|
91
|
-
if not name:
|
|
92
|
-
return False
|
|
93
|
-
|
|
94
|
-
url = f"https://pypi.org/pypi/{name}/json"
|
|
95
|
-
request = urllib.request.Request(url, method="HEAD")
|
|
96
|
-
try:
|
|
97
|
-
with urllib.request.urlopen(request, timeout=3):
|
|
98
|
-
return True
|
|
99
|
-
except (urllib.error.HTTPError, OSError):
|
|
100
|
-
return False
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def suggest_pypi_distribution_name(name: str) -> str:
|
|
104
|
-
"""Return a PyPI-safe distribution name, adding a suffix if needed."""
|
|
105
|
-
|
|
106
|
-
base = slugify(name)
|
|
107
|
-
if not base:
|
|
108
|
-
base = "package"
|
|
109
|
-
|
|
110
|
-
candidate = base
|
|
111
|
-
suffix = 1
|
|
112
|
-
while pypi_distribution_exists(candidate) and suffix < 50:
|
|
113
|
-
candidate = f"{base}-{suffix}"
|
|
114
|
-
suffix += 1
|
|
115
|
-
return candidate
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
class GitExtension(Extension):
|
|
119
|
-
def __init__(self, environment):
|
|
120
|
-
super().__init__(environment)
|
|
121
|
-
environment.filters["git_user_name"] = git_user_name
|
|
122
|
-
environment.filters["git_user_email"] = git_user_email
|
|
123
|
-
environment.filters["gh_user_login"] = gh_user_login
|
|
124
|
-
environment.filters["command_available"] = command_available
|
|
125
|
-
environment.filters["path_exists"] = path_exists
|
|
126
|
-
environment.filters["is_update"] = is_update
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
class SlugifyExtension(Extension):
|
|
130
|
-
def __init__(self, environment):
|
|
131
|
-
super().__init__(environment)
|
|
132
|
-
environment.filters["slugify"] = slugify
|
|
133
|
-
environment.filters["pypi_exists"] = pypi_distribution_exists
|
|
134
|
-
environment.filters["pypi_suggest_name"] = suggest_pypi_distribution_name
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
class CurrentYearExtension(Extension):
|
|
138
|
-
def __init__(self, environment):
|
|
139
|
-
super().__init__(environment)
|
|
140
|
-
environment.globals["current_year"] = date.today().year
|