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 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]