pre-commit-crocodile 2.0.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.
Files changed (31) hide show
  1. pre_commit_crocodile/__init__.py +5 -0
  2. pre_commit_crocodile/__main__.py +5 -0
  3. pre_commit_crocodile/assets/.cz.yaml +188 -0
  4. pre_commit_crocodile/assets/.pre-commit-config.yaml +68 -0
  5. pre_commit_crocodile/assets/__init__.py +0 -0
  6. pre_commit_crocodile/cli/__init__.py +0 -0
  7. pre_commit_crocodile/cli/entrypoint.py +414 -0
  8. pre_commit_crocodile/cli/main.py +269 -0
  9. pre_commit_crocodile/hooks/__init__.py +0 -0
  10. pre_commit_crocodile/hooks/configurations.py +322 -0
  11. pre_commit_crocodile/hooks/prepare_commit_message.py +258 -0
  12. pre_commit_crocodile/package/__init__.py +0 -0
  13. pre_commit_crocodile/package/bundle.py +34 -0
  14. pre_commit_crocodile/package/settings.py +148 -0
  15. pre_commit_crocodile/package/updates.py +228 -0
  16. pre_commit_crocodile/package/version.py +80 -0
  17. pre_commit_crocodile/prints/__init__.py +0 -0
  18. pre_commit_crocodile/prints/boxes.py +82 -0
  19. pre_commit_crocodile/prints/colors.py +85 -0
  20. pre_commit_crocodile/system/__init__.py +0 -0
  21. pre_commit_crocodile/system/commands.py +90 -0
  22. pre_commit_crocodile/system/platform.py +74 -0
  23. pre_commit_crocodile/types/__init__.py +0 -0
  24. pre_commit_crocodile/types/environments.py +92 -0
  25. pre_commit_crocodile/types/strings.py +152 -0
  26. pre_commit_crocodile-2.0.0.dist-info/LICENSE +201 -0
  27. pre_commit_crocodile-2.0.0.dist-info/METADATA +212 -0
  28. pre_commit_crocodile-2.0.0.dist-info/RECORD +31 -0
  29. pre_commit_crocodile-2.0.0.dist-info/WHEEL +5 -0
  30. pre_commit_crocodile-2.0.0.dist-info/entry_points.txt +3 -0
  31. pre_commit_crocodile-2.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,258 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Standard libraries
