Skip to content

Commit e38afe7

Browse files
committed
Add config.min_version to cookieplone-config.json for version gating
Closes #180
1 parent 8630051 commit e38afe7

8 files changed

Lines changed: 191 additions & 17 deletions

File tree

cookieplone/cli/__init__.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from cookieplone._types import GenerateConfig
1010
from cookieplone.exceptions import GeneratorException
1111
from cookieplone.exceptions import PreFlightException
12+
from cookieplone.exceptions import VersionTooOldException
1213
from cookieplone.generator import generate
1314
from cookieplone.logger import configure_logger
1415
from cookieplone.logger import logger
@@ -124,7 +125,6 @@ def prompt_for_template(base_path: Path, all_: bool = False) -> t.CookieploneTem
124125
groups = get_template_groups(base_path, all_)
125126
if groups:
126127
group = prompt_for_group(groups)
127-
console.clear_screen()
128128
templates = group.templates
129129
else:
130130
templates = get_template_options(base_path, all_)
@@ -303,10 +303,7 @@ def cli(
303303
)
304304
try:
305305
generate(gen_config)
306-
except GeneratorException as exc:
307-
console.error(exc.message)
308-
raise typer.Exit(1) # noQA:B904
309-
except PreFlightException as exc:
306+
except (GeneratorException, PreFlightException, VersionTooOldException) as exc:
310307
console.error(exc.message)
311308
raise typer.Exit(1) # noQA:B904
312309
except Exception as exc:

cookieplone/config/schemas/repository_config.schema.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,10 @@
5050
"renderer": {
5151
"type": "string",
5252
"minLength": 1
53+
},
54+
"min_version": {
55+
"type": "string",
56+
"minLength": 1
5357
}
5458
},
5559
"additionalProperties": false

cookieplone/exceptions.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,3 +88,8 @@ class FailedHookException(exc.FailedHookException):
8888

8989
class InvalidConfiguration(exc.InvalidConfiguration):
9090
"""Raised when a configuration is invalid."""
91+
92+
93+
class VersionTooOldException(CookieploneException):
94+
"""Raised when the installed cookieplone version is older than the
95+
repository's ``config.min_version`` requirement."""

cookieplone/repository.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@
99
from cookieplone.exceptions import PreFlightException
1010
from cookieplone.exceptions import RepositoryException
1111
from cookieplone.exceptions import RepositoryNotFound
12+
from cookieplone.exceptions import VersionTooOldException
13+
from packaging.version import Version
1214
from pathlib import Path
1315
from typing import Any
1416

