mantis-cli 19.2.0__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.
Files changed (27) hide show
  1. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/PKG-INFO +4 -7
  2. mantis_cli-20.0.0/mantis/__init__.py +1 -0
  3. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/command_line.py +96 -54
  4. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/environment.py +31 -3
  5. mantis_cli-20.0.0/mantis/helpers.py +98 -0
  6. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/logic.py +33 -14
  7. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/managers.py +158 -15
  8. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/mantis.tpl +1 -0
  9. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/PKG-INFO +3 -6
  10. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/entry_points.txt +1 -0
  11. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/requires.txt +1 -1
  12. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/setup.py +2 -2
  13. mantis_cli-19.2.0/mantis/__init__.py +0 -1
  14. mantis_cli-19.2.0/mantis/helpers.py +0 -131
  15. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/LICENSE +0 -0
  16. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/MANIFEST.in +0 -0
  17. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/README.md +0 -0
  18. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/__main__.py +0 -0
  19. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/crypto.py +0 -0
  20. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/extensions/__init__.py +0 -0
  21. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/extensions/django.py +0 -0
  22. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/extensions/nginx.py +0 -0
  23. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis/extensions/postgres.py +0 -0
  24. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/SOURCES.txt +0 -0
  25. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/dependency_links.txt +0 -0
  26. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/mantis_cli.egg-info/top_level.txt +0 -0
  27. {mantis_cli-19.2.0 → mantis_cli-20.0.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
- Name: mantis-cli
3
- Version: 19.2.0
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
- import prettytable
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 Colors, CLI, nested_set
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
- environment_intro = f'Environment ID = {Colors.BOLD}{manager.environment_id}{Colors.ENDC}, ' if manager.environment_id else ''
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
- print(heading)
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 {environment_id} --mode=host {" ".join(commands)}'
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 help(manager):
113
- print(f'\nUsage:\n\
114
- mantis [--mode=remote|ssh|host] [environment] --command[:params]')
115
-
116
- print('\nModes:\n\
117
- remote \truns commands remotely from local machine using DOCKER_HOST or DOCKER_CONTEXT (default)\n\
118
- ssh \tconnects to host via ssh and run all mantis commands on remote machine directly (nantis-cli needs to be installed on server)\n\
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
- # Get all methods of the class
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 of abstract manager
136
- if method_name in dir(AbstractManager):
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 = list(signature.parameters.keys())
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 not param.default:
144
+ if param_name == 'self':
145
+ continue
146
+ if param.default == inspect.Parameter.empty:
152
147
  params_are_optional = False
153
148
 
154
- # Print method name and its parameters
149
+ # Build command string
155
150
  command = f"--{command}"
156
- params = ""
151
+ params_str = ""
157
152
 
158
- if signature.parameters:
153
+ if parameters:
159
154
  if not params_are_optional:
160
- params += '['
155
+ params_str += '['
161
156
 
162
- params += ':'
157
+ params_str += ':'
163
158
 
164
- params += ','.join(parameters)
159
+ params_str += ','.join(parameters)
165
160
 
166
161
  if not params_are_optional:
167
- params += ']'
162
+ params_str += ']'
168
163
 
169
164
  docs = method.__doc__ or ''
170
165
 
171
- table.add_row([f"{command}{params}", docs.strip()])
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
- print(table)
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, Colors
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.id:
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
- from prettytable import PrettyTable
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
- table = PrettyTable(align='l')
41
- table.field_names = ["#", "Path", "Connections"]
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
- # TODO: get project names from compose files
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
- colorful_connections = []
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
- table.add_row([index + 1, normpath(dirname(path)), ', '.join(colorful_connections)])
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
- print(table)
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. Returning empty config')
96
- return {}
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
- if manager.environment_id is None and manager_method not in methods_without_environment:
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, Colors, merge_json
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
- if self.env.id is None:
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
- if self.env.id is None or 'local' in self.env.id:
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
- CLI.error(f'Connection for environment {self.env.id} not defined!')
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,14 +145,56 @@ 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
 
145
- if self.environment_id:
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:
146
169
  self.compose_path = normalize(path.join(self.config['compose']['folder'], self.environment_id))
147
170
 
148
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
+
149
193
  if not self.environment_id:
194
+ self.env = Environment(
195
+ environment_id=None,
196
+ folder=self.environment_path,
197
+ )
150
198
  self.connection = None
151
199
  return
152
200
 
@@ -167,6 +215,30 @@ class AbstractManager(object):
167
215
  # Read compose files
168
216
  self.compose_config = self.read_compose_configs()
169
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
+
170
242
  def check_environment_encryption(self, env_file):
171
243
  decrypted_environment = self.decrypt_env(env_file=env_file, return_value=True) # .env.encrypted
172
244
  loaded_environment = self.env.load(env_file) # .env
@@ -345,9 +417,15 @@ class BaseManager(AbstractManager):
345
417
 
346
418
  return values if return_value else None
347
419
 
348
- CLI.info(f'Encrypting environment file {env_file}...')
349
420
  env_file_encrypted = f'{env_file}.encrypted'
350
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
+
351
429
  if not self.KEY:
352
430
  CLI.error('Missing mantis key! (%s)' % self.key_file)
353
431
 
@@ -408,6 +486,11 @@ class BaseManager(AbstractManager):
408
486
 
409
487
  env_file_encrypted = f'{env_file}.encrypted'
410
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
+
411
494
  if not return_value:
412
495
  CLI.info(f'Decrypting environment file {env_file_encrypted}...')
413
496
 
@@ -614,7 +697,8 @@ class BaseManager(AbstractManager):
614
697
  if container not in self.get_containers():
615
698
  CLI.error(f"Container {container} not found")
616
699
 
617
- CLI.info(f'Health-checking {Colors.YELLOW}{container}{Colors.ENDC}...')
700
+ console = Console()
701
+ console.print(f'[blue]Health-checking [yellow]{container}[/yellow]...[/blue]')
618
702
 
619
703
  if self.has_healthcheck(container):
620
704
  healthcheck_config = self.get_healthcheck_config(container)
@@ -624,8 +708,8 @@ class BaseManager(AbstractManager):
624
708
  interval = healthcheck_interval / coeficient
625
709
  retries = healthcheck_retries * coeficient
626
710
 
627
- CLI.info(f'Interval: {Colors.FAINT}{healthcheck_interval}{Colors.ENDC} s -> {Colors.YELLOW}{interval} s')
628
- CLI.info(f'Retries: {Colors.FAINT}{healthcheck_retries}{Colors.ENDC} -> {Colors.YELLOW}{retries}')
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]')
629
713
 
630
714
  start = time.time()
631
715
 
@@ -633,13 +717,13 @@ class BaseManager(AbstractManager):
633
717
  is_healthy, status = self.check_health(container)
634
718
 
635
719
  if is_healthy:
636
- print(f"#{retry + 1}/{retries}: Status of '{container}' is {Colors.GREEN}{status}{Colors.ENDC}.")
720
+ console.print(f"#{retry + 1}/{retries}: Status of '{container}' is [green]{status}[/green].")
637
721
  end = time.time()
638
722
  loading_time = end - start
639
- print(f'Container {Colors.YELLOW}{container}{Colors.ENDC} took {Colors.BLUE}{Colors.UNDERLINE}{loading_time} s{Colors.ENDC} to become healthy')
723
+ console.print(f'Container [yellow]{container}[/yellow] took [blue underline]{loading_time} s[/blue underline] to become healthy')
640
724
  return True
641
725
  else:
642
- print(f"#{retry + 1}/{retries}: Status of '{container}' is {Colors.RED}{status}{Colors.ENDC}.")
726
+ console.print(f"#{retry + 1}/{retries}: Status of '{container}' is [red]{status}[/red].")
643
727
 
644
728
  if retries > 1:
645
729
  sleep(interval)
@@ -1089,14 +1173,73 @@ class BaseManager(AbstractManager):
1089
1173
  """
1090
1174
  Prints images and containers
1091
1175
  """
1176
+ console = Console()
1177
+
1092
1178
  CLI.info('Getting status...')
1093
1179
  steps = 2
1094
1180
 
1095
1181
  CLI.step(1, steps, 'List of Docker images')
1096
- self.docker(f'image ls')
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)
1097
1199
 
1098
1200
  CLI.step(2, steps, 'Docker containers')
1099
- self.docker(f'container ls -a --size')
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)
1100
1243
 
1101
1244
  def networks(self):
1102
1245
  """
@@ -31,6 +31,7 @@
31
31
  },
32
32
  "zero_downtime": [],
33
33
  "project_path": "~",
34
+ "connection": null,
34
35
  "connections": {
35
36
  }
36
37
  }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: mantis-cli
3
- Version: 19.2.0
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
+
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  mantis = mantis.command_line:run
3
+
@@ -2,4 +2,4 @@ cffi
2
2
  cryptography
3
3
  pycryptodome
4
4
  PyYAML
5
- prettytable
5
+ rich
@@ -4,7 +4,7 @@ from setuptools import setup, find_packages
4
4
  from mantis import VERSION
5
5
 
6
6
  setup(
7
- name='mantis-cli',
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', 'prettytable'],
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.2.0'
@@ -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