mantis-cli 19.1.7__tar.gz → 20.0.0__tar.gz
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.
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/PKG-INFO +4 -7
- mantis_cli-20.0.0/mantis/__init__.py +1 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/command_line.py +96 -54
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/environment.py +31 -3
- mantis_cli-20.0.0/mantis/helpers.py +98 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/logic.py +33 -14
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/managers.py +172 -15
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/mantis.tpl +1 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/PKG-INFO +3 -6
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/entry_points.txt +1 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/requires.txt +1 -1
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/setup.py +2 -2
- mantis_cli-19.1.7/mantis/__init__.py +0 -1
- mantis_cli-19.1.7/mantis/helpers.py +0 -131
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/LICENSE +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/MANIFEST.in +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/README.md +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/__main__.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/crypto.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/extensions/__init__.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/extensions/django.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/extensions/nginx.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis/extensions/postgres.py +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/SOURCES.txt +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/mantis_cli.egg-info/top_level.txt +0 -0
- {mantis_cli-19.1.7 → mantis_cli-20.0.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
|
-
Name:
|
|
3
|
-
Version:
|
|
2
|
+
Name: mantis_cli
|
|
3
|
+
Version: 20.0.0
|
|
4
4
|
Summary: Management command to build and deploy webapps, especially based on Django
|
|
5
5
|
Home-page: https://github.com/PragmaticMates/mantis-cli
|
|
6
6
|
Author: Erik Telepovský
|
|
@@ -9,6 +9,7 @@ Maintainer: Pragmatic Mates
|
|
|
9
9
|
Maintainer-email: info@pragmaticmates.com
|
|
10
10
|
License: GNU General Public License (GPL)
|
|
11
11
|
Keywords: management deployment docker command
|
|
12
|
+
Platform: UNKNOWN
|
|
12
13
|
Classifier: Programming Language :: Python
|
|
13
14
|
Classifier: Operating System :: OS Independent
|
|
14
15
|
Classifier: Environment :: Web Environment
|
|
@@ -18,11 +19,6 @@ Classifier: License :: OSI Approved :: GNU General Public License (GPL)
|
|
|
18
19
|
Classifier: Development Status :: 5 - Production/Stable
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: cffi
|
|
22
|
-
Requires-Dist: cryptography
|
|
23
|
-
Requires-Dist: pycryptodome
|
|
24
|
-
Requires-Dist: PyYAML
|
|
25
|
-
Requires-Dist: prettytable
|
|
26
22
|
|
|
27
23
|
# mantis-cli
|
|
28
24
|
|
|
@@ -341,3 +337,4 @@ Works as follows:
|
|
|
341
337
|
## Release notes
|
|
342
338
|
|
|
343
339
|
Mantis uses semantic versioning. See more in [changelog](https://github.com/PragmaticMates/mantis-cli/blob/master/CHANGES.md).
|
|
340
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
VERSION = '20.0.0'
|
|
@@ -2,12 +2,18 @@
|
|
|
2
2
|
import os
|
|
3
3
|
import sys
|
|
4
4
|
import inspect
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
from rich.text import Text
|
|
6
9
|
|
|
7
10
|
from mantis import VERSION
|
|
8
|
-
from mantis.helpers import
|
|
11
|
+
from mantis.helpers import CLI, nested_set
|
|
9
12
|
from mantis.logic import get_manager, execute
|
|
10
|
-
from mantis.managers import AbstractManager
|
|
13
|
+
from mantis.managers import AbstractManager, BaseManager
|
|
14
|
+
from mantis.extensions.django import Django
|
|
15
|
+
from mantis.extensions.nginx import Nginx
|
|
16
|
+
from mantis.extensions.postgres import Postgres
|
|
11
17
|
|
|
12
18
|
|
|
13
19
|
def parse_args(arguments):
|
|
@@ -43,6 +49,9 @@ def run():
|
|
|
43
49
|
if params['commands'] == ['--version']:
|
|
44
50
|
return print(version_info)
|
|
45
51
|
|
|
52
|
+
if params['commands'] == ['--help']:
|
|
53
|
+
return help()
|
|
54
|
+
|
|
46
55
|
# get params
|
|
47
56
|
environment_id = params['environment_id']
|
|
48
57
|
commands = params['commands']
|
|
@@ -51,9 +60,6 @@ def run():
|
|
|
51
60
|
# get manager
|
|
52
61
|
manager = get_manager(environment_id, mode)
|
|
53
62
|
|
|
54
|
-
if params['commands'] == ['--help']:
|
|
55
|
-
return help(manager)
|
|
56
|
-
|
|
57
63
|
if len(params['commands']) == 0:
|
|
58
64
|
CLI.error('Missing commands. Check mantis --help for more information.')
|
|
59
65
|
|
|
@@ -75,25 +81,24 @@ def run():
|
|
|
75
81
|
value=value
|
|
76
82
|
)
|
|
77
83
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
if manager.connection and manager.host:
|
|
81
|
-
host_intro = f'{Colors.RED}{manager.host}{Colors.ENDC}, '
|
|
82
|
-
else:
|
|
83
|
-
host_intro = ''
|
|
84
|
-
|
|
85
|
-
heading = f'{version_info}, '\
|
|
86
|
-
f'{environment_intro}'\
|
|
87
|
-
f'{host_intro}'\
|
|
88
|
-
f'mode: {Colors.GREEN}{manager.mode}{Colors.ENDC}, '\
|
|
89
|
-
f'hostname: {Colors.BLUE}{hostname}{Colors.ENDC}'
|
|
84
|
+
console = Console()
|
|
90
85
|
|
|
91
|
-
|
|
86
|
+
heading = Text.assemble(
|
|
87
|
+
version_info, ", ",
|
|
88
|
+
("Environment ID = ", "") if manager.environment_id else ("(single connection mode), ", "bold") if manager.single_connection_mode else ("", ""),
|
|
89
|
+
(str(manager.environment_id) + ", ", "bold") if manager.environment_id else ("", ""),
|
|
90
|
+
(str(manager.host) + ", ", "red") if manager.connection and manager.host else ("", ""),
|
|
91
|
+
"mode: ", (str(manager.mode), "green"),
|
|
92
|
+
", hostname: ", (hostname, "blue")
|
|
93
|
+
)
|
|
94
|
+
console.print(heading)
|
|
92
95
|
|
|
93
96
|
if mode == 'ssh':
|
|
97
|
+
# Build mantis command - environment_id is optional in single connection mode
|
|
98
|
+
env_part = f'{environment_id} ' if environment_id else ''
|
|
94
99
|
cmds = [
|
|
95
100
|
f'cd {manager.project_path}',
|
|
96
|
-
f'mantis {
|
|
101
|
+
f'mantis {env_part}--mode=host {" ".join(commands)}'
|
|
97
102
|
]
|
|
98
103
|
cmd = ';'.join(cmds)
|
|
99
104
|
exec = f"ssh -t {manager.user}@{manager.host} -p {manager.port} '{cmd}'"
|
|
@@ -109,31 +114,19 @@ def run():
|
|
|
109
114
|
|
|
110
115
|
execute(manager, command, params)
|
|
111
116
|
|
|
112
|
-
def
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
host \truns mantis on host machine directly without invoking connection (used as proxy for ssh mode)')
|
|
120
|
-
|
|
121
|
-
print(f'\nEnvironment:\n\
|
|
122
|
-
Either "local" or any custom environment identifier defined as connection in your config file.')
|
|
123
|
-
|
|
124
|
-
print(f'\nCommands:')
|
|
125
|
-
|
|
126
|
-
table = prettytable.PrettyTable(align='l')
|
|
127
|
-
table.set_style(prettytable.SINGLE_BORDER)
|
|
128
|
-
table.field_names = ["Command", "Description"]
|
|
117
|
+
def get_class_commands(cls, exclude_from=None):
|
|
118
|
+
"""
|
|
119
|
+
Extract commands from a class for help display.
|
|
120
|
+
Returns list of tuples: (command_str, description)
|
|
121
|
+
"""
|
|
122
|
+
commands = []
|
|
123
|
+
exclude_methods = dir(exclude_from) if exclude_from else []
|
|
129
124
|
|
|
130
|
-
|
|
131
|
-
methods = inspect.getmembers(manager, predicate=inspect.ismethod)
|
|
125
|
+
methods = inspect.getmembers(cls, predicate=inspect.isfunction)
|
|
132
126
|
|
|
133
|
-
# Iterate over each method
|
|
134
127
|
for method_name, method in methods:
|
|
135
|
-
# skip methods
|
|
136
|
-
if method_name in
|
|
128
|
+
# skip private methods and excluded methods
|
|
129
|
+
if method_name.startswith('_') or method_name in exclude_methods:
|
|
137
130
|
continue
|
|
138
131
|
|
|
139
132
|
command = method_name.replace('_', '-')
|
|
@@ -141,33 +134,82 @@ def help(manager):
|
|
|
141
134
|
# Get the method signature
|
|
142
135
|
signature = inspect.signature(method)
|
|
143
136
|
|
|
144
|
-
# Parameters
|
|
145
|
-
parameters =
|
|
137
|
+
# Parameters (skip 'self')
|
|
138
|
+
parameters = [p for p in signature.parameters.keys() if p != 'self']
|
|
146
139
|
|
|
147
140
|
# Check if parameters are optional
|
|
148
141
|
params_are_optional = True
|
|
149
142
|
|
|
150
143
|
for param_name, param in signature.parameters.items():
|
|
151
|
-
if
|
|
144
|
+
if param_name == 'self':
|
|
145
|
+
continue
|
|
146
|
+
if param.default == inspect.Parameter.empty:
|
|
152
147
|
params_are_optional = False
|
|
153
148
|
|
|
154
|
-
#
|
|
149
|
+
# Build command string
|
|
155
150
|
command = f"--{command}"
|
|
156
|
-
|
|
151
|
+
params_str = ""
|
|
157
152
|
|
|
158
|
-
if
|
|
153
|
+
if parameters:
|
|
159
154
|
if not params_are_optional:
|
|
160
|
-
|
|
155
|
+
params_str += '['
|
|
161
156
|
|
|
162
|
-
|
|
157
|
+
params_str += ':'
|
|
163
158
|
|
|
164
|
-
|
|
159
|
+
params_str += ','.join(parameters)
|
|
165
160
|
|
|
166
161
|
if not params_are_optional:
|
|
167
|
-
|
|
162
|
+
params_str += ']'
|
|
168
163
|
|
|
169
164
|
docs = method.__doc__ or ''
|
|
170
165
|
|
|
171
|
-
|
|
166
|
+
commands.append((f"{command}{params_str}", docs.strip()))
|
|
167
|
+
|
|
168
|
+
return commands
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def help():
|
|
172
|
+
print(f'\nUsage:\n\
|
|
173
|
+
mantis [--mode=remote|ssh|host] [environment] --command[:params]')
|
|
174
|
+
|
|
175
|
+
print('\nModes:\n\
|
|
176
|
+
remote \truns commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default)\n\
|
|
177
|
+
ssh \tconnects to host via ssh and run all mantis commands on remote machine directly (mantis-cli needs to be installed on server)\n\
|
|
178
|
+
host \truns mantis on host machine directly without invoking connection (used as proxy for ssh mode)')
|
|
179
|
+
|
|
180
|
+
print(f'\nEnvironment:\n\
|
|
181
|
+
Either "local" or any custom environment identifier defined as connection in your config file.\n\
|
|
182
|
+
Optional when using single connection mode (config has "connection" instead of "connections").')
|
|
183
|
+
|
|
184
|
+
console = Console()
|
|
172
185
|
|
|
173
|
-
|
|
186
|
+
# Base commands
|
|
187
|
+
print(f'\nCommands:')
|
|
188
|
+
table = Table(show_header=True, header_style="bold")
|
|
189
|
+
table.add_column("Command", style="cyan")
|
|
190
|
+
table.add_column("Description")
|
|
191
|
+
|
|
192
|
+
for command, description in get_class_commands(BaseManager, exclude_from=AbstractManager):
|
|
193
|
+
table.add_row(command, description)
|
|
194
|
+
|
|
195
|
+
console.print(table)
|
|
196
|
+
|
|
197
|
+
# Extension commands
|
|
198
|
+
extensions = [
|
|
199
|
+
('Django', Django),
|
|
200
|
+
('Nginx', Nginx),
|
|
201
|
+
('Postgres', Postgres),
|
|
202
|
+
]
|
|
203
|
+
|
|
204
|
+
for ext_name, ext_class in extensions:
|
|
205
|
+
ext_commands = get_class_commands(ext_class)
|
|
206
|
+
if ext_commands:
|
|
207
|
+
print(f'\n{ext_name} extension:')
|
|
208
|
+
ext_table = Table(show_header=True, header_style="bold")
|
|
209
|
+
ext_table.add_column("Command", style="yellow")
|
|
210
|
+
ext_table.add_column("Description")
|
|
211
|
+
|
|
212
|
+
for command, description in ext_commands:
|
|
213
|
+
ext_table.add_row(command, description)
|
|
214
|
+
|
|
215
|
+
console.print(ext_table)
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import os
|
|
2
2
|
|
|
3
|
-
from mantis.helpers import CLI
|
|
3
|
+
from mantis.helpers import CLI
|
|
4
4
|
|
|
5
5
|
|
|
6
6
|
class Environment(object):
|
|
7
|
-
def __init__(self, environment_id, folder):
|
|
7
|
+
def __init__(self, environment_id, folder, single_mode=False):
|
|
8
8
|
self.id = environment_id
|
|
9
9
|
self.folder = folder
|
|
10
|
+
self.single_mode = single_mode
|
|
10
11
|
|
|
11
|
-
if self.
|
|
12
|
+
if self.single_mode:
|
|
13
|
+
self.setup_single_mode()
|
|
14
|
+
elif self.id:
|
|
12
15
|
self.setup()
|
|
13
16
|
|
|
14
17
|
def setup(self):
|
|
@@ -30,6 +33,31 @@ class Environment(object):
|
|
|
30
33
|
self.files = list(map(lambda x: os.path.join(dirpath, x), environment_filenames))
|
|
31
34
|
self.encrypted_files = list(map(lambda x: os.path.join(dirpath, x), encrypted_environment_filenames))
|
|
32
35
|
|
|
36
|
+
def setup_single_mode(self):
|
|
37
|
+
"""
|
|
38
|
+
Setup for single connection mode: look for env files directly in the folder
|
|
39
|
+
instead of environment subfolders
|
|
40
|
+
"""
|
|
41
|
+
self.path = self.folder
|
|
42
|
+
|
|
43
|
+
if not os.path.exists(self.path):
|
|
44
|
+
CLI.warning(f"Environment path '{self.path}' does not exist")
|
|
45
|
+
self.files = []
|
|
46
|
+
self.encrypted_files = []
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
if not os.path.isdir(self.path):
|
|
50
|
+
CLI.error(f"Environment path '{self.path}' is not directory")
|
|
51
|
+
|
|
52
|
+
CLI.info(f"Found environment path (single mode): '{self.path}'")
|
|
53
|
+
|
|
54
|
+
# Look for env files directly in the folder (not in subdirectories)
|
|
55
|
+
files = os.listdir(self.path)
|
|
56
|
+
environment_filenames = list(filter(lambda f: f.endswith('.env') and not f.endswith('.encrypted'), files))
|
|
57
|
+
encrypted_environment_filenames = list(filter(lambda f: f.endswith('.env.encrypted'), files))
|
|
58
|
+
self.files = list(map(lambda x: os.path.join(self.path, x), environment_filenames))
|
|
59
|
+
self.encrypted_files = list(map(lambda x: os.path.join(self.path, x), encrypted_environment_filenames))
|
|
60
|
+
|
|
33
61
|
def _get_path(self, id):
|
|
34
62
|
possible_folder_names = [f'.{id}', id]
|
|
35
63
|
possible_folders = list(map(lambda x: os.path.normpath(os.path.join(self.folder, x)), possible_folder_names))
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
from rich.console import Console
|
|
2
|
+
from rich.text import Text
|
|
3
|
+
|
|
4
|
+
# Shared console instance
|
|
5
|
+
_console = Console()
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CLI(object):
|
|
9
|
+
@staticmethod
|
|
10
|
+
def _print(text, style, end='\n'):
|
|
11
|
+
styled_text = Text(str(text), style=style)
|
|
12
|
+
_console.print(styled_text, end=end)
|
|
13
|
+
|
|
14
|
+
@staticmethod
|
|
15
|
+
def error(text):
|
|
16
|
+
styled_text = Text(str(text), style='red')
|
|
17
|
+
_console.print(styled_text)
|
|
18
|
+
exit(1)
|
|
19
|
+
|
|
20
|
+
@staticmethod
|
|
21
|
+
def bold(text, end='\n'):
|
|
22
|
+
return CLI._print(text=text, style='bold', end=end)
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def info(text, end='\n'):
|
|
26
|
+
return CLI._print(text=text, style='blue', end=end)
|
|
27
|
+
|
|
28
|
+
@staticmethod
|
|
29
|
+
def pink(text, end='\n'):
|
|
30
|
+
return CLI._print(text=text, style='magenta', end=end)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def success(text, end='\n'):
|
|
34
|
+
return CLI._print(text=text, style='green', end=end)
|
|
35
|
+
|
|
36
|
+
@staticmethod
|
|
37
|
+
def warning(text, end='\n'):
|
|
38
|
+
return CLI._print(text=text, style='yellow', end=end)
|
|
39
|
+
|
|
40
|
+
@staticmethod
|
|
41
|
+
def danger(text, end='\n'):
|
|
42
|
+
return CLI._print(text=text, style='red', end=end)
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def underline(text, end='\n'):
|
|
46
|
+
return CLI._print(text=text, style='underline', end=end)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def step(index, total, text, end='\n'):
|
|
50
|
+
return CLI._print(text=f'[{index}/{total}] {text}', style='yellow', end=end)
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def link(uri, label=None):
|
|
54
|
+
if label is None:
|
|
55
|
+
label = uri
|
|
56
|
+
return f'[link={uri}]{label}[/link]'
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def nested_set(dic, keys, value):
|
|
60
|
+
for key in keys[:-1]:
|
|
61
|
+
dic = dic.setdefault(key, {})
|
|
62
|
+
dic[keys[-1]] = value
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def import_string(path):
|
|
66
|
+
components = path.split('.')
|
|
67
|
+
mod = __import__('.'.join(components[0:-1]), globals(), locals(), [components[-1]])
|
|
68
|
+
return getattr(mod, components[-1])
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def random_string(n=10):
|
|
72
|
+
import random
|
|
73
|
+
import string
|
|
74
|
+
|
|
75
|
+
chars = string.ascii_lowercase + string.ascii_uppercase + string.digits
|
|
76
|
+
return ''.join(random.choice(chars) for _ in range(n))
|
|
77
|
+
|
|
78
|
+
def merge_json(obj1, obj2):
|
|
79
|
+
# Base case: if both values are dictionaries, merge recursively
|
|
80
|
+
if isinstance(obj1, dict) and isinstance(obj2, dict):
|
|
81
|
+
merged = {}
|
|
82
|
+
for key in obj1.keys() | obj2.keys(): # Union of both sets of keys
|
|
83
|
+
if key in obj1 and key in obj2:
|
|
84
|
+
merged[key] = merge_json(obj1[key], obj2[key])
|
|
85
|
+
elif key in obj1:
|
|
86
|
+
merged[key] = obj1[key]
|
|
87
|
+
else:
|
|
88
|
+
merged[key] = obj2[key]
|
|
89
|
+
return merged
|
|
90
|
+
# If both are lists, combine them
|
|
91
|
+
elif isinstance(obj1, list) and isinstance(obj2, list):
|
|
92
|
+
return obj1 + obj2
|
|
93
|
+
# If both values are not dicts or lists, return value from obj2
|
|
94
|
+
else:
|
|
95
|
+
if obj1 == obj2:
|
|
96
|
+
return obj1
|
|
97
|
+
else:
|
|
98
|
+
raise ValueError(f'Trying to merge objects: {obj1} and {obj2}')
|
|
@@ -2,7 +2,9 @@ import os
|
|
|
2
2
|
import json
|
|
3
3
|
from json.decoder import JSONDecodeError
|
|
4
4
|
from os.path import dirname, normpath, abspath
|
|
5
|
-
|
|
5
|
+
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
6
8
|
|
|
7
9
|
from mantis.helpers import CLI, import_string
|
|
8
10
|
|
|
@@ -37,23 +39,36 @@ def find_config(environment_id=None):
|
|
|
37
39
|
# Multiple mantis files found
|
|
38
40
|
CLI.info(f'Found {total_mantis_files} mantis.json files:')
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
table
|
|
42
|
+
console = Console()
|
|
43
|
+
table = Table(show_header=True, header_style="bold")
|
|
44
|
+
table.add_column("#", style="cyan")
|
|
45
|
+
table.add_column("Path")
|
|
46
|
+
table.add_column("Connections")
|
|
42
47
|
|
|
43
48
|
for index, path in enumerate(paths):
|
|
44
49
|
config = load_config(path)
|
|
45
|
-
connections = config.get('connections', {}).keys()
|
|
46
50
|
|
|
47
|
-
#
|
|
51
|
+
# Check for single connection mode
|
|
52
|
+
single_connection = config.get('connection')
|
|
53
|
+
|
|
54
|
+
if single_connection:
|
|
55
|
+
# Single connection mode - display the connection string
|
|
56
|
+
connections_display = '[green](single)[/green]'
|
|
57
|
+
else:
|
|
58
|
+
# Multi-environment mode - display connection keys
|
|
59
|
+
connections = config.get('connections', {}).keys()
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
for connection in connections:
|
|
51
|
-
color = 'success' if connection == environment_id else 'warning'
|
|
52
|
-
colorful_connections.append(getattr(CLI, color)(connection, end='', return_value=True))
|
|
61
|
+
# TODO: get project names from compose files
|
|
53
62
|
|
|
54
|
-
|
|
63
|
+
colorful_connections = []
|
|
64
|
+
for connection in connections:
|
|
65
|
+
color = 'green' if connection == environment_id else 'yellow'
|
|
66
|
+
colorful_connections.append(f'[{color}]{connection}[/{color}]')
|
|
67
|
+
connections_display = ', '.join(colorful_connections)
|
|
55
68
|
|
|
56
|
-
|
|
69
|
+
table.add_row(str(index + 1), normpath(dirname(path)), connections_display)
|
|
70
|
+
|
|
71
|
+
console.print(table)
|
|
57
72
|
CLI.danger(f'[0] Exit now and define $MANTIS_CONFIG environment variable')
|
|
58
73
|
|
|
59
74
|
path_index = None
|
|
@@ -92,8 +107,11 @@ def find_keys_only_in_config(config, template, parent_key=""):
|
|
|
92
107
|
|
|
93
108
|
def load_config(config_file):
|
|
94
109
|
if not os.path.exists(config_file):
|
|
95
|
-
CLI.warning(f'File {config_file} does not exist.
|
|
96
|
-
|
|
110
|
+
CLI.warning(f'File {config_file} does not exist.')
|
|
111
|
+
CLI.danger(f'Mantis config not found. Double check your current working directory.')
|
|
112
|
+
exit()
|
|
113
|
+
# CLI.warning(f'File {config_file} does not exist. Returning empty config')
|
|
114
|
+
# return {}
|
|
97
115
|
|
|
98
116
|
with open(config_file, "r") as config:
|
|
99
117
|
try:
|
|
@@ -191,7 +209,8 @@ def execute(manager, command, params):
|
|
|
191
209
|
else:
|
|
192
210
|
methods_without_environment = ['contexts', 'create_context', 'check_config', 'generate_key', 'read_key']
|
|
193
211
|
|
|
194
|
-
|
|
212
|
+
# In single connection mode, environment_id is not required
|
|
213
|
+
if manager.environment_id is None and not manager.single_connection_mode and manager_method not in methods_without_environment:
|
|
195
214
|
CLI.error('Missing environment')
|
|
196
215
|
elif manager.environment_id is not None and manager_method in methods_without_environment:
|
|
197
216
|
CLI.error('Redundant environment')
|
|
@@ -8,9 +8,12 @@ from os import path
|
|
|
8
8
|
from os.path import normpath
|
|
9
9
|
from time import sleep
|
|
10
10
|
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
11
14
|
from mantis.crypto import Crypto
|
|
12
15
|
from mantis.environment import Environment
|
|
13
|
-
from mantis.helpers import CLI,
|
|
16
|
+
from mantis.helpers import CLI, merge_json
|
|
14
17
|
from mantis.logic import find_config, load_config, check_config, load_template_config
|
|
15
18
|
|
|
16
19
|
|
|
@@ -62,7 +65,8 @@ class AbstractManager(object):
|
|
|
62
65
|
|
|
63
66
|
@property
|
|
64
67
|
def connection_details(self):
|
|
65
|
-
|
|
68
|
+
# In single connection mode, env.id is None but we still have a connection
|
|
69
|
+
if not self.single_connection_mode and self.env.id is None:
|
|
66
70
|
return None
|
|
67
71
|
|
|
68
72
|
property_name = '_connection_details'
|
|
@@ -75,7 +79,7 @@ class AbstractManager(object):
|
|
|
75
79
|
if hasattr(self, property_name):
|
|
76
80
|
return getattr(self, property_name)
|
|
77
81
|
|
|
78
|
-
if 'local' in self.env.id:
|
|
82
|
+
if self.env.id and 'local' in self.env.id:
|
|
79
83
|
details = {
|
|
80
84
|
'host': 'localhost',
|
|
81
85
|
'user': None,
|
|
@@ -109,12 +113,14 @@ class AbstractManager(object):
|
|
|
109
113
|
|
|
110
114
|
@property
|
|
111
115
|
def docker_connection(self):
|
|
112
|
-
|
|
116
|
+
# In single connection mode or when env.id contains 'local', no extra connection needed
|
|
117
|
+
if not self.single_connection_mode and (self.env.id is None or 'local' in self.env.id):
|
|
113
118
|
return ''
|
|
114
119
|
|
|
115
120
|
if self.mode == 'remote':
|
|
116
121
|
if self.connection is None:
|
|
117
|
-
|
|
122
|
+
env_info = f' for environment {self.env.id}' if self.env.id else ''
|
|
123
|
+
CLI.error(f'Connection{env_info} not defined!')
|
|
118
124
|
if self.connection.startswith('ssh://'):
|
|
119
125
|
return f'DOCKER_HOST="{self.connection}"'
|
|
120
126
|
elif self.connection.startswith('context://'):
|
|
@@ -139,11 +145,59 @@ class AbstractManager(object):
|
|
|
139
145
|
# Save merged config to variable
|
|
140
146
|
self.config = defaults.copy()
|
|
141
147
|
|
|
148
|
+
# Detect single connection mode (connection string instead of connections dict)
|
|
149
|
+
has_single_connection = self.config.get('connection') is not None
|
|
150
|
+
has_multiple_connections = bool(self.config.get('connections', {}))
|
|
151
|
+
|
|
152
|
+
# Validate: only one of connection or connections should be defined
|
|
153
|
+
if has_single_connection and has_multiple_connections:
|
|
154
|
+
CLI.error('Config error: Cannot define both "connection" and "connections". Use either single connection mode or named environments, not both.')
|
|
155
|
+
|
|
156
|
+
self.single_connection_mode = has_single_connection
|
|
157
|
+
|
|
158
|
+
# Validate: environment_id should not be provided in single connection mode
|
|
159
|
+
if self.single_connection_mode and self.environment_id:
|
|
160
|
+
CLI.error(f'Config error: Environment "{self.environment_id}" was provided, but config uses single connection mode. Remove the environment argument or switch to named environments using "connections".')
|
|
161
|
+
|
|
142
162
|
self.key_file = normalize(path.join(self.config['encryption']['folder'], 'mantis.key'))
|
|
143
163
|
self.environment_path = normalize(self.config['environment']['folder'])
|
|
144
|
-
|
|
164
|
+
|
|
165
|
+
if self.single_connection_mode:
|
|
166
|
+
# In single connection mode, compose files are directly in compose folder
|
|
167
|
+
self.compose_path = normalize(self.config['compose']['folder'])
|
|
168
|
+
elif self.environment_id:
|
|
169
|
+
self.compose_path = normalize(path.join(self.config['compose']['folder'], self.environment_id))
|
|
145
170
|
|
|
146
171
|
def init_environment(self):
|
|
172
|
+
if self.single_connection_mode:
|
|
173
|
+
# Single connection mode: no environment_id required
|
|
174
|
+
self.env = Environment(
|
|
175
|
+
environment_id=None,
|
|
176
|
+
folder=self.environment_path,
|
|
177
|
+
single_mode=True,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
# connection from single 'connection' key
|
|
181
|
+
self.connection = self.config.get('connection')
|
|
182
|
+
|
|
183
|
+
# compose files directly in compose folder
|
|
184
|
+
compose_file_paths = os.popen(f'find {self.compose_path} -maxdepth 1 -name "*.yml" -o -name "*.yaml"').read().strip().split('\n')
|
|
185
|
+
|
|
186
|
+
# Remove empty strings
|
|
187
|
+
self.compose_files = list(filter(None, compose_file_paths))
|
|
188
|
+
|
|
189
|
+
# Read compose files
|
|
190
|
+
self.compose_config = self.read_compose_configs()
|
|
191
|
+
return
|
|
192
|
+
|
|
193
|
+
if not self.environment_id:
|
|
194
|
+
self.env = Environment(
|
|
195
|
+
environment_id=None,
|
|
196
|
+
folder=self.environment_path,
|
|
197
|
+
)
|
|
198
|
+
self.connection = None
|
|
199
|
+
return
|
|
200
|
+
|
|
147
201
|
self.env = Environment(
|
|
148
202
|
environment_id=self.environment_id,
|
|
149
203
|
folder=self.environment_path,
|
|
@@ -161,6 +215,30 @@ class AbstractManager(object):
|
|
|
161
215
|
# Read compose files
|
|
162
216
|
self.compose_config = self.read_compose_configs()
|
|
163
217
|
|
|
218
|
+
def are_env_files_in_sync(self, env_file):
|
|
219
|
+
"""
|
|
220
|
+
Checks if .env and .env.encrypted files are in sync.
|
|
221
|
+
Returns True if they match, False otherwise.
|
|
222
|
+
"""
|
|
223
|
+
env_file_encrypted = f'{env_file}.encrypted'
|
|
224
|
+
|
|
225
|
+
# Check if both files exist
|
|
226
|
+
if not os.path.exists(env_file):
|
|
227
|
+
return False
|
|
228
|
+
if not os.path.exists(env_file_encrypted):
|
|
229
|
+
return False
|
|
230
|
+
|
|
231
|
+
try:
|
|
232
|
+
decrypted_environment = self.decrypt_env(env_file=env_file, return_value=True)
|
|
233
|
+
loaded_environment = self.env.load(env_file)
|
|
234
|
+
|
|
235
|
+
if decrypted_environment is None or loaded_environment is None:
|
|
236
|
+
return False
|
|
237
|
+
|
|
238
|
+
return loaded_environment == decrypted_environment
|
|
239
|
+
except Exception:
|
|
240
|
+
return False
|
|
241
|
+
|
|
164
242
|
def check_environment_encryption(self, env_file):
|
|
165
243
|
decrypted_environment = self.decrypt_env(env_file=env_file, return_value=True) # .env.encrypted
|
|
166
244
|
loaded_environment = self.env.load(env_file) # .env
|
|
@@ -339,9 +417,15 @@ class BaseManager(AbstractManager):
|
|
|
339
417
|
|
|
340
418
|
return values if return_value else None
|
|
341
419
|
|
|
342
|
-
CLI.info(f'Encrypting environment file {env_file}...')
|
|
343
420
|
env_file_encrypted = f'{env_file}.encrypted'
|
|
344
421
|
|
|
422
|
+
# Skip if files are already in sync (unless return_value is True, which is used for internal checks)
|
|
423
|
+
if not return_value and self.are_env_files_in_sync(env_file):
|
|
424
|
+
CLI.success(f'Skipping {env_file} - already in sync with {env_file_encrypted}')
|
|
425
|
+
return None
|
|
426
|
+
|
|
427
|
+
CLI.info(f'Encrypting environment file {env_file}...')
|
|
428
|
+
|
|
345
429
|
if not self.KEY:
|
|
346
430
|
CLI.error('Missing mantis key! (%s)' % self.key_file)
|
|
347
431
|
|
|
@@ -402,6 +486,11 @@ class BaseManager(AbstractManager):
|
|
|
402
486
|
|
|
403
487
|
env_file_encrypted = f'{env_file}.encrypted'
|
|
404
488
|
|
|
489
|
+
# Skip if files are already in sync (unless return_value is True, which is used for internal checks)
|
|
490
|
+
if not return_value and self.are_env_files_in_sync(env_file):
|
|
491
|
+
CLI.success(f'Skipping {env_file_encrypted} - already in sync with {env_file}')
|
|
492
|
+
return None
|
|
493
|
+
|
|
405
494
|
if not return_value:
|
|
406
495
|
CLI.info(f'Decrypting environment file {env_file_encrypted}...')
|
|
407
496
|
|
|
@@ -608,7 +697,8 @@ class BaseManager(AbstractManager):
|
|
|
608
697
|
if container not in self.get_containers():
|
|
609
698
|
CLI.error(f"Container {container} not found")
|
|
610
699
|
|
|
611
|
-
|
|
700
|
+
console = Console()
|
|
701
|
+
console.print(f'[blue]Health-checking [yellow]{container}[/yellow]...[/blue]')
|
|
612
702
|
|
|
613
703
|
if self.has_healthcheck(container):
|
|
614
704
|
healthcheck_config = self.get_healthcheck_config(container)
|
|
@@ -618,8 +708,8 @@ class BaseManager(AbstractManager):
|
|
|
618
708
|
interval = healthcheck_interval / coeficient
|
|
619
709
|
retries = healthcheck_retries * coeficient
|
|
620
710
|
|
|
621
|
-
|
|
622
|
-
|
|
711
|
+
console.print(f'[blue]Interval: [dim]{healthcheck_interval}[/dim] s -> [yellow]{interval} s[/yellow][/blue]')
|
|
712
|
+
console.print(f'[blue]Retries: [dim]{healthcheck_retries}[/dim] -> [yellow]{retries}[/yellow][/blue]')
|
|
623
713
|
|
|
624
714
|
start = time.time()
|
|
625
715
|
|
|
@@ -627,13 +717,13 @@ class BaseManager(AbstractManager):
|
|
|
627
717
|
is_healthy, status = self.check_health(container)
|
|
628
718
|
|
|
629
719
|
if is_healthy:
|
|
630
|
-
print(f"#{retry + 1}/{retries}: Status of '{container}' is {
|
|
720
|
+
console.print(f"#{retry + 1}/{retries}: Status of '{container}' is [green]{status}[/green].")
|
|
631
721
|
end = time.time()
|
|
632
722
|
loading_time = end - start
|
|
633
|
-
print(f'Container {
|
|
723
|
+
console.print(f'Container [yellow]{container}[/yellow] took [blue underline]{loading_time} s[/blue underline] to become healthy')
|
|
634
724
|
return True
|
|
635
725
|
else:
|
|
636
|
-
print(f"#{retry + 1}/{retries}: Status of '{container}' is {
|
|
726
|
+
console.print(f"#{retry + 1}/{retries}: Status of '{container}' is [red]{status}[/red].")
|
|
637
727
|
|
|
638
728
|
if retries > 1:
|
|
639
729
|
sleep(interval)
|
|
@@ -1083,14 +1173,73 @@ class BaseManager(AbstractManager):
|
|
|
1083
1173
|
"""
|
|
1084
1174
|
Prints images and containers
|
|
1085
1175
|
"""
|
|
1176
|
+
console = Console()
|
|
1177
|
+
|
|
1086
1178
|
CLI.info('Getting status...')
|
|
1087
1179
|
steps = 2
|
|
1088
1180
|
|
|
1089
1181
|
CLI.step(1, steps, 'List of Docker images')
|
|
1090
|
-
self.docker(
|
|
1182
|
+
images_output = self.docker('image ls --format "{{.Repository}}\t{{.Tag}}\t{{.ID}}\t{{.CreatedSince}}\t{{.Size}}"', return_output=True)
|
|
1183
|
+
|
|
1184
|
+
if images_output.strip():
|
|
1185
|
+
images_table = Table(show_header=True, header_style="bold")
|
|
1186
|
+
images_table.add_column("REPOSITORY", style="cyan")
|
|
1187
|
+
images_table.add_column("TAG", style="yellow")
|
|
1188
|
+
images_table.add_column("IMAGE ID", style="bright_blue")
|
|
1189
|
+
images_table.add_column("CREATED", style="magenta")
|
|
1190
|
+
images_table.add_column("SIZE", style="green")
|
|
1191
|
+
|
|
1192
|
+
for line in images_output.strip().split('\n'):
|
|
1193
|
+
parts = line.split('\t')
|
|
1194
|
+
if len(parts) >= 5:
|
|
1195
|
+
repo, tag, image_id, created, size = parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
1196
|
+
images_table.add_row(repo, tag, image_id, created, size)
|
|
1197
|
+
|
|
1198
|
+
console.print(images_table)
|
|
1091
1199
|
|
|
1092
1200
|
CLI.step(2, steps, 'Docker containers')
|
|
1093
|
-
self.docker(
|
|
1201
|
+
containers_output = self.docker('container ls -a --format "{{.Names}}\t{{.Status}}\t{{.Image}}\t{{.Ports}}\t{{.Size}}"', return_output=True)
|
|
1202
|
+
|
|
1203
|
+
if containers_output.strip():
|
|
1204
|
+
containers_table = Table(show_header=True, header_style="bold")
|
|
1205
|
+
containers_table.add_column("NAME", style="blue")
|
|
1206
|
+
containers_table.add_column("STATUS")
|
|
1207
|
+
containers_table.add_column("IMAGE", style="magenta")
|
|
1208
|
+
containers_table.add_column("PORTS")
|
|
1209
|
+
containers_table.add_column("SIZE", style="dark_orange")
|
|
1210
|
+
|
|
1211
|
+
for line in containers_output.strip().split('\n'):
|
|
1212
|
+
parts = line.split('\t')
|
|
1213
|
+
if len(parts) >= 5:
|
|
1214
|
+
name, status, image, ports, size = parts[0], parts[1], parts[2], parts[3], parts[4]
|
|
1215
|
+
|
|
1216
|
+
# Colorize status based on state
|
|
1217
|
+
if 'Up' in status:
|
|
1218
|
+
status_colored = f'[green]{status}[/green]'
|
|
1219
|
+
elif 'Exited' in status:
|
|
1220
|
+
status_colored = f'[red]{status}[/red]'
|
|
1221
|
+
elif 'Created' in status:
|
|
1222
|
+
status_colored = f'[yellow]{status}[/yellow]'
|
|
1223
|
+
elif 'Paused' in status:
|
|
1224
|
+
status_colored = f'[yellow]{status}[/yellow]'
|
|
1225
|
+
else:
|
|
1226
|
+
status_colored = status
|
|
1227
|
+
|
|
1228
|
+
# Split ports into multiple lines with different colors for IPv4/IPv6
|
|
1229
|
+
ports_list = ports.split(', ') if ports else ['']
|
|
1230
|
+
colored_ports = []
|
|
1231
|
+
for port in ports_list:
|
|
1232
|
+
if '::' in port or '[' in port:
|
|
1233
|
+
# IPv6 port
|
|
1234
|
+
colored_ports.append(f'[bright_white]{port}[/bright_white]')
|
|
1235
|
+
else:
|
|
1236
|
+
# IPv4 port
|
|
1237
|
+
colored_ports.append(f'[cyan]{port}[/cyan]')
|
|
1238
|
+
ports_formatted = '\n'.join(colored_ports)
|
|
1239
|
+
|
|
1240
|
+
containers_table.add_row(name, status_colored, image, ports_formatted, size)
|
|
1241
|
+
|
|
1242
|
+
console.print(containers_table)
|
|
1094
1243
|
|
|
1095
1244
|
def networks(self):
|
|
1096
1245
|
"""
|
|
@@ -1161,6 +1310,14 @@ class BaseManager(AbstractManager):
|
|
|
1161
1310
|
"""
|
|
1162
1311
|
container, command = params.split(' ', maxsplit=1)
|
|
1163
1312
|
CLI.info(f'Executing command "{command}" in container {container}...')
|
|
1313
|
+
self.docker(f'exec {container} {command}')
|
|
1314
|
+
|
|
1315
|
+
def exec_it(self, params):
|
|
1316
|
+
"""
|
|
1317
|
+
Executes command in container using interactive pseudo-TTY
|
|
1318
|
+
"""
|
|
1319
|
+
container, command = params.split(' ', maxsplit=1)
|
|
1320
|
+
CLI.info(f'Executing command "{command}" in container {container}...')
|
|
1164
1321
|
self.docker(f'exec -it {container} {command}')
|
|
1165
1322
|
|
|
1166
1323
|
def get_healthcheck_config(self, container):
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: mantis-cli
|
|
3
|
-
Version:
|
|
3
|
+
Version: 20.0.0
|
|
4
4
|
Summary: Management command to build and deploy webapps, especially based on Django
|
|
5
5
|
Home-page: https://github.com/PragmaticMates/mantis-cli
|
|
6
6
|
Author: Erik Telepovský
|
|
@@ -9,6 +9,7 @@ Maintainer: Pragmatic Mates
|
|
|
9
9
|
Maintainer-email: info@pragmaticmates.com
|
|
10
10
|
License: GNU General Public License (GPL)
|
|
11
11
|
Keywords: management deployment docker command
|
|
12
|
+
Platform: UNKNOWN
|
|
12
13
|
Classifier: Programming Language :: Python
|
|
13
14
|
Classifier: Operating System :: OS Independent
|
|
14
15
|
Classifier: Environment :: Web Environment
|
|
@@ -18,11 +19,6 @@ Classifier: License :: OSI Approved :: GNU General Public License (GPL)
|
|
|
18
19
|
Classifier: Development Status :: 5 - Production/Stable
|
|
19
20
|
Description-Content-Type: text/markdown
|
|
20
21
|
License-File: LICENSE
|
|
21
|
-
Requires-Dist: cffi
|
|
22
|
-
Requires-Dist: cryptography
|
|
23
|
-
Requires-Dist: pycryptodome
|
|
24
|
-
Requires-Dist: PyYAML
|
|
25
|
-
Requires-Dist: prettytable
|
|
26
22
|
|
|
27
23
|
# mantis-cli
|
|
28
24
|
|
|
@@ -341,3 +337,4 @@ Works as follows:
|
|
|
341
337
|
## Release notes
|
|
342
338
|
|
|
343
339
|
Mantis uses semantic versioning. See more in [changelog](https://github.com/PragmaticMates/mantis-cli/blob/master/CHANGES.md).
|
|
340
|
+
|
|
@@ -4,7 +4,7 @@ from setuptools import setup, find_packages
|
|
|
4
4
|
from mantis import VERSION
|
|
5
5
|
|
|
6
6
|
setup(
|
|
7
|
-
name='
|
|
7
|
+
name='mantis_cli',
|
|
8
8
|
version=VERSION,
|
|
9
9
|
description='Management command to build and deploy webapps, especially based on Django',
|
|
10
10
|
long_description=open('README.md').read(),
|
|
@@ -16,7 +16,7 @@ setup(
|
|
|
16
16
|
url='https://github.com/PragmaticMates/mantis-cli',
|
|
17
17
|
packages=find_packages(),
|
|
18
18
|
include_package_data=True,
|
|
19
|
-
install_requires=['cffi', 'cryptography', 'pycryptodome', 'PyYAML', '
|
|
19
|
+
install_requires=['cffi', 'cryptography', 'pycryptodome', 'PyYAML', 'rich'],
|
|
20
20
|
entry_points={
|
|
21
21
|
'console_scripts': ['mantis=mantis.command_line:run'],
|
|
22
22
|
},
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
VERSION = '19.1.7'
|
|
@@ -1,131 +0,0 @@
|
|
|
1
|
-
class Colors:
|
|
2
|
-
# https://stackoverflow.com/questions/5947742/how-to-change-the-output-color-of-echo-in-linux
|
|
3
|
-
BLACK = "\033[0;30m"
|
|
4
|
-
BLUE = '\033[94m'
|
|
5
|
-
# BLUE = "\033[0;34m"
|
|
6
|
-
GREEN = '\033[92m'
|
|
7
|
-
# GREEN = "\033[0;32m"
|
|
8
|
-
YELLOW = '\033[93m'
|
|
9
|
-
# YELLOW = "\033[1;33m"
|
|
10
|
-
RED = '\033[91m'
|
|
11
|
-
# RED = "\033[0;31m"
|
|
12
|
-
PINK = '\033[95m'
|
|
13
|
-
BOLD = '\033[1m'
|
|
14
|
-
UNDERLINE = '\033[4m'
|
|
15
|
-
BROWN = "\033[0;33m"
|
|
16
|
-
PURPLE = "\033[0;35m"
|
|
17
|
-
CYAN = "\033[0;36m"
|
|
18
|
-
LIGHT_GRAY = "\033[0;37m"
|
|
19
|
-
DARK_GRAY = "\033[1;30m"
|
|
20
|
-
LIGHT_RED = "\033[1;31m"
|
|
21
|
-
LIGHT_GREEN = "\033[1;32m"
|
|
22
|
-
LIGHT_BLUE = "\033[1;34m"
|
|
23
|
-
LIGHT_PURPLE = "\033[1;35m"
|
|
24
|
-
LIGHT_CYAN = "\033[1;36m"
|
|
25
|
-
LIGHT_WHITE = "\033[1;37m"
|
|
26
|
-
FAINT = "\033[2m"
|
|
27
|
-
ITALIC = "\033[3m"
|
|
28
|
-
BLINK_SLOW = "\033[5m"
|
|
29
|
-
BLINK_FAST = "\033[6m"
|
|
30
|
-
NEGATIVE = "\033[7m"
|
|
31
|
-
CROSSED = "\033[9m"
|
|
32
|
-
RESET = "\033[0m"
|
|
33
|
-
ENDC = '\033[0m'
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
class CLI(object):
|
|
37
|
-
@staticmethod
|
|
38
|
-
def print_or_return(text, color, end='\n', return_value=False):
|
|
39
|
-
s = f'{color}{text}{Colors.ENDC}'
|
|
40
|
-
if return_value:
|
|
41
|
-
return f'{s}{end}'
|
|
42
|
-
print(s, end=end)
|
|
43
|
-
|
|
44
|
-
@staticmethod
|
|
45
|
-
def error(text):
|
|
46
|
-
exit(f'{Colors.RED}{text}{Colors.ENDC}')
|
|
47
|
-
|
|
48
|
-
@staticmethod
|
|
49
|
-
def bold(text, end='\n', return_value=False):
|
|
50
|
-
return CLI.print_or_return(text=text, color=Colors.BOLD, end=end, return_value=return_value)
|
|
51
|
-
|
|
52
|
-
@staticmethod
|
|
53
|
-
def info(text, end='\n', return_value=False):
|
|
54
|
-
return CLI.print_or_return(text=text, color=Colors.BLUE, end=end, return_value=return_value)
|
|
55
|
-
|
|
56
|
-
@staticmethod
|
|
57
|
-
def pink(text, end='\n', return_value=False):
|
|
58
|
-
return CLI.print_or_return(text=text, color=Colors.PINK, end=end, return_value=return_value)
|
|
59
|
-
|
|
60
|
-
@staticmethod
|
|
61
|
-
def success(text, end='\n', return_value=False):
|
|
62
|
-
return CLI.print_or_return(text=text, color=Colors.GREEN, end=end, return_value=return_value)
|
|
63
|
-
|
|
64
|
-
@staticmethod
|
|
65
|
-
def warning(text, end='\n', return_value=False):
|
|
66
|
-
return CLI.print_or_return(text=text, color=Colors.YELLOW, end=end, return_value=return_value)
|
|
67
|
-
|
|
68
|
-
@staticmethod
|
|
69
|
-
def danger(text, end='\n', return_value=False):
|
|
70
|
-
return CLI.print_or_return(text=text, color=Colors.RED, end=end, return_value=return_value)
|
|
71
|
-
|
|
72
|
-
@staticmethod
|
|
73
|
-
def underline(text, end='\n', return_value=False):
|
|
74
|
-
return CLI.print_or_return(text=text, color=Colors.UNDERLINE, end=end, return_value=return_value)
|
|
75
|
-
|
|
76
|
-
@staticmethod
|
|
77
|
-
def step(index, total, text, end='\n', return_value=False):
|
|
78
|
-
return CLI.print_or_return(text=f'[{index}/{total}] {text}', color=Colors.YELLOW, end=end, return_value=return_value)
|
|
79
|
-
|
|
80
|
-
@staticmethod
|
|
81
|
-
def link(uri, label=None):
|
|
82
|
-
if label is None:
|
|
83
|
-
label = uri
|
|
84
|
-
parameters = ''
|
|
85
|
-
|
|
86
|
-
# OSC 8 ; params ; URI ST <name> OSC 8 ;; ST
|
|
87
|
-
escape_mask = '\033]8;{};{}\033\\{}\033]8;;\033\\'
|
|
88
|
-
|
|
89
|
-
return escape_mask.format(parameters, uri, label)
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
def nested_set(dic, keys, value):
|
|
93
|
-
for key in keys[:-1]:
|
|
94
|
-
dic = dic.setdefault(key, {})
|
|
95
|
-
dic[keys[-1]] = value
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def import_string(path):
|
|
99
|
-
components = path.split('.')
|
|
100
|
-
mod = __import__('.'.join(components[0:-1]), globals(), locals(), [components[-1]])
|
|
101
|
-
return getattr(mod, components[-1])
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
def random_string(n=10):
|
|
105
|
-
import random
|
|
106
|
-
import string
|
|
107
|
-
|
|
108
|
-
chars = string.ascii_lowercase + string.ascii_uppercase + string.digits
|
|
109
|
-
return ''.join(random.choice(chars) for _ in range(n))
|
|
110
|
-
|
|
111
|
-
def merge_json(obj1, obj2):
|
|
112
|
-
# Base case: if both values are dictionaries, merge recursively
|
|
113
|
-
if isinstance(obj1, dict) and isinstance(obj2, dict):
|
|
114
|
-
merged = {}
|
|
115
|
-
for key in obj1.keys() | obj2.keys(): # Union of both sets of keys
|
|
116
|
-
if key in obj1 and key in obj2:
|
|
117
|
-
merged[key] = merge_json(obj1[key], obj2[key])
|
|
118
|
-
elif key in obj1:
|
|
119
|
-
merged[key] = obj1[key]
|
|
120
|
-
else:
|
|
121
|
-
merged[key] = obj2[key]
|
|
122
|
-
return merged
|
|
123
|
-
# If both are lists, combine them
|
|
124
|
-
elif isinstance(obj1, list) and isinstance(obj2, list):
|
|
125
|
-
return obj1 + obj2
|
|
126
|
-
# If both values are not dicts or lists, return value from obj2
|
|
127
|
-
else:
|
|
128
|
-
if obj1 == obj2:
|
|
129
|
-
return obj1
|
|
130
|
-
else:
|
|
131
|
-
raise ValueError(f'Trying to merge objects: {obj1} and {obj2}')
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|