@@ -359,6 +361,28 @@ def _run_pre_hook(
359361
return repo_dir
360362

361363

364+
def _check_min_version(config_section: dict[str, Any]) -> None:
365+
"""Raise if the installed cookieplone is older than ``config.min_version``.
366+
367+
:param config_section: The ``config`` dict from ``cookieplone-config.json``.
368+
:raises VersionTooOldException: When the installed version is too old.
369+
"""
370+
min_version_str = config_section.get("min_version", "")
371+
if not min_version_str:
372+
return
373+
from cookieplone import __version__
374+
375+
installed = Version(__version__)
376+
required = Version(min_version_str)
377+
if installed < required:
378+
msg = (
379+
f"This template requires cookieplone >= {required}, "
380+
f"but you have {installed} installed.\n"
381+
f"Please upgrade: uvx --no-cache cookieplone@{required}"
382+
)
383+
raise VersionTooOldException(msg)
384+
385+
362386
def get_repository(
363387
repository: str | Path,
364388
template_name: str,
@@ -434,6 +458,7 @@ def get_repository(
434458
repo_config_section = repo_config.get("config", {})
435459
global_versions = repo_config_section.get("versions", {})
436460
renderer = repo_config_section.get("renderer", "")
461+
_check_min_version(repo_config_section)
437462
except RuntimeError:
438463
pass
439464

cookieplone/utils/console.py

Lines changed: 45 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from .internal import version_info
66
from collections.abc import Sequence
77
from contextlib import contextmanager
8+
from cookieplone import __version__ as cookieplone_version
89
from cookieplone import _types as t
910
from cookieplone.settings import QUIET_MODE_VAR
1011
from pathlib import Path
@@ -17,6 +18,7 @@
1718
from rich.table import Table
1819
from rich.text import Text
1920
from textwrap import dedent
21+
from typing import Any
2022

2123
import os
2224

@@ -95,39 +97,44 @@ def print(msg: str, style: str = "", color: str = ""): # noQA:A001
9597
_print(f"{tag_open}{escape(msg)}{tag_close}")
9698

9799

98-
def print_plone_banner():
100+
def print_plone_banner() -> None:
99101
"""Print Plone banner."""
100102
style: str = "bold"
101103
color: str = "blue"
102104
banner = choose_banner()
103105
print(banner, style, color)
104106

105107

106-
def info(msg: str):
108+
def info(msg: str) -> None:
109+
"""Print an informational message in bold white."""
107110
style: str = "bold"
108111
color: str = "white"
109112
print(msg, style, color)
110113

111114

112-
def success(msg: str):
115+
def success(msg: str) -> None:
116+
"""Print a success message in bold green."""
113117
style: str = "bold"
114118
color: str = "green"
115119
print(msg, style, color)
116120

117121

118-
def error(msg: str):
122+
def error(msg: str) -> None:
123+
"""Print an error message in bold red."""
119124
style: str = "bold"
120125
color: str = "red"
121126
print(msg, style, color)
122127

123128

124-
def warning(msg: str):
129+
def warning(msg: str) -> None:
130+
"""Print a warning message in bold yellow."""
125131
style: str = "bold"
126132
color: str = "yellow"
127133
print(msg, style, color)
128134

129135

130-
def panel(title: str, msg: str = "", subtitle: str = "", url: str = ""):
136+
def panel(title: str, msg: str = "", subtitle: str = "", url: str = "") -> None:
137+
"""Print a Rich panel with an optional subtitle and clickable URL."""
131138
msg = dedent(msg)
132139
if url:
133140
msg = f"{msg}\n[link]{url}[/link]"
@@ -143,9 +150,16 @@ def panel(title: str, msg: str = "", subtitle: str = "", url: str = ""):
143150
def create_table(
144151
columns: list[dict] | None = None,
145152
rows: Sequence[Sequence[str]] | None = None,
146-
**kwargs,
153+
**kwargs: Any,
147154
) -> Table:
148-
"""Create table."""
155+
"""Create a Rich :class:`~rich.table.Table` from column definitions and row data.
156+
157+
:param columns: List of dicts, each containing a ``title`` key and any
158+
extra keyword arguments accepted by :meth:`~rich.table.Table.add_column`.
159+
:param rows: Sequence of row tuples passed to :meth:`~rich.table.Table.add_row`.
160+
:param kwargs: Forwarded to the :class:`~rich.table.Table` constructor.
161+
:returns: A fully populated :class:`~rich.table.Table`.
162+
"""
149163
table = Table(**kwargs)
150164
for column in columns:
151165
col_title = column.pop("title", "")
@@ -188,7 +202,19 @@ def list_available_groups(
188202
def welcome_screen(
189203
templates: dict[str, t.CookieploneTemplate] | None = None,
190204
groups: dict[str, t.CookieploneTemplateGroup] | None = None,
191-
):
205+
) -> None:
206+
"""Display the Cookieplone welcome screen with an optional template or group list.
207+
208+
Clears the terminal, renders the Plone banner, and — when provided —
209+
appends a panel listing either template *groups* (category selection)
210+
or individual *templates* (template selection).
211+
212+
:param templates: Templates to list. Mutually exclusive with *groups*.
213+
:param groups: Template groups to list. Takes precedence over *templates*.
214+
"""
215+
# Always clear the screen, even if we're not printing the banner,
216+
# to ensure a clean start.
217+
clear_screen()
192218
banner = choose_banner()
193219
items = [
194220
Align.center(f"[bold blue]{banner}[/bold blue]"),
@@ -205,19 +231,26 @@ def welcome_screen(
205231
title_align="left",
206232
)
207233
)
234+
panel_title = f"cookieplone ({cookieplone_version})"
208235
panel = Panel(
209236
Group(*items),
210-
title="cookieplone",
237+
title=panel_title,
211238
)
212239
base_print(panel)
213240

214241

215-
def version_screen():
242+
def version_screen() -> None:
216243
"""Print version information."""
217244
base_print(version_info())
218245

219246

220-
def info_screen(repository: str | Path, passwd: str, tag: str):
247+
def info_screen(repository: str | Path, passwd: str, tag: str) -> None:
248+
"""Print a detailed information panel about the current Cookieplone installation.
249+
250+
:param repository: Template repository URL or local path.
251+
:param passwd: Repository password (may be empty).
252+
:param tag: Git tag or branch used for the repository.
253+
"""
221254
info = cookieplone_info(repository, passwd, tag)
222255
title = info["title"]
223256
subtitle = info["subtitle"]

docs/src/reference/repository-config.md

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ Global configuration shared across all templates in the repository.
130130
```json
131131
{
132132
"config": {
133+
"min_version": "2.0.0a2",
133134
"versions": {
134135
"gha_version_checkout": "v6",
135136
"gha_version_setup_node": "v4",
@@ -174,6 +175,29 @@ If the configured renderer name is not registered with `tui_forms`, Cookieplone
174175

175176
See {doc}`/reference/environment-variables` for the corresponding environment variable.
176177

178+
### `config.min_version`
179+
180+
Declares the minimum Cookieplone version required by the templates in this repository.
181+
The value must be a valid [PEP 440](https://peps.python.org/pep-0440/) version string, including pre-release versions such as `2.0.0a1`.
182+
183+
```json
184+
{
185+
"config": {
186+
"min_version": "2.0.0a2"
187+
}
188+
}
189+
```
190+
191+
When present, Cookieplone compares the installed version against this value before any generation begins.
192+
If the installed version is older, generation stops with an actionable error message:
193+
194+
```text
195+
This template requires cookieplone >= 2.0.0a2, but you have 1.3.0 installed.
196+
Please upgrade: uvx --no-cache cookieplone@2.0.0a2
197+
```
198+
199+
When the key is absent or empty, no version check is performed (backwards compatible with existing repositories).
200+
177201
(repo-extends)=
178202
## `extends`
179203

@@ -261,6 +285,7 @@ Cookieplone checks for `cookieplone-config.json` first, then falls back to `cook
261285
}
262286
},
263287
"config": {
288+
"min_version": "2.0.0a2",
264289
"versions": {
265290
"gha_version_checkout": "v6",
266291
"frontend_pnpm": "10.20.0"

news/180.feature

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Added `config.min_version` to `cookieplone-config.json` so template repositories can declare the minimum Cookieplone version they require. @ericof

tests/test_repository.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from cookiecutter.exceptions import FailedHookException
22
from cookieplone import repository
33
from cookieplone.exceptions import PreFlightException
4+
from cookieplone.exceptions import VersionTooOldException
5+
from cookieplone.repository import _check_min_version
46
from pathlib import Path
57

68
import json
@@ -127,3 +129,85 @@ def test_no_repo_config_means_empty_renderer(self, tmp_path, monkeypatch):
127129
template_path="",
128130
)
129131
assert info.renderer == ""
132+
133+
134+
class TestCheckMinVersion:
135+
"""Tests for _check_min_version."""
136+
137+
def test_no_min_version_key(self):
138+
"""No-op when min_version is absent."""
139+
_check_min_version({})
140+
141+
def test_empty_min_version(self):
142+
"""No-op when min_version is an empty string."""
143+
_check_min_version({"min_version": ""})
144+
145+
def test_version_satisfied(self, monkeypatch):
146+
"""No error when installed version meets the requirement."""
147+
monkeypatch.setattr("cookieplone.__version__", "2.1.0")
148+
_check_min_version({"min_version": "2.0.0"})
149+
150+
def test_version_exact_match(self, monkeypatch):
151+
"""No error when installed version equals min_version."""
152+
monkeypatch.setattr("cookieplone.__version__", "2.0.0")
153+
_check_min_version({"min_version": "2.0.0"})
154+
155+
def test_version_too_old(self, monkeypatch):
156+
"""Raises VersionTooOldException when installed version is older."""
157+
monkeypatch.setattr("cookieplone.__version__", "1.3.0")
158+
with pytest.raises(VersionTooOldException, match=r"cookieplone >= 2\.0\.0"):
159+
_check_min_version({"min_version": "2.0.0"})
160+
161+
def test_error_message_includes_versions(self, monkeypatch):
162+
"""Error message includes both the required and installed versions."""
163+
monkeypatch.setattr("cookieplone.__version__", "1.5.0")
164+
with pytest.raises(VersionTooOldException, match=r"1\.5\.0") as exc_info:
165+
_check_min_version({"min_version": "2.0.0"})
166+
assert "uvx --no-cache cookieplone@2.0.0" in exc_info.value.message
167+
168+
def test_prerelease_satisfied(self, monkeypatch):
169+
"""Pre-release installed version satisfies a pre-release requirement."""
170+
monkeypatch.setattr("cookieplone.__version__", "2.0.0a2")
171+
_check_min_version({"min_version": "2.0.0a1"})
172+
173+
def test_prerelease_too_old(self, monkeypatch):
174+
"""Pre-release installed version fails against a newer pre-release."""
175+
monkeypatch.setattr("cookieplone.__version__", "2.0.0a1")
176+
with pytest.raises(VersionTooOldException):
177+
_check_min_version({"min_version": "2.0.0a2"})
178+
179+
def test_dev_version_satisfies_prerelease(self, monkeypatch):
180+
"""Dev version (e.g. 2.0.0a2.dev0) satisfies an older pre-release."""
181+
monkeypatch.setattr("cookieplone.__version__", "2.0.0a2.dev0")
182+
_check_min_version({"min_version": "2.0.0a1"})
183+
184+
def test_get_repository_raises_on_min_version(self, tmp_path, monkeypatch):
185+
"""get_repository raises VersionTooOldException when min_version fails."""
186+
repo_dir = tmp_path / "repo"
187+
repo_dir.mkdir()
188+
_write_repo_config(repo_dir, {"min_version": "99.0.0"})
189+
monkeypatch.setattr(
190+
repository,
191+
"get_user_config",
192+
lambda **_: {
193+
"abbreviations": {},
194+
"cookiecutters_dir": str(repo_dir.parent),
195+
"replay_dir": str(repo_dir.parent),
196+
},
197+
)
198+
monkeypatch.setattr(
199+
repository,
200+
"determine_repo_dir",
201+
lambda **_: (repo_dir, False),
202+
)
203+
monkeypatch.setattr(
204+
repository,
205+
"_run_pre_hook",
206+
lambda base, repo, accept_hooks: repo,
207+
)
208+
with pytest.raises(VersionTooOldException, match=r"cookieplone >= 99\.0\.0"):
209+
repository.get_repository(
210+
repository=str(repo_dir),
211+
template_name="project",
212+
template_path="",
213+
)

0 commit comments

Comments
 (0)