python-package-copier-template 0.4.3__py3-none-any.whl → 0.6.0__py3-none-any.whl

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,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
 
@@ -1,23 +1,41 @@
1
+ """Jinja extensions and filters used while rendering the template."""
2
+
3
+ import platform
1
4
  import re
2
5
  import shutil
3
6
  import subprocess
7
+ import sys
4
8
  import unicodedata
5
9
  import urllib.error
6
10
  import urllib.request
11
+ from collections.abc import Iterator
7
12
  from contextlib import contextmanager
8
13
  from contextvars import ContextVar
9
- from datetime import date
14
+ from datetime import UTC, datetime
10
15
  from pathlib import Path
16
+ from typing import cast
11
17
 
18
+ from jinja2 import Environment
12
19
  from jinja2.ext import Extension
13
20
 
14
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
+ )
15
34
 
16
35
 
17
36
  @contextmanager
18
- def update_mode():
37
+ def update_mode() -> Iterator[None]:
19
38
  """Mark the current execution context as an update operation."""
20
-
21
39
  token = _UPDATE_MODE.set(True)
22
40
  try:
23
41
  yield
@@ -26,19 +44,40 @@ def update_mode():
26
44
 
27
45
 
28
46
  def git_user_name(default: str) -> str:
29
- return subprocess.getoutput("git config user.name").strip() or default
47
+ """Return the configured Git author name or a fallback."""
48
+ return _git_config_value("user.name", default)
30
49
 
31
50
 
32
51
  def git_user_email(default: str) -> str:
33
- return subprocess.getoutput("git config user.email").strip() or default
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
34
71
 
35
72
 
36
73
  def gh_user_login(default: str) -> str:
37
74
  """Return the authenticated GitHub username via the GH CLI when available."""
75
+ if not (gh := shutil.which("gh")):
76
+ return default
38
77
 
39
78
  try:
