plonecli 7.0.0b1__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.
- plonecli/__init__.py +1 -0
- plonecli/cli.py +354 -0
- plonecli/config.py +127 -0
- plonecli/exceptions.py +28 -0
- plonecli/plone_versions.py +122 -0
- plonecli/project.py +160 -0
- plonecli/registry.py +197 -0
- plonecli/templates.py +165 -0
- plonecli/updater.py +98 -0
- plonecli-7.0.0b1.dist-info/METADATA +475 -0
- plonecli-7.0.0b1.dist-info/RECORD +15 -0
- plonecli-7.0.0b1.dist-info/WHEEL +4 -0
- plonecli-7.0.0b1.dist-info/entry_points.txt +2 -0
- plonecli-7.0.0b1.dist-info/licenses/AUTHORS.md +9 -0
- plonecli-7.0.0b1.dist-info/licenses/LICENSE +31 -0
plonecli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Plone CLI - A command line tool for creating Plone packages."""
|
plonecli/cli.py
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
"""Console script for plonecli."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib.metadata
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
from click_aliases import ClickAliasedGroup
|
|
11
|
+
|
|
12
|
+
from plonecli.config import load_config, save_config
|
|
13
|
+
from plonecli.exceptions import NoSuchValue, NotInPackageError
|
|
14
|
+
from plonecli.project import find_project_root
|
|
15
|
+
from plonecli.registry import TemplateRegistry
|
|
16
|
+
from plonecli.templates import (
|
|
17
|
+
ensure_templates_cloned,
|
|
18
|
+
get_templates_info,
|
|
19
|
+
run_add,
|
|
20
|
+
run_create,
|
|
21
|
+
update_templates_clone,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def echo(msg, fg="green", reverse=False):
|
|
26
|
+
click.echo(click.style(msg, fg=fg, reverse=reverse))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _get_registry():
|
|
30
|
+
"""Create a TemplateRegistry with current context."""
|
|
31
|
+
config = load_config()
|
|
32
|
+
project = find_project_root()
|
|
33
|
+
return TemplateRegistry(config, project)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_templates(ctx, args, incomplete):
|
|
37
|
+
"""Shell completion for template names."""
|
|
38
|
+
reg = _get_registry()
|
|
39
|
+
templates = reg.get_available_templates()
|
|
40
|
+
return [k for k in templates if incomplete in k]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ClickFilteredAliasedGroup(ClickAliasedGroup):
|
|
44
|
+
def list_commands(self, ctx):
|
|
45
|
+
existing_cmds = super().list_commands(ctx)
|
|
46
|
+
project = find_project_root()
|
|
47
|
+
global_cmds = ["completion", "create", "config", "update"]
|
|
48
|
+
global_only_cmds = ["create"]
|
|
49
|
+
if not project:
|
|
50
|
+
cmds = [cmd for cmd in existing_cmds if cmd in global_cmds]
|
|
51
|
+
else:
|
|
52
|
+
cmds = [cmd for cmd in existing_cmds if cmd not in global_only_cmds]
|
|
53
|
+
return cmds
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@click.group(
|
|
57
|
+
cls=ClickFilteredAliasedGroup,
|
|
58
|
+
chain=True,
|
|
59
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
60
|
+
invoke_without_command=True,
|
|
61
|
+
)
|
|
62
|
+
@click.option("-l", "--list-templates", "list_templates", is_flag=True)
|
|
63
|
+
@click.option("-V", "--versions", "versions", is_flag=True)
|
|
64
|
+
@click.pass_context
|
|
65
|
+
def cli(context, list_templates, versions):
|
|
66
|
+
"""Plone Command Line Interface (CLI)"""
|
|
67
|
+
config = load_config()
|
|
68
|
+
project = find_project_root()
|
|
69
|
+
context.obj = {
|
|
70
|
+
"config": config,
|
|
71
|
+
"project": project,
|
|
72
|
+
"target_dir": str(project.root_folder) if project else None,
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if list_templates:
|
|
76
|
+
reg = TemplateRegistry(config, project)
|
|
77
|
+
click.echo(reg.list_templates())
|
|
78
|
+
|
|
79
|
+
if versions:
|
|
80
|
+
plonecli_version = importlib.metadata.version("plonecli")
|
|
81
|
+
templates_info = get_templates_info(config)
|
|
82
|
+
click.echo(f"plonecli: {plonecli_version}")
|
|
83
|
+
click.echo(f"copier-templates: {templates_info}")
|
|
84
|
+
|
|
85
|
+
# Check for updates (non-blocking, cached)
|
|
86
|
+
if not list_templates and not versions:
|
|
87
|
+
try:
|
|
88
|
+
from plonecli.updater import check_for_updates
|
|
89
|
+
|
|
90
|
+
new_version = check_for_updates()
|
|
91
|
+
if new_version:
|
|
92
|
+
echo(
|
|
93
|
+
f"\nA new version of plonecli is available: {new_version}",
|
|
94
|
+
fg="yellow",
|
|
95
|
+
)
|
|
96
|
+
echo(
|
|
97
|
+
"Update with: uv tool upgrade plonecli\n",
|
|
98
|
+
fg="yellow",
|
|
99
|
+
)
|
|
100
|
+
except Exception: # noqa: BLE001
|
|
101
|
+
pass
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@cli.command()
|
|
105
|
+
@click.argument("template", type=click.STRING, shell_complete=get_templates)
|
|
106
|
+
@click.argument("name")
|
|
107
|
+
@click.pass_context
|
|
108
|
+
def create(context, template, name):
|
|
109
|
+
"""Create a new Plone package"""
|
|
110
|
+
config = context.obj["config"]
|
|
111
|
+
reg = TemplateRegistry(config)
|
|
112
|
+
|
|
113
|
+
resolved = reg.resolve_template_name(template)
|
|
114
|
+
if resolved is None or not reg.is_main_template(resolved):
|
|
115
|
+
raise NoSuchValue(
|
|
116
|
+
context.command.name,
|
|
117
|
+
template,
|
|
118
|
+
possibilities=reg.get_main_templates(),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
echo(f"\nCreating {resolved} project: {name}", fg="green", reverse=True)
|
|
122
|
+
run_create(resolved, name, config)
|
|
123
|
+
context.obj["target_dir"] = name
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@cli.command()
|
|
127
|
+
@click.argument("template", type=click.STRING, shell_complete=get_templates)
|
|
128
|
+
@click.pass_context
|
|
129
|
+
def add(context, template):
|
|
130
|
+
"""Add features to your existing Plone package"""
|
|
131
|
+
project = context.obj.get("project")
|
|
132
|
+
if project is None:
|
|
133
|
+
raise NotInPackageError(context.command.name)
|
|
134
|
+
|
|
135
|
+
config = context.obj["config"]
|
|
136
|
+
reg = TemplateRegistry(config, project)
|
|
137
|
+
|
|
138
|
+
resolved = reg.resolve_template_name(template)
|
|
139
|
+
if resolved is None or not reg.is_subtemplate(resolved):
|
|
140
|
+
raise NoSuchValue(
|
|
141
|
+
context.command.name,
|
|
142
|
+
template,
|
|
143
|
+
possibilities=reg.get_subtemplates(),
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
echo(f"\nAdding {resolved} to {project.root_folder.name}", fg="green", reverse=True)
|
|
147
|
+
run_add(resolved, project, config)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@cli.command()
|
|
151
|
+
@click.pass_context
|
|
152
|
+
def setup(context):
|
|
153
|
+
"""Run zope-setup inside an existing backend_addon"""
|
|
154
|
+
project = context.obj.get("project")
|
|
155
|
+
if project is None:
|
|
156
|
+
raise NotInPackageError(context.command.name)
|
|
157
|
+
if project.project_type != "backend_addon":
|
|
158
|
+
raise click.UsageError(
|
|
159
|
+
"The 'setup' command can only be run inside a backend_addon project."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
config = context.obj["config"]
|
|
163
|
+
echo("\nRunning zope-setup...", fg="green", reverse=True)
|
|
164
|
+
run_create("zope-setup", str(project.root_folder), config)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cli.command("serve")
|
|
168
|
+
@click.pass_context
|
|
169
|
+
def run_serve(context):
|
|
170
|
+
"""Start the Plone instance (delegates to invoke start)"""
|
|
171
|
+
project = context.obj.get("project")
|
|
172
|
+
if project is None:
|
|
173
|
+
raise NotInPackageError(context.command.name)
|
|
174
|
+
params = ["uv", "run", "invoke", "start"]
|
|
175
|
+
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
|
|
176
|
+
echo("\nINFO: Open this in a Web Browser: http://localhost:8080")
|
|
177
|
+
echo("INFO: You can stop it by pressing CTRL + c\n")
|
|
178
|
+
subprocess.call(params, cwd=str(project.root_folder))
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
@cli.command("test")
|
|
182
|
+
@click.option("-v", "--verbose", is_flag=True, help="Verbose test output")
|
|
183
|
+
@click.pass_context
|
|
184
|
+
def run_test(context, verbose):
|
|
185
|
+
"""Run the tests in your package (delegates to invoke test)"""
|
|
186
|
+
project = context.obj.get("project")
|
|
187
|
+
if project is None:
|
|
188
|
+
raise NotInPackageError(context.command.name)
|
|
189
|
+
params = ["uv", "run", "invoke", "test"]
|
|
190
|
+
if verbose:
|
|
191
|
+
params.append("--verbose")
|
|
192
|
+
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
|
|
193
|
+
subprocess.call(params, cwd=str(project.root_folder))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@cli.command("debug")
|
|
197
|
+
@click.pass_context
|
|
198
|
+
def run_debug(context):
|
|
199
|
+
"""Start the Plone instance in debug mode (delegates to invoke debug)"""
|
|
200
|
+
project = context.obj.get("project")
|
|
201
|
+
if project is None:
|
|
202
|
+
raise NotInPackageError(context.command.name)
|
|
203
|
+
params = ["uv", "run", "invoke", "debug"]
|
|
204
|
+
echo(f"\nRUN: {' '.join(params)}", fg="green", reverse=True)
|
|
205
|
+
echo("INFO: You can stop it by pressing CTRL + c\n")
|
|
206
|
+
subprocess.call(params, cwd=str(project.root_folder))
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@cli.command()
|
|
210
|
+
@click.pass_context
|
|
211
|
+
def config(context):
|
|
212
|
+
"""Configure plonecli global settings"""
|
|
213
|
+
cfg = context.obj["config"]
|
|
214
|
+
|
|
215
|
+
# Check for migration from .mrbob on first run
|
|
216
|
+
from plonecli.config import CONFIG_FILE, migrate_from_mrbob
|
|
217
|
+
|
|
218
|
+
if not CONFIG_FILE.exists():
|
|
219
|
+
migrated = migrate_from_mrbob()
|
|
220
|
+
if migrated:
|
|
221
|
+
echo("Found existing ~/.mrbob configuration.", fg="yellow")
|
|
222
|
+
if click.confirm("Import settings from ~/.mrbob?", default=True):
|
|
223
|
+
cfg = migrated
|
|
224
|
+
|
|
225
|
+
# Interactive prompts with current values as defaults
|
|
226
|
+
cfg.author_name = click.prompt("Author name", default=cfg.author_name)
|
|
227
|
+
cfg.author_email = click.prompt("Author email", default=cfg.author_email)
|
|
228
|
+
cfg.github_user = click.prompt("GitHub username", default=cfg.github_user)
|
|
229
|
+
|
|
230
|
+
# Suggest latest Plone version
|
|
231
|
+
from plonecli.plone_versions import get_latest_stable_version
|
|
232
|
+
|
|
233
|
+
default_version = cfg.plone_version or get_latest_stable_version()
|
|
234
|
+
cfg.plone_version = click.prompt(
|
|
235
|
+
"Default Plone version", default=default_version
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
cfg.repo_url = click.prompt("Templates repo URL", default=cfg.repo_url)
|
|
239
|
+
cfg.repo_branch = click.prompt("Templates branch", default=cfg.repo_branch)
|
|
240
|
+
|
|
241
|
+
save_config(cfg)
|
|
242
|
+
echo(f"\nConfiguration saved to {CONFIG_FILE}", fg="green")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@cli.command()
|
|
246
|
+
@click.pass_context
|
|
247
|
+
def update(context):
|
|
248
|
+
"""Update copier-templates and check for plonecli updates"""
|
|
249
|
+
config = context.obj["config"]
|
|
250
|
+
|
|
251
|
+
# Update templates clone
|
|
252
|
+
echo("\nUpdating copier-templates...", fg="green")
|
|
253
|
+
try:
|
|
254
|
+
ensure_templates_cloned(config)
|
|
255
|
+
msg = update_templates_clone(config)
|
|
256
|
+
echo(f" {msg}", fg="green")
|
|
257
|
+
except Exception as e: # noqa: BLE001
|
|
258
|
+
echo(f" Failed to update templates: {e}", fg="red")
|
|
259
|
+
|
|
260
|
+
# Check PyPI for plonecli updates
|
|
261
|
+
echo("\nChecking for plonecli updates...", fg="green")
|
|
262
|
+
try:
|
|
263
|
+
from plonecli.updater import check_for_updates
|
|
264
|
+
|
|
265
|
+
new_version = check_for_updates(force=True)
|
|
266
|
+
if new_version:
|
|
267
|
+
current = importlib.metadata.version("plonecli")
|
|
268
|
+
echo(
|
|
269
|
+
f" New version available: {new_version} (current: {current})",
|
|
270
|
+
fg="yellow",
|
|
271
|
+
)
|
|
272
|
+
echo(" Update with: uv tool upgrade plonecli", fg="yellow")
|
|
273
|
+
else:
|
|
274
|
+
echo(" plonecli is up to date.", fg="green")
|
|
275
|
+
except Exception as e: # noqa: BLE001
|
|
276
|
+
echo(f" Could not check for updates: {e}", fg="red")
|
|
277
|
+
|
|
278
|
+
# Show templates info
|
|
279
|
+
echo(f"\nTemplates: {get_templates_info(config)}", fg="green")
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@cli.command()
|
|
283
|
+
@click.argument(
|
|
284
|
+
"shell",
|
|
285
|
+
required=False,
|
|
286
|
+
type=click.Choice(["bash", "zsh", "fish"]),
|
|
287
|
+
)
|
|
288
|
+
@click.option("--install", is_flag=True, help="Install completion into your shell config")
|
|
289
|
+
def completion(shell, install):
|
|
290
|
+
"""Show or install shell completion.
|
|
291
|
+
|
|
292
|
+
Without arguments, auto-detects your shell and prints the completion script.
|
|
293
|
+
Use --install to append the activation line to your shell config file.
|
|
294
|
+
"""
|
|
295
|
+
import os
|
|
296
|
+
|
|
297
|
+
if shell is None:
|
|
298
|
+
login_shell = os.path.basename(os.environ.get("SHELL", ""))
|
|
299
|
+
if login_shell in ("bash", "zsh", "fish"):
|
|
300
|
+
shell = login_shell
|
|
301
|
+
else:
|
|
302
|
+
raise click.UsageError(
|
|
303
|
+
f"Could not detect shell (SHELL={os.environ.get('SHELL', '')!r}).\n"
|
|
304
|
+
"Please specify one: plonecli completion bash|zsh|fish"
|
|
305
|
+
)
|
|
306
|
+
|
|
307
|
+
env_var = "_PLONECLI_COMPLETE"
|
|
308
|
+
source_cmd = f"{env_var}={shell}_source plonecli"
|
|
309
|
+
|
|
310
|
+
if not install:
|
|
311
|
+
# Print the completion script to stdout
|
|
312
|
+
import subprocess as _sp
|
|
313
|
+
|
|
314
|
+
env = {**os.environ, env_var: f"{shell}_source"}
|
|
315
|
+
result = _sp.run(["plonecli"], capture_output=True, text=True, env=env)
|
|
316
|
+
if result.stdout:
|
|
317
|
+
click.echo(result.stdout)
|
|
318
|
+
else:
|
|
319
|
+
# Fallback: print eval instruction
|
|
320
|
+
click.echo(f'eval "$({source_cmd})"')
|
|
321
|
+
return
|
|
322
|
+
|
|
323
|
+
# --install: append eval line to the appropriate rc file
|
|
324
|
+
rc_files = {
|
|
325
|
+
"bash": os.path.expanduser("~/.bashrc"),
|
|
326
|
+
"zsh": os.path.expanduser("~/.zshrc"),
|
|
327
|
+
"fish": os.path.expanduser("~/.config/fish/completions/plonecli.fish"),
|
|
328
|
+
}
|
|
329
|
+
rc_file = rc_files[shell]
|
|
330
|
+
|
|
331
|
+
if shell == "fish":
|
|
332
|
+
# Fish uses a completions directory with the script itself
|
|
333
|
+
os.makedirs(os.path.dirname(rc_file), exist_ok=True)
|
|
334
|
+
eval_line = f"env {source_cmd} | source"
|
|
335
|
+
else:
|
|
336
|
+
eval_line = f'eval "$({source_cmd})"'
|
|
337
|
+
|
|
338
|
+
# Check if already installed
|
|
339
|
+
if os.path.exists(rc_file):
|
|
340
|
+
with open(rc_file) as f:
|
|
341
|
+
content = f.read()
|
|
342
|
+
if "_PLONECLI_COMPLETE" in content:
|
|
343
|
+
echo(f"Shell completion already configured in {rc_file}", fg="yellow")
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
with open(rc_file, "a") as f:
|
|
347
|
+
f.write(f"\n# plonecli shell completion\n{eval_line}\n")
|
|
348
|
+
|
|
349
|
+
echo(f"Shell completion installed in {rc_file}", fg="green")
|
|
350
|
+
echo(f"Restart your shell or run: source {rc_file}", fg="green")
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
if __name__ == "__main__":
|
|
354
|
+
cli()
|
plonecli/config.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Global plonecli configuration management."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import configparser
|
|
6
|
+
import os
|
|
7
|
+
import tomllib
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
CONFIG_DIR = Path.home() / ".plonecli"
|
|
13
|
+
CONFIG_FILE = CONFIG_DIR / "config.toml"
|
|
14
|
+
TEMPLATES_DIR = Path.home() / ".copier-templates" / "plone-copier-templates"
|
|
15
|
+
DEFAULT_REPO_URL = "https://github.com/plone/copier-templates"
|
|
16
|
+
DEFAULT_BRANCH = "main"
|
|
17
|
+
|
|
18
|
+
# Environment variable overrides
|
|
19
|
+
ENV_REPO_URL = "PLONECLI_TEMPLATES_REPO_URL"
|
|
20
|
+
ENV_REPO_BRANCH = "PLONECLI_TEMPLATES_BRANCH"
|
|
21
|
+
ENV_TEMPLATES_DIR = "PLONECLI_TEMPLATES_DIR"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class PlonecliConfig:
|
|
26
|
+
author_name: str = "Plone Developer"
|
|
27
|
+
author_email: str = "dev@plone.org"
|
|
28
|
+
github_user: str = ""
|
|
29
|
+
plone_version: str = ""
|
|
30
|
+
repo_url: str = DEFAULT_REPO_URL
|
|
31
|
+
repo_branch: str = DEFAULT_BRANCH
|
|
32
|
+
templates_dir: str = str(TEMPLATES_DIR)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_config() -> PlonecliConfig:
|
|
36
|
+
"""Load config from ~/.plonecli/config.toml.
|
|
37
|
+
|
|
38
|
+
Environment variables take precedence over config file values:
|
|
39
|
+
- PLONECLI_TEMPLATES_REPO_URL: Override the templates repo URL
|
|
40
|
+
- PLONECLI_TEMPLATES_BRANCH: Override the templates branch
|
|
41
|
+
- PLONECLI_TEMPLATES_DIR: Override the local templates directory
|
|
42
|
+
|
|
43
|
+
Returns a PlonecliConfig with defaults for any missing values.
|
|
44
|
+
"""
|
|
45
|
+
config = PlonecliConfig()
|
|
46
|
+
if CONFIG_FILE.exists():
|
|
47
|
+
with open(CONFIG_FILE, "rb") as f:
|
|
48
|
+
data = tomllib.load(f)
|
|
49
|
+
|
|
50
|
+
author = data.get("author", {})
|
|
51
|
+
defaults = data.get("defaults", {})
|
|
52
|
+
templates = data.get("templates", {})
|
|
53
|
+
|
|
54
|
+
config.author_name = author.get("name", config.author_name)
|
|
55
|
+
config.author_email = author.get("email", config.author_email)
|
|
56
|
+
config.github_user = author.get("github_user", config.github_user)
|
|
57
|
+
config.plone_version = defaults.get("plone_version", config.plone_version)
|
|
58
|
+
config.repo_url = templates.get("repo_url", config.repo_url)
|
|
59
|
+
config.repo_branch = templates.get("branch", config.repo_branch)
|
|
60
|
+
config.templates_dir = templates.get("local_path", config.templates_dir)
|
|
61
|
+
|
|
62
|
+
# Environment variables override config file
|
|
63
|
+
if os.environ.get(ENV_REPO_URL):
|
|
64
|
+
config.repo_url = os.environ[ENV_REPO_URL]
|
|
65
|
+
if os.environ.get(ENV_REPO_BRANCH):
|
|
66
|
+
config.repo_branch = os.environ[ENV_REPO_BRANCH]
|
|
67
|
+
if os.environ.get(ENV_TEMPLATES_DIR):
|
|
68
|
+
config.templates_dir = os.environ[ENV_TEMPLATES_DIR]
|
|
69
|
+
|
|
70
|
+
# Expand ~ in templates_dir
|
|
71
|
+
config.templates_dir = str(Path(config.templates_dir).expanduser())
|
|
72
|
+
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def save_config(config: PlonecliConfig) -> None:
|
|
77
|
+
"""Save config to ~/.plonecli/config.toml."""
|
|
78
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
|
|
80
|
+
content = f"""\
|
|
81
|
+
[author]
|
|
82
|
+
name = "{config.author_name}"
|
|
83
|
+
email = "{config.author_email}"
|
|
84
|
+
github_user = "{config.github_user}"
|
|
85
|
+
|
|
86
|
+
[defaults]
|
|
87
|
+
plone_version = "{config.plone_version}"
|
|
88
|
+
|
|
89
|
+
[templates]
|
|
90
|
+
repo_url = "{config.repo_url}"
|
|
91
|
+
branch = "{config.repo_branch}"
|
|
92
|
+
local_path = "{config.templates_dir}"
|
|
93
|
+
"""
|
|
94
|
+
CONFIG_FILE.write_text(content)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def migrate_from_mrbob() -> PlonecliConfig | None:
|
|
98
|
+
"""Attempt to read settings from ~/.mrbob and return a config.
|
|
99
|
+
|
|
100
|
+
Returns None if ~/.mrbob doesn't exist or can't be parsed.
|
|
101
|
+
"""
|
|
102
|
+
mrbob_file = Path.home() / ".mrbob"
|
|
103
|
+
if not mrbob_file.exists():
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
parser = configparser.ConfigParser()
|
|
107
|
+
try:
|
|
108
|
+
parser.read(str(mrbob_file))
|
|
109
|
+
except configparser.Error:
|
|
110
|
+
return None
|
|
111
|
+
|
|
112
|
+
config = PlonecliConfig()
|
|
113
|
+
if parser.has_section("variables"):
|
|
114
|
+
variables = dict(parser.items("variables"))
|
|
115
|
+
config.author_name = variables.get("author.name", config.author_name)
|
|
116
|
+
config.author_email = variables.get("author.email", config.author_email)
|
|
117
|
+
config.github_user = variables.get(
|
|
118
|
+
"author.github.user", config.github_user
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if parser.has_section("defaults"):
|
|
122
|
+
defaults = dict(parser.items("defaults"))
|
|
123
|
+
config.plone_version = defaults.get(
|
|
124
|
+
"plone.version", config.plone_version
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
return config
|
plonecli/exceptions.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Custom exceptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from click.exceptions import BadOptionUsage, NoSuchOption
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class NotInPackageError(BadOptionUsage):
|
|
9
|
+
"""Raised if a command is used outside a Plone project."""
|
|
10
|
+
|
|
11
|
+
message = 'The "{0}" command is only allowed within an existing package.'
|
|
12
|
+
|
|
13
|
+
def __init__(self, option_name, ctx=None):
|
|
14
|
+
message = self.message.format(option_name)
|
|
15
|
+
super().__init__(option_name, message, ctx)
|
|
16
|
+
self.option_name = option_name
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class NoSuchValue(NoSuchOption):
|
|
20
|
+
"""Raised if an unknown template name is provided."""
|
|
21
|
+
|
|
22
|
+
message = 'No such value: "{0}".'
|
|
23
|
+
|
|
24
|
+
def __init__(self, option_name, value, possibilities=None, ctx=None):
|
|
25
|
+
message = self.message.format(value)
|
|
26
|
+
super().__init__(option_name, message, possibilities, ctx)
|
|
27
|
+
self.option_name = option_name
|
|
28
|
+
self.possibilities = possibilities
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Fetch and cache available Plone versions from dist.plone.org."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from datetime import datetime, timedelta, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.error import URLError
|
|
10
|
+
from urllib.request import urlopen
|
|
11
|
+
|
|
12
|
+
from plonecli.config import CONFIG_DIR
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
PLONE_VERSIONS_URL = "https://dist.plone.org/release/"
|
|
16
|
+
VERSIONS_CACHE_FILE = CONFIG_DIR / ".plone_versions_cache.json"
|
|
17
|
+
CACHE_MAX_AGE = timedelta(hours=24)
|
|
18
|
+
FALLBACK_VERSION = "6.1.1"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fetch_stable_versions() -> list[str]:
|
|
22
|
+
"""Fetch directory listing from dist.plone.org/release/ and return
|
|
23
|
+
stable versions sorted descending (newest first).
|
|
24
|
+
|
|
25
|
+
Filters out alpha, beta, rc, and dev releases.
|
|
26
|
+
"""
|
|
27
|
+
response = urlopen(PLONE_VERSIONS_URL, timeout=10) # noqa: S310
|
|
28
|
+
html = response.read().decode("utf-8")
|
|
29
|
+
|
|
30
|
+
# Parse directory listing: links like href="6.1.1/"
|
|
31
|
+
version_pattern = re.compile(r'href="(\d+\.\d+[\.\d]*)/?"')
|
|
32
|
+
versions = []
|
|
33
|
+
for match in version_pattern.finditer(html):
|
|
34
|
+
v = match.group(1)
|
|
35
|
+
# Filter: no alpha/beta/rc/dev in the version string
|
|
36
|
+
if not re.search(r"(a|b|rc|dev|alpha|beta)", v, re.IGNORECASE):
|
|
37
|
+
versions.append(v)
|
|
38
|
+
|
|
39
|
+
# Sort by version tuple, descending
|
|
40
|
+
versions.sort(
|
|
41
|
+
key=lambda v: tuple(int(x) for x in v.split(".")),
|
|
42
|
+
reverse=True,
|
|
43
|
+
)
|
|
44
|
+
return versions
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _read_cache() -> dict | None:
|
|
48
|
+
"""Read the versions cache file. Returns None if missing or expired."""
|
|
49
|
+
if not VERSIONS_CACHE_FILE.exists():
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
try:
|
|
53
|
+
data = json.loads(VERSIONS_CACHE_FILE.read_text())
|
|
54
|
+
last_check = datetime.fromisoformat(data["last_check"])
|
|
55
|
+
if datetime.now(timezone.utc) - last_check > CACHE_MAX_AGE:
|
|
56
|
+
return None
|
|
57
|
+
return data
|
|
58
|
+
except (json.JSONDecodeError, KeyError, ValueError):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _write_cache(versions: list[str], latest: str) -> None:
|
|
63
|
+
"""Write the versions cache file."""
|
|
64
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
65
|
+
data = {
|
|
66
|
+
"last_check": datetime.now(timezone.utc).isoformat(),
|
|
67
|
+
"versions": versions,
|
|
68
|
+
"latest": latest,
|
|
69
|
+
}
|
|
70
|
+
VERSIONS_CACHE_FILE.write_text(json.dumps(data))
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def get_latest_stable_version(force: bool = False) -> str:
|
|
74
|
+
"""Return the latest stable Plone version, cached for 24h.
|
|
75
|
+
|
|
76
|
+
Falls back to FALLBACK_VERSION if offline or fetch fails.
|
|
77
|
+
"""
|
|
78
|
+
if not force:
|
|
79
|
+
cache = _read_cache()
|
|
80
|
+
if cache:
|
|
81
|
+
return cache.get("latest", FALLBACK_VERSION)
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
versions = fetch_stable_versions()
|
|
85
|
+
if versions:
|
|
86
|
+
latest = versions[0]
|
|
87
|
+
_write_cache(versions, latest)
|
|
88
|
+
return latest
|
|
89
|
+
except (URLError, OSError, TimeoutError):
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
# Try stale cache before falling back
|
|
93
|
+
if VERSIONS_CACHE_FILE.exists():
|
|
94
|
+
try:
|
|
95
|
+
data = json.loads(VERSIONS_CACHE_FILE.read_text())
|
|
96
|
+
return data.get("latest", FALLBACK_VERSION)
|
|
97
|
+
except (json.JSONDecodeError, KeyError):
|
|
98
|
+
pass
|
|
99
|
+
|
|
100
|
+
return FALLBACK_VERSION
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_version_choices(force: bool = False) -> list[str]:
|
|
104
|
+
"""Return recent stable versions suitable for template choices.
|
|
105
|
+
|
|
106
|
+
Returns up to 5 most recent stable versions, e.g. ['6.1.1', '6.1.0', '6.0.13'].
|
|
107
|
+
"""
|
|
108
|
+
if not force:
|
|
109
|
+
cache = _read_cache()
|
|
110
|
+
if cache:
|
|
111
|
+
return cache.get("versions", [FALLBACK_VERSION])[:5]
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
versions = fetch_stable_versions()
|
|
115
|
+
if versions:
|
|
116
|
+
latest = versions[0]
|
|
117
|
+
_write_cache(versions, latest)
|
|
118
|
+
return versions[:5]
|
|
119
|
+
except (URLError, OSError, TimeoutError):
|
|
120
|
+
pass
|
|
121
|
+
|
|
122
|
+
return [FALLBACK_VERSION]
|