cave-cli 3.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,93 @@
1
+ import argparse
2
+ import fnmatch
3
+ import re
4
+ from collections import defaultdict
5
+
6
+ from cave_cli.utils.constants import CHAR_LINE, VALID_REPOS
7
+ from cave_cli.utils.git import ls_remote_tags
8
+ from cave_cli.utils.logger import logger
9
+
10
+ RECENT_LIMIT = 5
11
+
12
+
13
+ def _version_sort_key(v: str) -> list[int]:
14
+ return [int(x) for x in re.findall(r"\d+", v)]
15
+
16
+
17
+ def list_versions(args: argparse.Namespace) -> None:
18
+ """
19
+ Usage:
20
+
21
+ - Lists available stable versions across all CAVE repositories,
22
+ grouped by major version, with a presence check per repo
23
+
24
+ Optional:
25
+
26
+ - ``all``:
27
+ - Type: bool
28
+ - What: Show all versions instead of the 5 most recent per major
29
+ - Default: False
30
+ """
31
+ show_all = getattr(args, "all", False)
32
+ pattern = getattr(args, "pattern", None)
33
+
34
+ repo_tags: dict[str, set[str]] = {}
35
+ for repo in VALID_REPOS:
36
+ logger.info(f"Fetching versions for {repo}...")
37
+ git_url = f"https://github.com/MIT-CAVE/{repo}.git"
38
+ raw = ls_remote_tags(git_url)
39
+ repo_tags[repo] = {
40
+ t for t in raw if re.match(r"^v[0-9]+\.[0-9]+\.[0-9]+$", t)
41
+ }
42
+
43
+ all_versions = sorted(
44
+ (
45
+ v for v in set().union(*repo_tags.values())
46
+ if not pattern or fnmatch.fnmatch(v, pattern)
47
+ ),
48
+ key=_version_sort_key,
49
+ reverse=True,
50
+ )
51
+
52
+ if not all_versions:
53
+ logger.info("No stable versions found.")
54
+ return
55
+
56
+ by_major: dict[int, list[str]] = defaultdict(list)
57
+ for v in all_versions:
58
+ major = _version_sort_key(v)[0]
59
+ by_major[major].append(v)
60
+
61
+ repos = list(VALID_REPOS)
62
+ version_w = max(len("Version"), max(len(v) for v in all_versions))
63
+ col_w = [max(len(r), 3) for r in repos]
64
+ pad = 2
65
+ indent = " "
66
+
67
+ col_header = indent + f"{'Version':<{version_w}}"
68
+ for r, w in zip(repos, col_w):
69
+ col_header += " " * pad + f"{r:^{w}}"
70
+ col_sep = indent + "-" * (len(col_header) - len(indent))
71
+
72
+ for major in sorted(by_major.keys(), reverse=True):
73
+ versions = by_major[major]
74
+ shown = versions if show_all else versions[:RECENT_LIMIT]
75
+ hidden = len(versions) - len(shown)
76
+
77
+ print(CHAR_LINE)
78
+ print(f"Version {major}")
79
+ print(CHAR_LINE)
80
+ print(col_header)
81
+ print(col_sep)
82
+
83
+ for v in shown:
84
+ row = indent + f"{v:<{version_w}}"
85
+ for r, w in zip(repos, col_w):
86
+ mark = "✓" if v in repo_tags[r] else ""
87
+ row += " " * pad + f"{mark:^{w}}"
88
+ print(row)
89
+
90
+ if hidden:
91
+ print(f"\n +{hidden} older, pass --all to show")
92
+
93
+ print()
@@ -0,0 +1,27 @@
1
+ import argparse
2
+
3
+ from cave_cli.commands.run import run_cave
4
+ from cave_cli.utils.logger import logger
5
+ from cave_cli.utils.validate import get_app
6
+
7
+
8
+ def prettify(args: argparse.Namespace) -> None:
9
+ """
10
+ Usage:
11
+
12
+ - Runs code formatting on the CAVE app in the current directory
13
+ """
14
+ app_dir, app_name = get_app()
15
+ logger.info("Prettifying cave_api...")
16
+
17
+ run_args = argparse.Namespace(
18
+ entrypoint="./utils/prettify.sh",
19
+ interactive=False,
20
+ it=False,
21
+ docker_args="",
22
+ ip_port=None,
23
+ yes=True,
24
+ verbose=getattr(args, "verbose", False),
25
+ loglevel=getattr(args, "loglevel", "INFO"),
26
+ )
27
+ run_cave(app_dir, app_name, run_args)
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import sys
5
+
6
+ from cave_cli.utils.docker import (
7
+ remove_containers,
8
+ remove_image,
9
+ remove_volume,
10
+ )
11
+ from cave_cli.utils.logger import logger
12
+ from cave_cli.utils.subprocess import run_and_log
13
+ from cave_cli.utils.validate import confirm_action, validate_app_dir
14
+
15
+
16
+ def purge(args: argparse.Namespace) -> None:
17
+ """
18
+ Usage:
19
+
20
+ - Removes a CAVE app and all its Docker resources
21
+ """
22
+ app_path = args.path
23
+ if not os.path.isdir(app_path):
24
+ logger.error(f"No directory {app_path}")
25
+ sys.exit(1)
26
+
27
+ abs_path = os.path.abspath(app_path)
28
+ errors = validate_app_dir(abs_path)
29
+ if errors:
30
+ logger.error("Ensure you specified a valid CAVE app directory")
31
+ sys.exit(1)
32
+
33
+ app_name = os.path.basename(abs_path)
34
+ logger.header(f"Purging CAVE App ({app_name}):")
35
+
36
+ auto_yes = getattr(args, "yes", False)
37
+ if not auto_yes:
38
+ confirm_action(
39
+ "This will permanently remove all data associated "
40
+ f"with your CAVE App ({app_name})"
41
+ )
42
+
43
+ remove_containers(app_name)
44
+ remove_volume(app_name)
45
+ remove_image(app_name)
46
+
47
+ logger.info("Removing files...")
48
+ try:
49
+ shutil.rmtree(abs_path)
50
+ except PermissionError:
51
+ logger.debug(
52
+ "Some files are owned by root (created by Docker). "
53
+ "Removing via Docker..."
54
+ )
55
+ run_and_log([
56
+ "docker", "run", "--rm",
57
+ "-v", f"{abs_path}:/purge",
58
+ "alpine", "rm", "-rf", "/purge",
59
+ ])
60
+ if os.path.isdir(abs_path):
61
+ try:
62
+ shutil.rmtree(abs_path)
63
+ except PermissionError:
64
+ logger.error(
65
+ "Couldn't remove files. "
66
+ "You may need elevated permissions."
67
+ )
68
+ sys.exit(1)
69
+ logger.info("Done")
70
+ logger.info("Purge complete.")
@@ -0,0 +1,43 @@
1
+ import argparse
2
+
3
+ from cave_cli.commands.run import run_cave
4
+ from cave_cli.utils.docker import remove_containers, remove_volume
5
+ from cave_cli.utils.logger import logger
6
+ from cave_cli.utils.validate import confirm_action, get_app
7
+
8
+
9
+ def reset(
10
+ args: argparse.Namespace,
11
+ app_dir: str | None = None,
12
+ app_name: str | None = None,
13
+ ) -> None:
14
+ """
15
+ Usage:
16
+
17
+ - Removes Docker containers and volumes, then rebuilds from scratch
18
+ """
19
+ auto_yes = getattr(args, "yes", False)
20
+ if not auto_yes:
21
+ confirm_action(
22
+ "This will remove the Docker containers "
23
+ "(deleted and recreated from scratch) for this app. "
24
+ "All data in your database will be lost"
25
+ )
26
+
27
+ if app_dir is None or app_name is None:
28
+ app_dir, app_name = get_app()
29
+ remove_containers(app_name)
30
+ remove_volume(app_name)
31
+
32
+ reset_args = argparse.Namespace(
33
+ entrypoint="./utils/reset_db.sh",
34
+ interactive=False,
35
+ it=False,
36
+ docker_args="",
37
+ ip_port=None,
38
+ yes=True,
39
+ verbose=getattr(args, "verbose", False),
40
+ loglevel=getattr(args, "loglevel", "INFO"),
41
+ )
42
+ run_cave(app_dir, app_name, reset_args)
43
+ logger.info("DB reset complete.")
@@ -0,0 +1,189 @@
1
+ import argparse
2
+ import sys
3
+
4
+ from cave_cli.utils.docker import (
5
+ build_image,
6
+ create_network,
7
+ remove_containers,
8
+ run_detached,
9
+ run_interactive,
10
+ )
11
+ from cave_cli.utils.env import parse_env
12
+ from cave_cli.utils.logger import logger
13
+ from cave_cli.utils.net import find_open_port, is_port_available, parse_ip_port
14
+
15
+
16
+ def run(args: argparse.Namespace) -> None:
17
+ """
18
+ Usage:
19
+
20
+ - Builds and runs the CAVE app's Docker containers
21
+ """
22
+ from cave_cli.utils.validate import get_app
23
+
24
+ app_dir, app_name = get_app()
25
+ run_cave(app_dir, app_name, args)
26
+
27
+
28
+ def run_cave(
29
+ app_dir: str,
30
+ app_name: str,
31
+ args: argparse.Namespace,
32
+ ) -> None:
33
+ build_image(app_name, app_dir)
34
+
35
+ interactive = getattr(args, "interactive", False) or getattr(
36
+ args, "it", False
37
+ )
38
+ entrypoint = getattr(args, "entrypoint", None) or "./utils/run_server.sh"
39
+ docker_args_str = getattr(args, "docker_args", "") or ""
40
+ extra_docker_args = docker_args_str.split() if docker_args_str else []
41
+ ip_port_arg = getattr(args, "ip_port", None)
42
+
43
+ command_args = getattr(args, "command_args", []) or []
44
+ extra_env = getattr(args, "extra_env", {}) or {}
45
+
46
+ if interactive:
47
+ server_command = ["bash"]
48
+ logger.header("CAVE App: (Interactive)")
49
+ else:
50
+ server_command = [entrypoint] + command_args
51
+ logger.header(f"CAVE App: ({entrypoint})")
52
+
53
+ if extra_docker_args:
54
+ logger.info(f"docker-args: {docker_args_str}")
55
+
56
+ env_vars = parse_env(f"{app_dir}/.env")
57
+ db_password = env_vars.get("DATABASE_PASSWORD", "")
58
+ db_image = env_vars.get("DATABASE_IMAGE", "postgres:latest")
59
+ db_command_str = env_vars.get(
60
+ "DATABASE_COMMAND", "postgres -c listen_addresses=*"
61
+ )
62
+ cache_image = env_vars.get("CACHE_IMAGE", "")
63
+
64
+ if not db_command_str:
65
+ db_command_str = "postgres -c listen_addresses=*"
66
+ logger.debug(
67
+ "DATABASE_COMMAND not set in '.env' file. "
68
+ "Using 'postgres -c listen_addresses=*' as default."
69
+ )
70
+
71
+ if not cache_image:
72
+ logger.warn(
73
+ "CACHE_IMAGE not set in '.env' file. "
74
+ "Using valkey/valkey:7 as default."
75
+ )
76
+ cache_image = "valkey/valkey:7"
77
+
78
+ network = f"cave-net:{app_name}"
79
+ create_network(app_name)
80
+
81
+ run_detached(
82
+ name=f"{app_name}_db_host",
83
+ image=db_image,
84
+ network=network,
85
+ volumes=[f"{app_name}_pg_volume:/var/lib/postgresql/data"],
86
+ env_vars={
87
+ "POSTGRES_PASSWORD": db_password,
88
+ "POSTGRES_USER": f"{app_name}_user",
89
+ "POSTGRES_DB": f"{app_name}_name",
90
+ },
91
+ extra_args=extra_docker_args or None,
92
+ command=db_command_str.split(),
93
+ )
94
+
95
+ run_detached(
96
+ name=f"{app_name}_redis_host",
97
+ image=cache_image,
98
+ network=network,
99
+ volumes=[f"{app_name}_redis_volume:/data"],
100
+ extra_args=extra_docker_args or None,
101
+ command=["--save", "7200", "1"],
102
+ )
103
+
104
+ django_env = {
105
+ "DATABASE_HOST": f"{app_name}_db_host",
106
+ "DATABASE_USER": f"{app_name}_user",
107
+ "DATABASE_PASSWORD": db_password,
108
+ "DATABASE_NAME": f"{app_name}_name",
109
+ "DATABASE_PORT": "5432",
110
+ "REDIS_HOST": f"{app_name}_redis_host",
111
+ "REDIS_PORT": "6379",
112
+ **extra_env,
113
+ }
114
+
115
+ django_volumes = [
116
+ f"{app_dir}:/app"
117
+ ]
118
+
119
+ parsed = parse_ip_port(ip_port_arg) if ip_port_arg else None
120
+
121
+ if parsed:
122
+ ip, port = parsed
123
+ if not is_port_available(port):
124
+ logger.error(
125
+ "The specified port is in use. Please try another."
126
+ )
127
+ sys.exit(1)
128
+
129
+ if entrypoint == "./utils/run_server.sh" and not interactive:
130
+ logger.info(
131
+ f"Your Cave App can be accessed from Chrome at:\n"
132
+ f"https://{ip}:{port}\n"
133
+ )
134
+
135
+ run_detached(
136
+ name=f"{app_name}_nginx_host",
137
+ image="nginx",
138
+ network=network,
139
+ extra_args=[
140
+ "--restart",
141
+ "unless-stopped",
142
+ "-p",
143
+ f"{ip}:{port}:8000",
144
+ ]
145
+ + (extra_docker_args or []),
146
+ volumes=[
147
+ f"{app_dir}/utils/lan_hosting:/certs",
148
+ f"{app_dir}/utils/nginx_ssl.conf.template:"
149
+ "/etc/nginx/templates/default.conf.template:ro",
150
+ ],
151
+ env_vars={
152
+ "CAVE_HOST": f"{app_name}_django",
153
+ "CAVE_PORT": str(port),
154
+ "CAVE_IP": ip,
155
+ },
156
+ )
157
+
158
+ django_env["CSRF_TRUSTED_ORIGIN"] = f"{ip}:{port}"
159
+ run_interactive(
160
+ name=f"{app_name}_django",
161
+ image=f"cave-app:{app_name}",
162
+ network=network,
163
+ ports=["8000"],
164
+ volumes=django_volumes,
165
+ env_vars=django_env,
166
+ extra_args=extra_docker_args or None,
167
+ command=server_command,
168
+ )
169
+ else:
170
+ port = find_open_port(8000)
171
+ if entrypoint == "./utils/run_server.sh" and not interactive:
172
+ logger.info(
173
+ f"Your Cave App can be accessed from Chrome at:\n"
174
+ f"http://localhost:{port}\n"
175
+ )
176
+
177
+ run_interactive(
178
+ name=f"{app_name}_django",
179
+ image=f"cave-app:{app_name}",
180
+ network=network,
181
+ ports=[f"{port}:8000"],
182
+ volumes=django_volumes,
183
+ env_vars=django_env,
184
+ extra_args=extra_docker_args or None,
185
+ command=server_command,
186
+ )
187
+
188
+ logger.debug("Stopping Running Containers...")
189
+ remove_containers(app_name)
@@ -0,0 +1,79 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+
6
+ from cave_cli.commands.reset import reset
7
+ from cave_cli.utils.constants import HTTPS_URL
8
+ from cave_cli.utils.git import clone
9
+ from cave_cli.utils.logger import logger
10
+ from cave_cli.utils.sync import sync_files
11
+ from cave_cli.utils.validate import confirm_action, find_app_dir
12
+
13
+
14
+ def sync_cmd(args: argparse.Namespace) -> None:
15
+ """
16
+ Usage:
17
+
18
+ - Merges files from another git repository into the current CAVE app
19
+ """
20
+ app_dir = find_app_dir()
21
+ auto_yes = getattr(args, "yes", False)
22
+
23
+ if not auto_yes:
24
+ confirm_action(
25
+ "This will reset your docker containers and database. "
26
+ "It will also potentially update your local files"
27
+ )
28
+
29
+ logger.header("Sync:")
30
+
31
+ url = getattr(args, "url", None)
32
+ if not url:
33
+ logger.error("--url is required for sync")
34
+ return
35
+
36
+ branch = getattr(args, "branch", None)
37
+ includes = getattr(args, "include", None) or []
38
+ excludes = getattr(args, "exclude", None) or []
39
+
40
+ logger.info("Syncing files with the following parameters:\n")
41
+ logger.info(f"App Location: {app_dir}")
42
+ logger.info(f"Using Repo: {url}")
43
+ logger.info(f"Using Branch: {branch or 'default'}\n")
44
+ logger.info("Downloading repo to sync...")
45
+
46
+ temp_dir = tempfile.mkdtemp()
47
+ success = clone(url, temp_dir, branch=branch)
48
+
49
+ if not success or not os.listdir(temp_dir):
50
+ logger.error(
51
+ f"Failed!\n"
52
+ f"Ensure you have access rights to the repository: {url}\n"
53
+ f"Ensure you specified a valid branch: {branch}."
54
+ )
55
+ shutil.rmtree(temp_dir, ignore_errors=True)
56
+ return
57
+
58
+ logger.info("Done")
59
+ logger.info("Syncing files...")
60
+
61
+ sync_files(
62
+ source=temp_dir,
63
+ dest=app_dir,
64
+ includes=includes,
65
+ excludes=excludes,
66
+ )
67
+
68
+ logger.info("Done")
69
+
70
+ shutil.rmtree(temp_dir, ignore_errors=True)
71
+
72
+ reset_args = argparse.Namespace(
73
+ yes=True,
74
+ verbose=getattr(args, "verbose", False),
75
+ loglevel=getattr(args, "loglevel", "INFO"),
76
+ )
77
+ reset(reset_args)
78
+
79
+ logger.info("Sync complete.")
@@ -0,0 +1,38 @@
1
+ import argparse
2
+
3
+ from cave_cli.commands.run import run_cave
4
+ from cave_cli.utils.logger import logger
5
+ from cave_cli.utils.validate import get_app
6
+
7
+
8
+ def test(args: argparse.Namespace) -> None:
9
+ """
10
+ Usage:
11
+
12
+ - Runs tests for the CAVE app in the current directory
13
+ """
14
+ app_dir, app_name = get_app()
15
+ logger.info("Testing cave_api...")
16
+
17
+ remaining = getattr(args, "remaining", []) or []
18
+
19
+ if remaining:
20
+ command_args = [remaining[0]]
21
+ extra_env: dict[str, str] = {}
22
+ else:
23
+ command_args = []
24
+ extra_env = {"ALL_FLAG": "true"}
25
+
26
+ run_args = argparse.Namespace(
27
+ entrypoint="./utils/run_test.sh",
28
+ command_args=command_args,
29
+ extra_env=extra_env,
30
+ interactive=False,
31
+ it=False,
32
+ docker_args="",
33
+ ip_port=None,
34
+ yes=True,
35
+ verbose=getattr(args, "verbose", False),
36
+ loglevel=getattr(args, "loglevel", "INFO"),
37
+ )
38
+ run_cave(app_dir, app_name, run_args)
@@ -0,0 +1,39 @@
1
+ import argparse
2
+ import subprocess
3
+ import sys
4
+
5
+ from cave_cli.utils.logger import logger
6
+
7
+
8
+ def uninstall(args: argparse.Namespace) -> None:
9
+ """
10
+ Usage:
11
+
12
+ - Removes the CAVE CLI package
13
+ """
14
+ try:
15
+ response = input(
16
+ "Are you sure you want to uninstall CAVE CLI? [y/N] "
17
+ )
18
+ except (EOFError, KeyboardInterrupt):
19
+ print()
20
+ logger.error("Uninstall canceled")
21
+ return
22
+
23
+ if response.strip().lower() not in ("y", "yes"):
24
+ logger.error("Uninstall canceled")
25
+ return
26
+
27
+ logger.info("Removing installation...")
28
+ result = subprocess.run(
29
+ [sys.executable, "-m", "pip", "uninstall", "cave_cli", "-y"],
30
+ stdout=subprocess.PIPE,
31
+ stderr=subprocess.PIPE,
32
+ text=True,
33
+ )
34
+ if result.returncode == 0:
35
+ logger.info("Done.")
36
+ else:
37
+ logger.error("Failed to uninstall CAVE CLI.")
38
+ if result.stderr:
39
+ logger.error(result.stderr.strip())
@@ -0,0 +1,38 @@
1
+ import argparse
2
+ import subprocess
3
+ import sys
4
+
5
+ from cave_cli.utils.logger import logger
6
+
7
+
8
+ def update(args: argparse.Namespace) -> None:
9
+ """
10
+ Usage:
11
+
12
+ - Updates the CAVE CLI via pip install --upgrade
13
+ """
14
+ logger.info("Updating CAVE CLI...")
15
+ version = getattr(args, "version", None)
16
+ if version:
17
+ spec = (
18
+ f"cave_cli @ "
19
+ f"git+https://github.com/MIT-CAVE/cave_cli.git@{version}"
20
+ )
21
+ else:
22
+ spec = (
23
+ "cave_cli @ "
24
+ "git+https://github.com/MIT-CAVE/cave_cli.git@main"
25
+ )
26
+ result = subprocess.run(
27
+ [sys.executable, "-m", "pip", "install", "--upgrade", spec],
28
+ stdout=subprocess.PIPE,
29
+ stderr=subprocess.PIPE,
30
+ text=True,
31
+ )
32
+ if result.returncode == 0:
33
+ logger.info("Done.")
34
+ logger.info("CAVE CLI updated.")
35
+ else:
36
+ logger.error("Failed to update CAVE CLI.")
37
+ if result.stderr:
38
+ logger.error(result.stderr.strip())
@@ -0,0 +1,70 @@
1
+ import argparse
2
+ import os
3
+ import shutil
4
+ import tempfile
5
+
6
+ from cave_cli.commands.create import remove_licence_info
7
+ from cave_cli.commands.run import run_cave
8
+ from cave_cli.commands.sync_cmd import sync_cmd
9
+ from cave_cli.utils.constants import HTTPS_URL
10
+ from cave_cli.utils.env import upgrade_env
11
+ from cave_cli.utils.git import clone
12
+ from cave_cli.utils.logger import logger
13
+ from cave_cli.utils.sync import sync_files
14
+ from cave_cli.utils.validate import confirm_action, find_app_dir, get_app
15
+
16
+
17
+ def upgrade(args: argparse.Namespace) -> None:
18
+ """
19
+ Usage:
20
+
21
+ - Upgrades the CAVE app in the current directory from the template repository
22
+ """
23
+ auto_yes = getattr(args, "yes", False)
24
+ if not auto_yes:
25
+ confirm_action(
26
+ "This will potentially update all files not in "
27
+ "'cave_api/' or '.env' and reset your database"
28
+ )
29
+
30
+ logger.header("Upgrade:")
31
+ logger.info("Upgrading CAVE App via a Sync operation...\n")
32
+
33
+ app_dir, app_name = get_app()
34
+ url = getattr(args, "url", None) or HTTPS_URL
35
+ version = getattr(args, "version", None)
36
+ skip_env_upgrade = getattr(args, "skip_env_upgrade", False)
37
+
38
+ sync_args = argparse.Namespace(
39
+ url=url,
40
+ branch=version or "main",
41
+ include=["cave_api/docs"],
42
+ exclude=[".env", ".gitignore", "cave_api/*"],
43
+ yes=True,
44
+ verbose=getattr(args, "verbose", False),
45
+ loglevel=getattr(args, "loglevel", "INFO"),
46
+ )
47
+ sync_cmd(sync_args)
48
+
49
+ if not skip_env_upgrade:
50
+ temp_dir = tempfile.mkdtemp()
51
+ clone(url, temp_dir, branch=version or "main")
52
+ env_path = os.path.join(app_dir, ".env")
53
+ upgrade_env(env_path, temp_dir)
54
+ shutil.rmtree(temp_dir, ignore_errors=True)
55
+
56
+ remove_licence_info(app_dir)
57
+
58
+ logger.info("\nUpdating Docs...")
59
+ docs_args = argparse.Namespace(
60
+ entrypoint="./utils/generate_docs.sh",
61
+ interactive=False,
62
+ it=False,
63
+ docker_args="",
64
+ ip_port=None,
65
+ yes=True,
66
+ verbose=getattr(args, "verbose", False),
67
+ loglevel=getattr(args, "loglevel", "INFO"),
68
+ )
69
+ run_cave(app_dir, app_name, docs_args)
70
+ logger.info("Upgrade complete.")