40
- completed = subprocess.run(
41
- ["gh", "api", "user", "-q", ".login"],
79
+ completed = subprocess.run( # noqa: S603 - arguments are passed without a shell
80
+ [gh, "api", "user", "-q", ".login"],
42
81
  check=True,
43
82
  capture_output=True,
44
83
  text=True,
@@ -46,7 +85,7 @@ def gh_user_login(default: str) -> str:
46
85
  login = completed.stdout.strip()
47
86
  if login:
48
87
  return login
49
- except (FileNotFoundError, subprocess.CalledProcessError):
88
+ except subprocess.CalledProcessError:
50
89
  return default
51
90
 
52
91
  return default
@@ -54,11 +93,11 @@ def gh_user_login(default: str) -> str:
54
93
 
55
94
  def command_available(command: str) -> bool:
56
95
  """Return True if the command exists on PATH."""
57
-
58
96
  return shutil.which(command) is not None
59
97
 
60
98
 
61
- def slugify(value, separator="-"):
99
+ def slugify(value: object, separator: str = "-") -> str:
100
+ """Normalize a value for use in package and repository names."""
62
101
  value = unicodedata.normalize("NFKD", str(value)).encode("ascii", "ignore").decode("ascii")
63
102
  value = re.sub(r"[^\w\s-]", "", value.lower())
64
103
  return re.sub(r"[-_\s]+", separator, value).strip("-_")
@@ -66,13 +105,11 @@ def slugify(value, separator="-"):
66
105
 
67
106
  def path_exists(path: str) -> bool:
68
107
  """Return True when ``path`` exists relative to the destination."""
69
-
70
108
  return Path(path).expanduser().exists()
71
109
 
72
110
 
73
- def is_update(defaults: bool | None = None) -> bool:
111
+ def is_update(defaults: bool | None = None) -> bool: # noqa: FBT001 - Jinja filters receive positional values
74
112
  """Return True when running under `copier update`."""
75
-
76
113
  return bool(defaults)
77
114
 
78
115
 
@@ -82,7 +119,6 @@ def pypi_distribution_exists(name: str) -> bool:
82
119
  Uses the lightweight JSON endpoint and handles network failures gracefully
83
120
  by treating them as "not found" so template execution is not blocked.
84
121
  """
85
-
86
122
  # During updates we keep the previously selected distribution name and
87
123
  # should not block on global PyPI availability checks.
88
124
  if _UPDATE_MODE.get():
@@ -94,7 +130,7 @@ def pypi_distribution_exists(name: str) -> bool:
94
130
  url = f"https://pypi.org/pypi/{name}/json"
95
131
  request = urllib.request.Request(url, method="HEAD")
96
132
  try:
97
- with urllib.request.urlopen(request, timeout=3):
133
+ with urllib.request.urlopen(request, timeout=3): # noqa: S310 - request URL is restricted above
98
134
  return True
99
135
  except (urllib.error.HTTPError, OSError):
100
136
  return False
@@ -102,21 +138,23 @@ def pypi_distribution_exists(name: str) -> bool:
102
138
 
103
139
  def suggest_pypi_distribution_name(name: str) -> str:
104
140
  """Return a PyPI-safe distribution name, adding a suffix if needed."""
105
-
106
141
  base = slugify(name)
107
142
  if not base:
108
143
  base = "package"
109
144
 
110
145
  candidate = base
111
- suffix = 1
112
- while pypi_distribution_exists(candidate) and suffix < 50:
146
+ for suffix in range(1, MAX_PYPI_SUFFIX + 1):
147
+ if not pypi_distribution_exists(candidate):
148
+ return candidate
113
149
  candidate = f"{base}-{suffix}"
114
- suffix += 1
115
150
  return candidate
116
151
 
117
152
 
118
153
  class GitExtension(Extension):
119
- def __init__(self, environment):
154
+ """Register Git, GitHub, command, and path filters."""
155
+
156
+ def __init__(self, environment: Environment) -> None:
157
+ """Register filters in a Jinja environment."""
120
158
  super().__init__(environment)
121
159
  environment.filters["git_user_name"] = git_user_name
122
160
  environment.filters["git_user_email"] = git_user_email
@@ -127,7 +165,10 @@ class GitExtension(Extension):
127
165
 
128
166
 
129
167
  class SlugifyExtension(Extension):
130
- def __init__(self, environment):
168
+ """Register slug and PyPI name filters."""
169
+
170
+ def __init__(self, environment: Environment) -> None:
171
+ """Register filters in a Jinja environment."""
131
172
  super().__init__(environment)
132
173
  environment.filters["slugify"] = slugify
133
174
  environment.filters["pypi_exists"] = pypi_distribution_exists
@@ -135,6 +176,22 @@ class SlugifyExtension(Extension):
135
176
 
136
177
 
137
178
  class CurrentYearExtension(Extension):
138
- def __init__(self, environment):
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."""
139
192
  super().__init__(environment)
140
- environment.globals["current_year"] = date.today().year
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,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/).
@@ -0,0 +1,7 @@
1
+ python_package_copier_template/__init__.py,sha256=OinvQVj2ZByb1RmK5MQiILujuV2mELHY_A_e_FsAN-Q,75
2
+ python_package_copier_template/cli.py,sha256=p9cpaajf4MS_2UnNf3iKRcyvoZoS-a7oumRxh3UNMqY,4144
3
+ python_package_copier_template/extensions.py,sha256=ffCU_Xe7VDCf9fMFmjSCYWyxJq2SVsdkQL6p82emV4I,6696
4
+ python_package_copier_template-0.6.0.dist-info/WHEEL,sha256=l3MmIxu8qaet7ng2J9fFoJnYGj8IREj7jXTbgsuzmy4,81
5
+ python_package_copier_template-0.6.0.dist-info/entry_points.txt,sha256=lCOiBcuDIsrwQCy6P_6xfD47lOp_ES7WcLfh95QxmAo,92
6
+ python_package_copier_template-0.6.0.dist-info/METADATA,sha256=YYJ2t9SuT_O7AoiMRO0Lz0SuZ0-DnBCaae1uML2RwDc,7252
7
+ python_package_copier_template-0.6.0.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.11.3
2
+ Generator: uv 0.11.32
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
@@ -1,7 +0,0 @@
1
- python_package_copier_template/__init__.py,sha256=oKsCpoiQSSXx57DGs2VfYzeO1ukJQxdGnV-2hC3BI0M,42
2
- python_package_copier_template/cli.py,sha256=S5IWoX4G0vUD4kXkG9bATI0-D_hslLrmrhRj1l3DWHc,3523
3
- python_package_copier_template/extensions.py,sha256=Or7j8l44CaiKiWZaA01n0rhOC14YqmW0F6NgmGr7eXw,4098
4
- python_package_copier_template-0.4.3.dist-info/WHEEL,sha256=bEhYrD-rjlF0iRRHiAnfJ0mEjMsRwm29hhDD7yRgWCY,80
5
- python_package_copier_template-0.4.3.dist-info/entry_points.txt,sha256=lCOiBcuDIsrwQCy6P_6xfD47lOp_ES7WcLfh95QxmAo,92
6
- python_package_copier_template-0.4.3.dist-info/METADATA,sha256=3hCr7teo_OMZb7Q347v495i1SIyXfMPmu4VEfdCCfhc,6856
7
- python_package_copier_template-0.4.3.dist-info/RECORD,,