python-package-copier-template 0.5.0__tar.gz → 0.7.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.5.0
3
+ Version: 0.7.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
 
@@ -20,15 +19,16 @@ Description-Content-Type: text/markdown
20
19
  A [Copier](https://github.com/copier-org/copier) template
21
20
  for modern Python projects.
22
21
 
23
- Canonical example repo generated from this template: [mgaitan/yet-another-repo](https://github.com/mgaitan/yet-another-repo)
22
+ Canonical example repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
24
23
 
25
24
  Documentation: <https://mgaitan.github.io/python-package-copier-template/>
26
25
 
27
26
  ## Features
28
27
 
29
- - 🐍 Modern Python package (3.12+)
28
+ - 🐍 Modern Python package supporting Python 3.12-3.15
30
29
  - 📦 Build and dependency management with [uv](https://docs.astral.sh/uv/), split by groups (dev/qa/docs)
31
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
32
32
  - 🧹 Linting and formatting via [Ruff](https://docs.astral.sh/ruff/) with a broad set of rules enabled
33
33
  - ✅ Type checking via [ty](https://github.com/astral-sh/ty)
34
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)
@@ -85,7 +85,11 @@ To upgrade an existing project created from this template to the latest version,
85
85
  uvx --with=copier-template-extensions copier update . --trust
86
86
  ```
87
87
 
88
- 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.
89
93
 
90
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.
91
95
 
@@ -96,7 +100,11 @@ uv sync
96
100
  uv run copier copy --trust --vcs-ref=HEAD . /path/to/your/test/project
97
101
  ```
98
102
 
99
- 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.
100
108
 
101
109
 
102
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/).
@@ -8,15 +8,16 @@
8
8
  A [Copier](https://github.com/copier-org/copier) template
9
9
  for modern Python projects.
10
10
 
11
- Canonical example repo generated from this template: [mgaitan/yet-another-repo](https://github.com/mgaitan/yet-another-repo)
11
+ Canonical example repo generated from this template: [mgaitan/yet-another-demo](https://github.com/mgaitan/yet-another-demo)
12
12
 
13
13
  Documentation: <https://mgaitan.github.io/python-package-copier-template/>
14
14
 
15
15
  ## Features
16
16
 
17
- - 🐍 Modern Python package (3.12+)
17
+ - 🐍 Modern Python package supporting Python 3.12-3.15
18
18
  - 📦 Build and dependency management with [uv](https://docs.astral.sh/uv/), split by groups (dev/qa/docs)
19
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
20
21
  - 🧹 Linting and formatting via [Ruff](https://docs.astral.sh/ruff/) with a broad set of rules enabled
21
22
  - ✅ Type checking via [ty](https://github.com/astral-sh/ty)
22
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)
@@ -73,7 +74,11 @@ To upgrade an existing project created from this template to the latest version,
73
74
  uvx --with=copier-template-extensions copier update . --trust
74
75
  ```
75
76
 
76
- 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.
77
82
 
78
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.
79
84
 
@@ -84,7 +89,11 @@ uv sync
84
89
  uv run copier copy --trust --vcs-ref=HEAD . /path/to/your/test/project
85
90
  ```
86
91
 
87
- 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.
88
97
 
89
98
 
90
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,113 @@
1
+ [project]
2
+ name = "python-package-copier-template"
3
+ version = "0.7.0"
4
+ description = "A Copier template for modern Python projects."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ dependencies = [
8
+ "copier>=9.17.0",
9
+ "copier-templates-extensions>=0.3.2",
10
+ ]
11
+
12
+ [[project.authors]]
13
+ name = "Martín Gaitán"
14
+ email = "gaitan@gmail.com"
15
+
16
+ [project.scripts]
17
+ python-package-copier-template = "python_package_copier_template.cli:main"
18
+
19
+ [build-system]
20
+ requires = ["uv_build>=0.11.32,<0.12.0"]
21
+ build-backend = "uv_build"
22
+
23
+ [tool.uv.build-backend]
24
+ module-name = "python_package_copier_template"
25
+ module-root = ""
26
+
27
+ [tool.ty.src]
28
+ exclude = [
29
+ ".worktrees/",
30
+ ".external-worktrees/",
31
+ ]
32
+
33
+ [tool.ruff]
34
+ line-length = 120
35
+
36
+ [tool.ruff.lint]
37
+ select = [
38
+ "E",
39
+ "W",
40
+ "F",
41
+ "I",
42
+ "C90",
43
+ "A",
44
+ "ANN",
45
+ "UP",
46
+ "RUF",
47
+ "T10",
48
+ "ISC",
49
+ "SIM",
50
+ "ASYNC",
51
+ "ERA",
52
+ "TRY",
53
+ "YTT",
54
+ "BLE",
55
+ "B",
56
+ "EXE",
57
+ "FA",
58
+ "C4",
59
+ "DTZ",
60
+ "FBT",
61
+ "INT",
62
+ "LOG",
63
+ "S",
64
+ "SLF",
65
+ "G",
66
+ "FLY",
67
+ "N",
68
+ "PIE",
69
+ "PYI",
70
+ "PT",
71
+ "TC",
72
+ "PTH",
73
+ "PERF",
74
+ "D",
75
+ "PGH",
76
+ "PL",
77
+ "FURB",
78
+ "RET",
79
+ "TID252",
80
+ ]
81
+ ignore = [
82
+ "D203",
83
+ "D213",
84
+ ]
85
+
86
+ [tool.ruff.lint.per-file-ignores]
87
+ "tests/**/*.py" = [
88
+ "ANN",
89
+ "D",
90
+ "S101",
91
+ "S108",
92
+ "S603",
93
+ "S607",
94
+ ]
95
+ "docs/conf.py" = [
96
+ "A",
97
+ "D100",
98
+ ]
99
+
100
+ [dependency-groups]
101
+ dev = ["pytest>=9.1.1"]
102
+ docs = [
103
+ "myst-parser>=5.1.0",
104
+ "richterm[sphinx]>=0.2.0",
105
+ "sphinx>=9.1.0",
106
+ "sphinx-book-theme>=1.4.0",
107
+ "sphinxcontrib-mermaid>=2.1.0",
108
+ ]
109
+ lint = ["ruff>=0.16.7"]
110
+ qa = [
111
+ { include-group = "lint" },
112
+ "ty>=0.0.80",
113
+ ]
@@ -0,0 +1,98 @@
1
+ [project]
2
+ name = "python-package-copier-template"
3
+ version = "0.7.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
+ "SLF", # https://docs.astral.sh/ruff/rules/#flake8-self-slf
59
+ "G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
60
+ "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
61
+ "N", # https://docs.astral.sh/ruff/rules/#pep8-naming-n
62
+ "PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
63
+ "PYI", # https://docs.astral.sh/ruff/rules/#flake8-pyi-pyi
64
+ "PT", # https://docs.astral.sh/ruff/rules/#flake8-pytest-style-pt
65
+ "TC", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tc
66
+ "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
67
+ "PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
68
+ "D", # https://docs.astral.sh/ruff/rules/#pydocstyle-d
69
+ "PGH", # https://docs.astral.sh/ruff/rules/#pygrep-hooks-pgh
70
+ "PL", # https://docs.astral.sh/ruff/rules/#pylint-pl
71
+ "FURB", # https://docs.astral.sh/ruff/rules/#refurb-furb
72
+ "RET", # https://docs.astral.sh/ruff/rules/#flake8-return-ret
73
+ "TID252", # https://docs.astral.sh/ruff/rules/relative-imports/
74
+ ]
75
+ ignore = ["D203", "D213"]
76
+
77
+ [tool.ruff.lint.per-file-ignores]
78
+ "tests/**/*.py" = ["ANN", "D", "S101", "S108", "S603", "S607"]
79
+ "docs/conf.py" = ["A", "D100"]
80
+
81
+ [dependency-groups]
82
+ dev = [
83
+ "pytest>=9.1.1",
84
+ ]
85
+ docs = [
86
+ "myst-parser>=5.1.0",
87
+ "richterm[sphinx]>=0.2.0",
88
+ "sphinx>=9.1.0",
89
+ "sphinx-book-theme>=1.4.0",
90
+ "sphinxcontrib-mermaid>=2.1.0",
91
+ ]
92
+ lint = [
93
+ "ruff>=0.16.7",
94
+ ]
95
+ qa = [
96
+ { include-group = "lint" },
97
+ "ty>=0.0.80",
98
+ ]
@@ -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
 
@@ -105,6 +119,7 @@ def main(argv: list[str] | None = None) -> int:
105
119
  unsafe=True,
106
120
  overwrite=True,
107
121
  skip_answered=True,
122
+ vcs_ref="HEAD",
108
123
  )
109
124
  else:
110
125
  template_target = resolve_template_target()
@@ -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.5.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.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