4
+ from os import environ
5
+ from pathlib import Path
6
+ from re import match, search
7
+ import sys
8
+ from sys import argv, exit as sys_exit, path
9
+ from typing import List, NamedTuple, Optional, Tuple
10
+
11
+ # Bind sources
12
+ sys.dont_write_bytecode = True
13
+ path.append(str(Path(__file__).resolve().parent))
14
+
15
+ # Components, pylint: disable=import-error,wrong-import-position
16
+ from configurations import (
17
+ COMMITS_CHANGES_PATTERN,
18
+ COMMITS_CHANGES_SECTION,
19
+ COMMITS_COMMENTS_PREFIX,
20
+ COMMITS_DEFAULT_BODY,
21
+ COMMITS_DEFAULT_SCOPE,
22
+ COMMITS_DEFAULT_SUBJECT,
23
+ COMMITS_DEFAULT_TYPE,
24
+ CHANGES_EVALUATORS,
25
+ COMMITS_FOOTER_SIGNOFF,
26
+ CHANGES_MATCHERS,
27
+ COMMITS_MESSAGE_EOL,
28
+ )
29
+
30
+ # Parse commit input
31
+ def parse_commit_input(filepath: str) -> Tuple[bool, bool, str, List[str]]:
32
+
33
+ # Variables
34
+ capture: bool = False
35
+ changes: List[str] = []
36
+ context: List[str] = []
37
+ empty: bool = True
38
+ signoff: bool = False
39
+
40
+ # Read commit template
41
+ with open(filepath, encoding='utf8', mode='r') as file:
42
+ for line in file:
43
+
44
+ # Get line content
45
+ content = line.rstrip()
46
+
47
+ # Append context
48
+ context += [content]
49
+
50
+ # Detect signoff
51
+ if content and content.startswith(COMMITS_FOOTER_SIGNOFF):
52
+ signoff = True
53
+
54
+ # Detect contents
55
+ if content and not any(
56
+ content.startswith(prefix) for prefix in [
57
+ COMMITS_COMMENTS_PREFIX,
58
+ COMMITS_FOOTER_SIGNOFF,
59
+ ]):
60
+ empty = False
61
+
62
+ # Detect section
63
+ if content.startswith(COMMITS_CHANGES_SECTION):
64
+ capture = True
65
+ continue
66
+
67
+ # Parse section
68
+ if capture:
69
+ matches = match(COMMITS_CHANGES_PATTERN, content)
70
+ if matches:
71
+ changes.append(matches.group(2).strip())
72
+ elif not content.strip() or content.startswith('#'):
73
+ capture = False
74
+
75
+ # Result
76
+ return empty, signoff, COMMITS_MESSAGE_EOL.join(context), changes
77
+
78
+ # Prepare commit title, pylint: disable=too-many-branches,too-many-locals,too-many-nested-blocks
79
+ def prepare_commit_title(changes: List[str]) -> Tuple[str, str]:
80
+
81
+ # Types
82
+ Result = NamedTuple('Result', [
83
+ ('priority', int),
84
+ ('level', int),
85
+ ('commit_type', str),
86
+ ('commit_scope', str),
87
+ ])
88
+
89
+ # Variables
90
+ commit_scope: str = COMMITS_DEFAULT_SCOPE
91
+ commit_type: str = COMMITS_DEFAULT_TYPE
92
+ level: int
93
+ levels: List[int]
94
+ priority: int = 0
95
+ results: List[Result] = []
96
+
97
+ # Evaluate common changes
98
+ for changes_evaluator in CHANGES_EVALUATORS:
99
+ priority += 1
100
+ for type_change in changes_evaluator.changes:
101
+ for change in changes:
102
+ if not search(type_change, change):
103
+ continue
104
+ for parser in changes_evaluator.parsers:
105
+ matches = search(parser.match, change)
106
+ if not matches or not isinstance(matches.lastindex, int):
107
+ continue
108
+ level = 0
109
+ for group in parser.groups:
110
+ level += 1
111
+ if matches.lastindex >= group:
112
+ results += [
113
+ Result(
114
+ priority=priority,
115
+ level=level,
116
+ commit_type=parser.commit_type,
117
+ commit_scope=matches.group(group),
118
+ )
119
+ ]
120
+
121
+ # Match common changes
122
+ for changes_matcher in CHANGES_MATCHERS:
123
+ priority += 1
124
+ for matcher_type in changes_matcher.types:
125
+ for type_change in matcher_type.changes:
126
+ for change in changes:
127
+ if search(type_change, change):
128
+ results += [
129
+ Result(
130
+ priority=priority,
131
+ level=0,
132
+ commit_type=matcher_type.commit_type,
133
+ commit_scope=changes_matcher.commit_scope,
134
+ )
135
+ ]
136
+
137
+ # Parse results
138
+ if results:
139
+ priority = min((result.priority for result in results))
140
+ results = [result for result in set(results) if result.priority == priority]
141
+ levels = sorted([result.level for result in results])
142
+ level = min((level for level in set(levels) if levels.count(level) == 1),
143
+ default=levels[-1])
144
+ for result in [result for result in set(results) if result.level == level]:
145
+ commit_type = result.commit_type
146
+ commit_scope = result.commit_scope
147
+ break
148
+
149
+ # Result
150
+ return commit_type, commit_scope
151
+
152
+ # Prepare commit message
153
+ def prepare_commit_template(
154
+ commit_type: str,
155
+ commit_scope: str,
156
+ commit_subject: str,
157
+ commit_body: str,
158
+ signoff: bool,
159
+ ) -> str:
160
+
161
+ # Variables
162
+ commit_lines: List[str] = []
163
+
164
+ # Prepare commit title
165
+ commit_lines += [
166
+ f'{commit_type}({commit_scope}): {commit_subject}',
167
+ ]
168
+
169
+ # Prepare commit body
170
+ template_body = not ([
171
+ line for line in commit_body.splitlines()
172
+ if not line.startswith(COMMITS_COMMENTS_PREFIX)
173
+ ])
174
+ if commit_body:
175
+ commit_lines += [
176
+ '',
177
+ commit_body,
178
+ ]
179
+
180
+ # Append commit separator
181
+ if commit_body and signoff:
182
+ commit_lines += [
183
+ f'{COMMITS_COMMENTS_PREFIX} ---' if template_body else '---',
184
+ ]
185
+
186
+ # Append comments separator
187
+ if not signoff:
188
+ commit_lines += [
189
+ '',
190
+ ]
191
+
192
+ # Result
193
+ return str(COMMITS_MESSAGE_EOL.join(commit_lines))
194
+
195
+ # Main, pylint: disable=too-many-branches,too-many-statements
196
+ def main() -> None:
197
+
198
+ # Variables
199
+ changes: List[str]
200
+ commit_scope: str
201
+ commit_type: str
202
+ context: str
203
+ empty: bool
204
+ filepath: str
205
+ signoff: bool
206
+ source: Optional[str]
207
+
208
+ # Validate arguments
209
+ if len(argv) <= 1:
210
+ sys_exit(1)
211
+
212
+ # Parse arguments
213
+ filepath = argv[1]
214
+ source = argv[2] if len(argv) > 2 else None
215
+
216
+ # Ignore commit source, documentation:
217
+ # - message (if a -m or -F option was given)
218
+ # - template (if a -t option was given or the configuration option commit.template is set)
219
+ # - merge (if the commit is a merge or a .git/MERGE_MSG file exists)
220
+ # - squash (if a .git/SQUASH_MSG file exists)
221
+ # - commit, followed by a commit object name (if a -c, -C or --amend option was given)
222
+ if source and source in ['template', 'squash', 'commit']:
223
+ sys_exit(0)
224
+
225
+ # Parse commit input
226
+ empty, signoff, context, changes = parse_commit_input(filepath)
227
+
228
+ # Existing commit input
229
+ if not empty:
230
+ sys_exit(0)
231
+
232
+ # Prepare commit title
233
+ commit_type, commit_scope = prepare_commit_title(changes)
234
+
235
+ # Prepare commit template
236
+ commit_template = prepare_commit_template(
237
+ commit_type=commit_type,
238
+ commit_scope=commit_scope,
239
+ commit_subject=COMMITS_DEFAULT_SUBJECT,
240
+ commit_body=COMMITS_DEFAULT_BODY,
241
+ signoff=signoff,
242
+ )
243
+
244
+ # Write commit template
245
+ if environ.get('GIT_EXEC_PATH', ''):
246
+ with open(filepath, encoding='utf8', mode='w') as file:
247
+ file.write(commit_template + context)
248
+
249
+ # Dump commit template
250
+ else:
251
+ print(commit_template + context)
252
+
253
+ # Result
254
+ sys_exit(0)
255
+
256
+ # Entrypoint
257
+ if __name__ == '__main__': # pragma: no cover
258
+ main()
File without changes
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Bundle class, pylint: disable=too-few-public-methods
4
+ class Bundle:
5
+
6
+ # Modules
7
+ MODULE: str = 'pre_commit_crocodile'
8
+
9
+ # Names
10
+ NAME: str = 'pre-commit-crocodile'
11
+
12
+ # Packages
13
+ PACKAGE: str = 'pre-commit-crocodile'
14
+
15
+ # Resources
16
+ RESOURCES_ASSETS: str = f'{MODULE}.assets'
17
+
18
+ # Details
19
+ DESCRIPTION: str = 'Git hooks intended for developers using pre-commit'
20
+
21
+ # Sources
22
+ REPOSITORY: str = 'https://gitlab.com/RadianDevCore/tools/pre-commit-crocodile'
23
+
24
+ # Releases
25
+ RELEASE_FIRST_TIMESTAMP: int = 1579337311
26
+
27
+ # Environment
28
+ ENV_DEBUG_REVISION_SHA: str = 'DEBUG_REVISION_SHA'
29
+ ENV_DEBUG_UPDATES_DAILY: str = 'DEBUG_UPDATES_DAILY'
30
+ ENV_DEBUG_UPDATES_DISABLE: str = 'DEBUG_UPDATES_DISABLE'
31
+ ENV_DEBUG_UPDATES_FAKE: str = 'DEBUG_UPDATES_FAKE'
32
+ ENV_DEBUG_UPDATES_OFFLINE: str = 'DEBUG_UPDATES_OFFLINE'
33
+ ENV_DEBUG_VERSION_FAKE: str = 'DEBUG_VERSION_FAKE'
34
+ ENV_NO_COLOR: str = 'NO_COLOR'
@@ -0,0 +1,148 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Standard libraries
4
+ from configparser import ConfigParser
5
+ from pathlib import Path
6
+ from sys import stdout
7
+ from typing import Optional, Union
8
+
9
+ # Components
10
+ from ..prints.colors import Colors
11
+ from ..system.platform import Platform
12
+
13
+ # Settings class
14
+ class Settings:
15
+
16
+ # Types
17
+ Value = Union[int, str]
18
+
19
+ # Constants
20
+ SETTINGS_FILE: str = 'settings.ini'
21
+
22
+ # Members
23
+ __folder: Path
24
+ __persistent: bool
25
+ __path: Path
26
+ __settings: ConfigParser
27
+
28
+ # Constructor
29
+ def __init__(self, name: str) -> None:
30
+
31
+ # Prepare paths
32
+ self.__folder = Platform.userspace(name)
33
+ self.__path = self.__folder / Settings.SETTINGS_FILE
34
+ self.__persistent = False
35
+
36
+ # Parse settings
37
+ self.__settings = ConfigParser()
38
+ self.__settings.read(self.__path)
39
+
40
+ # Prepare missing settings
41
+ try:
42
+ if self.get('package', 'name') != name:
43
+ raise ValueError('Missing settings files')
44
+ self.__persistent = True
45
+ except ValueError:
46
+ try:
47
+ self.__prepare()
48
+ self.__reset(name)
49
+ self.__persistent = True
50
+ except PermissionError: # pragma: no cover
51
+ self.__persistent = False
52
+
53
+ # Initialize settings
54
+ if not Path(self.__path).is_file():
55
+ self.__write()
56
+
57
+ # Prepare
58
+ def __prepare(self) -> None:
59
+
60
+ # Prepare folder path
61
+ if not Platform.IS_SIMULATED:
62
+ self.__folder.mkdir(parents=True, exist_ok=True)
63
+
64
+ # Reset
65
+ def __reset(self, name: str) -> None:
66
+
67
+ # Prepare barebone settings
68
+ self.__settings = ConfigParser()
69
+ self.set('package', 'name', name)
70
+
71
+ # Writer
72
+ def __write(self) -> None:
73
+
74
+ # Write initial settings
75
+ if self.__persistent and not Platform.IS_SIMULATED:
76
+ with open(self.__path, encoding='utf8', mode='w') as output:
77
+ self.__settings.write(output)
78
+
79
+ # Has
80
+ def has(self, group: str, key: str) -> bool:
81
+
82
+ # Check settings key in group
83
+ return group in self.__settings and key in self.__settings[group]
84
+
85
+ # Get
86
+ def get(self, group: str, key: str) -> Optional[Value]:
87
+
88
+ # Get settings key in group
89
+ if group in self.__settings and key in self.__settings[group]:
90
+ return self.__settings[group][key]
91
+
92
+ # Default fallback
93
+ return None
94
+
95
+ # Get bool
96
+ def get_bool(self, group: str, key: str) -> Optional[bool]:
97
+
98
+ # Get settings key as boolean
99
+ try:
100
+ value: str = str(self.get(group, key))
101
+ return value.lower() == 'true' or int(value) == 1
102
+ except (TypeError, ValueError):
103
+ return False
104
+
105
+ # Set
106
+ def set(self, group: str, key: str, value: Value) -> None:
107
+
108
+ # Prepare group
109
+ if group not in self.__settings:
110
+ self.__settings[group] = {}
111
+
112
+ # Unset key
113
+ if str(value) == 'UNSET':
114
+ del self.__settings[group][key]
115
+
116
+ # Set key
117
+ else:
118
+ self.__settings[group][key] = str(value)
119
+
120
+ # Write updated settings
121
+ self.__write()
122
+
123
+ # Set bool
124
+ def set_bool(self, group: str, key: str, value: Value) -> None:
125
+
126
+ # Set settings key as boolean
127
+ self.set(group, key, 1 if value else 0)
128
+
129
+ # Show
130
+ def show(self) -> None:
131
+
132
+ # Settings file path
133
+ print(' ')
134
+ print(
135
+ f' {Colors.GREEN}===[ {Colors.YELLOW}Settings:' \
136
+ f' {Colors.BOLD}{self.__path} {Colors.GREEN}]==={Colors.RESET}'
137
+ )
138
+ print(' ')
139
+
140
+ # Settings simulated contents
141
+ if Platform.IS_SIMULATED:
142
+ self.__settings.write(stdout)
143
+
144
+ # Settings file contents
145
+ else:
146
+ with open(self.__path, encoding='utf8', mode='r') as data:
147
+ print(data.read())
148
+ Platform.flush()
@@ -0,0 +1,228 @@
1
+ #!/usr/bin/env python3
2
+
3
+ # Standard libraries
4
+ from datetime import datetime
5
+ from os import access, environ, W_OK
6
+ from time import localtime, strftime, time
7
+ from typing import Optional
8
+
9
+ # Modules libraries
10
+ from pkg_resources import parse_version
11
+
12
+ # Components
13
+ from ..prints.boxes import Boxes
14
+ from ..prints.colors import Colors
15
+ from ..system.platform import Platform
16
+ from .bundle import Bundle
17
+ from .settings import Settings
18
+ from .version import Version
19
+
20
+ # Updates class
21
+ class Updates:
22
+
23
+ # Members
24
+ __enabled: bool
25
+ __name: str
26
+ __settings: Settings
27
+
28
+ # Constructor
29
+ def __init__(
30
+ self,
31
+ name: str,
32
+ settings: Settings,
33
+ ) -> None:
34
+
35
+ # Initialize members
36
+ self.__name = name
37
+ self.__settings = settings
38
+
39
+ # Detect migration
40
+ self.__migration()
41
+
42
+ # Prepare enabled
43
+ if not self.__settings.has('updates', 'enabled'):
44
+ self.__settings.set_bool('updates', 'enabled', True)
45
+
46
+ # Check enabled
47
+ self.__enabled = bool(self.__settings.get_bool('updates', 'enabled')) and \
48
+ not environ.get(Bundle.ENV_DEBUG_UPDATES_DISABLE, '')
49
+
50
+ # Migration
51
+ def __migration(self) -> None:
52
+
53
+ # Acquire versions
54
+ current_version = Version.get()
55
+ package_version = self.__settings.get('package', 'version')
56
+ if not package_version:
57
+ package_version = '0.0.0'
58
+
59
+ # Refresh package version
60
+ if not package_version or current_version != package_version:
61
+ self.__settings.set('package', 'version', current_version)
62
+
63
+ # Checker
64
+ def check(
65
+ self,
66
+ older: bool = False,
67
+ ) -> bool:
68
+
69
+ # Reference version
70
+ version = '0.0.0' if older else Version.get()
71
+
72
+ # Fake test updates
73
+ if Bundle.ENV_DEBUG_UPDATES_FAKE in environ:
74
+ available = environ[Bundle.ENV_DEBUG_UPDATES_FAKE]
75
+ if parse_version(available) >= parse_version(version):
76
+
77
+ # Show updates message
78
+ release_date = datetime.utcfromtimestamp(Bundle.RELEASE_FIRST_TIMESTAMP)
79
+ Updates.message(
80
+ name=self.__name,
81
+ older=older,
82
+ available=available,
83
+ date=release_date,
84
+ )
85
+ return True
86
+
87
+ # Check if not offline
88
+ if not environ.get(Bundle.ENV_DEBUG_UPDATES_OFFLINE, ''):
89
+
90
+ # Modules libraries, pylint: disable=import-outside-toplevel
91
+ from update_checker import UpdateChecker
92
+
93
+ # Check for updates
94
+ check = UpdateChecker(bypass_cache=True).check(self.__name, version)
95
+ if check: # pragma: no cover
96
+
97
+ # Show updates message
98
+ Updates.message(
99
+ name=self.__name,
100
+ older=older,
101
+ available=check.available_version,
102
+ date=check.release_date,
103
+ )
104
+ return True
105
+
106
+ # Older offline failure
107
+ if older:
108
+
109
+ # Show offline message
110
+ Updates.message(
111
+ name=self.__name,
112
+ offline=True,
113
+ )
114
+ return True
115
+
116
+ # Result
117
+ return False
118
+
119
+ # Daily
120
+ @property
121
+ def daily(self) -> bool:
122
+
123
+ # Acquire updates check last timestamp
124
+ last = self.__settings.get('updates', 'last_timestamp')
125
+
126
+ # Fake test updates
127
+ if Bundle.ENV_DEBUG_UPDATES_DAILY in environ:
128
+ last = None
129
+
130
+ # Handle daily checks
131
+ current = int(time())
132
+ if not last or strftime('%Y-%m-%d', localtime(current)) != strftime(
133
+ '%Y-%m-%d', localtime(int(last))):
134
+ self.__settings.set('updates', 'last_timestamp', current)
135
+ return True
136
+
137
+ # Default fallback
138
+ return False
139
+
140
+ # Enabled
141
+ @property
142
+ def enabled(self) -> bool:
143
+ return self.__enabled
144
+
145
+ # Message, pylint: disable=too-many-arguments
146
+ @staticmethod
147
+ def message(
148
+ name: str,
149
+ offline: bool = False,
150
+ older: bool = False,
151
+ available: Optional[str] = None,
152
+ date: Optional[datetime] = None,
153
+ ) -> None:
154
+
155
+ # Modules libraries, pylint: disable=import-outside-toplevel
156
+ from update_checker import pretty_date
157
+
158
+ # Create message box
159
+ box = Boxes()
160
+
161
+ # Acquire current version
162
+ version = Version.get()
163
+
164
+ # Detect package installer
165
+ package_install: str = ''
166
+ if Platform.PATH_SEPARATOR + 'pipx' + Platform.PATH_SEPARATOR in Version.path():
167
+ package_install = 'pipx upgrade' # pragma: no cover
168
+ else:
169
+ package_install = 'pip3 install -U' # pragma: no cover
170
+
171
+ # Detect package ownership
172
+ writable = access(__file__, W_OK)
173
+ if Platform.IS_USER_SUDO or not writable:
174
+ package_install = 'sudo ' + package_install # pragma: no cover
175
+
176
+ # Prepare package specification
177
+ package_specification: str = ''
178
+ if 'pipx' in package_install:
179
+ package_specification = f'{name}' # pragma: no cover
180
+ else:
181
+ package_specification = f'{name}>={available}' # pragma: no cover
182
+
183
+ # Evaluate same version
184
+ same = available and available == version
185
+
186
+ # Version message prefix
187
+ version_outdated = not offline and available and not older and not same
188
+ version_prefix = f'{Colors.YELLOW_LIGHT}Version: {Colors.BOLD}{name}' \
189
+ f' {Colors.RED if version_outdated else Colors.GREEN}{version}'
190
+
191
+ # Offline version message
192
+ if offline:
193
+ box.add(f'{version_prefix} {Colors.BOLD}not found, network might be down')
194
+
195
+ # Updated version message
196
+ elif same:
197
+ box.add(
198
+ f'{version_prefix} {Colors.BOLD}was released {pretty_date(date)}{Colors.BOLD}!'
199
+ )
200
+
201
+ # Older version message
202
+ elif older:
203
+ box.add(
204
+ f'{version_prefix} {Colors.BOLD}newer than {Colors.RED}{available}' \
205
+ f' {Colors.BOLD}from {pretty_date(date)}{Colors.BOLD}!'
206
+ )
207
+
208
+ # Newer version message
209
+ else:
210
+ box.add(
211
+ f'{version_prefix} {Colors.BOLD}updated {pretty_date(date)}' \
212
+ f' to {Colors.GREEN}{available}{Colors.BOLD}!'
213
+ )
214
+
215
+ # Changelog message
216
+ box.add(
217
+ f'{Colors.YELLOW_LIGHT}Changelog: {Colors.CYAN}{Bundle.REPOSITORY}/-/releases'
218
+ )
219
+
220
+ # Update message
221
+ if available:
222
+ box.add(
223
+ f'{Colors.YELLOW_LIGHT}Update: {Colors.BOLD}' \
224
+ f"Run {Colors.GREEN}{package_install} '{package_specification}'"
225
+ )
226
+
227
+ # Print message box
228
+ box.print()