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.
@@ -1,12 +1,11 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: python-package-copier-template
3
- Version: 0.4.3
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.11.0
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
  [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-purple.json)](https://github.com/copier-org/copier)
16
15
  [![CI](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml/badge.svg)](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml)
16
+ [![PyPI](https://img.shields.io/pypi/v/python-package-copier-template)](https://pypi.org/project/python-package-copier-template/)
17
17
  [![Changelog](https://img.shields.io/github/v/release/mgaitan/python-package-copier-template)](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
- Demo repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
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 `LICENSE`, `CODE_OF_CONDUCT`, etc.
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
- 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.
48
-
49
- ## Package Documentation
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
- The package now includes a Diataxis-based docs set under `docs/` that expands this README:
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 will fetch the latest template version and guide you through updating your project, preserving your customizations whenever possible.
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 attempt to enable GitHub Pages (using the Actions build type) so documentation deployments succeed. If Pages is unavailable (for example, with some private repositories or account policies), the docs workflow will keep failing until Pages is allowed.
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
  [![Copier](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/copier-org/copier/master/img/badge/badge-grayscale-inverted-border-purple.json)](https://github.com/copier-org/copier)
4
4
  [![CI](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml/badge.svg)](https://github.com/mgaitan/python-package-copier-template/actions/workflows/ci.yml)
5
+ [![PyPI](https://img.shields.io/pypi/v/python-package-copier-template)](https://pypi.org/project/python-package-copier-template/)
5
6
  [![Changelog](https://img.shields.io/github/v/release/mgaitan/python-package-copier-template)](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
- Demo repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
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 `LICENSE`, `CODE_OF_CONDUCT`, etc.
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
- 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.
36
-
37
- ## Package Documentation
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
- The package now includes a Diataxis-based docs set under `docs/` that expands this README:
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 will fetch the latest template version and guide you through updating your project, preserving your customizations whenever possible.
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 attempt to enable GitHub Pages (using the Actions build type) so documentation deployments succeed. If Pages is unavailable (for example, with some private repositories or account policies), the docs workflow will keep failing until Pages is allowed.
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,3 +1,5 @@
1
+ """Public package interface."""
2
+
1
3
  from .cli import main
2
4
 
3
5
  __all__ = ["main"]
@@ -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
- ["git", "-C", path, "rev-parse", "HEAD"],
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