db-sync-tool-kmi 2.11.6__py3-none-any.whl → 3.0.2__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 (41) hide show
  1. db_sync_tool/__main__.py +7 -252
  2. db_sync_tool/cli.py +733 -0
  3. db_sync_tool/database/process.py +94 -111
  4. db_sync_tool/database/utility.py +339 -121
  5. db_sync_tool/info.py +1 -1
  6. db_sync_tool/recipes/drupal.py +87 -12
  7. db_sync_tool/recipes/laravel.py +7 -6
  8. db_sync_tool/recipes/parsing.py +102 -0
  9. db_sync_tool/recipes/symfony.py +17 -28
  10. db_sync_tool/recipes/typo3.py +33 -54
  11. db_sync_tool/recipes/wordpress.py +13 -12
  12. db_sync_tool/remote/client.py +206 -71
  13. db_sync_tool/remote/file_transfer.py +303 -0
  14. db_sync_tool/remote/rsync.py +18 -15
  15. db_sync_tool/remote/system.py +2 -3
  16. db_sync_tool/remote/transfer.py +51 -47
  17. db_sync_tool/remote/utility.py +29 -30
  18. db_sync_tool/sync.py +52 -28
  19. db_sync_tool/utility/config.py +367 -0
  20. db_sync_tool/utility/config_resolver.py +573 -0
  21. db_sync_tool/utility/console.py +779 -0
  22. db_sync_tool/utility/exceptions.py +32 -0
  23. db_sync_tool/utility/helper.py +155 -148
  24. db_sync_tool/utility/info.py +53 -20
  25. db_sync_tool/utility/log.py +55 -31
  26. db_sync_tool/utility/logging_config.py +410 -0
  27. db_sync_tool/utility/mode.py +85 -150
  28. db_sync_tool/utility/output.py +122 -51
  29. db_sync_tool/utility/parser.py +33 -53
  30. db_sync_tool/utility/pure.py +93 -0
  31. db_sync_tool/utility/security.py +79 -0
  32. db_sync_tool/utility/system.py +277 -194
  33. db_sync_tool/utility/validation.py +2 -9
  34. db_sync_tool_kmi-3.0.2.dist-info/METADATA +99 -0
  35. db_sync_tool_kmi-3.0.2.dist-info/RECORD +44 -0
  36. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/WHEEL +1 -1
  37. db_sync_tool_kmi-2.11.6.dist-info/METADATA +0 -276
  38. db_sync_tool_kmi-2.11.6.dist-info/RECORD +0 -34
  39. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/entry_points.txt +0 -0
  40. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info/licenses}/LICENSE +0 -0
  41. {db_sync_tool_kmi-2.11.6.dist-info → db_sync_tool_kmi-3.0.2.dist-info}/top_level.txt +0 -0
@@ -1,24 +1,30 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: future_fstrings -*-
3
2
 
4
3
  """
5
4
  System module
6
5
  """
7
6
 
8
- import sys
9
7
  import json
10
8
  import os
11
9
  import getpass
10
+ import secrets
11
+ from pathlib import Path
12
+ from typing import Any, TYPE_CHECKING
12
13
  import yaml
13
14
  from db_sync_tool.utility import log, parser, mode, helper, output, validation
15
+ from db_sync_tool.utility.console import get_output_manager
16
+ from db_sync_tool.utility.exceptions import ConfigError
14
17
  from db_sync_tool.remote import utility as remote_utility
15
18
 
19
+ if TYPE_CHECKING:
20
+ from db_sync_tool.utility.config import SyncConfig
21
+
16
22
  #
17
23
  # GLOBALS
18
24
  #
19
25
 
20
- config = {
21
- 'verbose': False,
26
+ config: dict[str, Any] = {
27
+ 'verbose': 0, # 0=compact, 1=verbose, 2=debug
22
28
  'mute': False,
23
29
  'dry_run': False,
24
30
  'keep_dump': False,
@@ -32,9 +38,11 @@ config = {
32
38
  'config_file_path': None,
33
39
  'clear_database': False,
34
40
  'force_password': False,
35
- 'use_rsync': False,
41
+ 'use_rsync': True, # rsync is 5-10x faster than Paramiko SFTP
36
42
  'use_rsync_options': None,
37
43
  'use_sshpass': False,
44
+ 'with_files': False, # Enable file synchronization (opt-in)
45
+ 'files_only': False, # Sync only files, skip database
38
46
  'ssh_agent': False,
39
47
  'ssh_password': {
40
48
  mode.Client.ORIGIN: None,
@@ -47,11 +55,94 @@ config = {
47
55
  'additional_mysqldump_options': ''
48
56
  }
49
57
 
58
+ # Typed configuration (single source of truth after migration)
59
+ _typed_config: 'SyncConfig | None' = None
60
+
61
+
62
+ def get_typed_config() -> 'SyncConfig':
63
+ """
64
+ Get current configuration as typed SyncConfig object.
65
+
66
+ :return: SyncConfig instance
67
+ """
68
+ global _typed_config
69
+ if _typed_config is None:
70
+ from db_sync_tool.utility.config import SyncConfig
71
+ _typed_config = SyncConfig.from_dict(config)
72
+ return _typed_config
73
+
74
+
75
+ def refresh_typed_config() -> None:
76
+ """
77
+ Refresh typed config after dict changes.
78
+
79
+ Call this after modifying system.config to keep _typed_config in sync.
80
+ This is needed during the migration phase where both dict and dataclass
81
+ are used.
82
+ """
83
+ global _typed_config
84
+ from db_sync_tool.utility.config import SyncConfig
85
+ _typed_config = SyncConfig.from_dict(config)
86
+
87
+
88
+ def _set_config_value(key: str, value, client: str | None = None) -> None:
89
+ """
90
+ Set a configuration value and refresh typed config.
91
+
92
+ :param key: Configuration key
93
+ :param value: Value to set
94
+ :param client: Optional client identifier for nested config
95
+ """
96
+ if client:
97
+ config.setdefault(client, {})[key] = value
98
+ else:
99
+ config[key] = value
100
+ refresh_typed_config()
101
+
102
+
103
+ def set_database_config(client: str, db_config: dict) -> None:
104
+ """Set database configuration for a client."""
105
+ _set_config_value('db', db_config, client)
106
+
107
+
108
+ def set_framework_type(type_name: str) -> None:
109
+ """Set the framework type."""
110
+ _set_config_value('type', type_name)
111
+
112
+
113
+ def set_is_same_client(value: bool) -> None:
114
+ """Set is_same_client flag."""
115
+ _set_config_value('is_same_client', value)
116
+
117
+
118
+ def set_use_sshpass(value: bool) -> None:
119
+ """Set use_sshpass flag."""
120
+ _set_config_value('use_sshpass', value)
121
+
122
+
50
123
  #
51
124
  # DEFAULTS
52
125
  #
53
126
 
54
- default_local_sync_path = '/tmp/db_sync_tool/'
127
+ # Generate a secure random suffix to prevent predictable temp paths
128
+ _temp_suffix = secrets.token_hex(8)
129
+ default_local_sync_path = f'/tmp/db_sync_tool_{_temp_suffix}/'
130
+
131
+
132
+ def create_secure_temp_dir(path):
133
+ """
134
+ Create a temporary directory with secure permissions (0700).
135
+ Prevents other users from accessing sensitive database dumps.
136
+
137
+ :param path: String path to create
138
+ :return: String path
139
+ """
140
+ if not os.path.exists(path):
141
+ os.makedirs(path, mode=0o700)
142
+ else:
143
+ # Ensure secure permissions even if directory exists
144
+ os.chmod(path, 0o700)
145
+ return path
55
146
 
56
147
 
57
148
  #
@@ -70,7 +161,7 @@ def get_configuration(host_config, args = {}):
70
161
  """
71
162
  Checking configuration information by file or dictionary
72
163
  :param host_config: Dictionary
73
- :param args: Dictionary
164
+ :param args: Dictionary (or argparse.Namespace with resolved_config attribute)
74
165
  :return:
75
166
  """
76
167
  global config
@@ -92,13 +183,9 @@ def get_configuration(host_config, args = {}):
92
183
  elif _config_file_path.endswith('.yaml') or _config_file_path.endswith('.yml'):
93
184
  config.update(yaml.safe_load(read_file))
94
185
  else:
95
- sys.exit(
96
- output.message(
97
- output.Subject.ERROR,
98
- f'Unsupported configuration file type [json,yml,yaml]: '
99
- f'{config["config_file_path"]}',
100
- False
101
- )
186
+ raise ConfigError(
187
+ f'Unsupported configuration file type [json,yml,yaml]: '
188
+ f'{config["config_file_path"]}'
102
189
  )
103
190
  output.message(
104
191
  output.Subject.LOCAL,
@@ -107,14 +194,13 @@ def get_configuration(host_config, args = {}):
107
194
  True
108
195
  )
109
196
  else:
110
- sys.exit(
111
- output.message(
112
- output.Subject.ERROR,
113
- f'Local configuration not found: {config["config_file_path"]}',
114
- False
115
- )
197
+ raise ConfigError(
198
+ f'Local configuration not found: {config["config_file_path"]}'
116
199
  )
117
200
 
201
+ # Apply resolved config from ConfigResolver (if present)
202
+ _apply_resolved_config(args)
203
+
118
204
  # workaround for argument order handling respecting the linking feature
119
205
  build_config(args, True)
120
206
  link_configuration_with_hosts()
@@ -124,138 +210,137 @@ def get_configuration(host_config, args = {}):
124
210
  check_options()
125
211
 
126
212
  if not config[mode.Client.TARGET] and not config[mode.Client.ORIGIN]:
127
- sys.exit(
128
- output.message(
129
- output.Subject.ERROR,
130
- f'Configuration is missing, use a separate file or provide host parameter',
131
- False
132
- )
213
+ raise ConfigError(
214
+ 'Configuration is missing, use a separate file or provide host parameter'
133
215
  )
216
+
217
+ # Refresh typed config after all configuration is loaded
218
+ refresh_typed_config()
219
+
134
220
  helper.run_script(script='before')
135
221
  log.get_logger().info('Starting db_sync_tool')
136
222
 
137
223
 
138
- def build_config(args, pre_run = False):
224
+ def _apply_resolved_config(args) -> None:
139
225
  """
140
- ADding the provided arguments
141
- :param args:
142
- :param pre_run:
143
- :return:
144
- """
145
- if args is None or not args:
146
- return {}
147
-
148
- if not args.type is None:
149
- config['type'] = args.type
150
-
151
- if not args.tables is None:
152
- config['tables'] = args.tables
153
-
154
- if not args.origin is None:
155
- config['link_origin'] = args.origin
156
-
157
- if not args.target is None:
158
- config['link_target'] = args.target
159
-
160
- # for order reasons check just the link arguments
161
- if pre_run: return
162
-
163
- if not args.target_path is None:
164
- config[mode.Client.TARGET]['path'] = args.target_path
165
-
166
- if not args.target_name is None:
167
- config[mode.Client.TARGET]['name'] = args.target_name
168
-
169
- if not args.target_host is None:
170
- config[mode.Client.TARGET]['host'] = args.target_host
171
-
172
- if not args.target_user is None:
173
- config[mode.Client.TARGET]['user'] = args.target_user
226
+ Apply resolved config from ConfigResolver to the global config.
174
227
 
175
- if not args.target_password is None:
176
- config[mode.Client.TARGET]['password'] = args.target_password
177
-
178
- if not args.target_key is None:
179
- config[mode.Client.TARGET]['ssh_key'] = args.target_key
180
-
181
- if not args.target_port is None:
182
- config[mode.Client.TARGET]['port'] = args.target_port
183
-
184
- if not args.target_dump_dir is None:
185
- config[mode.Client.TARGET]['dump_dir'] = args.target_dump_dir
186
-
187
- if not args.target_db_name is None:
188
- check_config_dict_key(mode.Client.TARGET, 'db')
189
- config[mode.Client.TARGET]['db']['name'] = args.target_db_name
190
-
191
- if not args.target_db_host is None:
192
- check_config_dict_key(mode.Client.TARGET, 'db')
193
- config[mode.Client.TARGET]['db']['host'] = args.target_db_host
194
-
195
- if not args.target_db_user is None:
196
- check_config_dict_key(mode.Client.TARGET, 'db')
197
- config[mode.Client.TARGET]['db']['user'] = args.target_db_user
198
-
199
- if not args.target_db_password is None:
200
- check_config_dict_key(mode.Client.TARGET, 'db')
201
- config[mode.Client.TARGET]['db']['password'] = args.target_db_password
202
-
203
- if not args.target_db_port is None:
204
- check_config_dict_key(mode.Client.TARGET, 'db')
205
- config[mode.Client.TARGET]['db']['port'] = args.target_db_port
206
-
207
- if not args.target_after_dump is None:
208
- config[mode.Client.TARGET]['after_dump'] = args.target_after_dump
209
-
210
- if not args.origin_path is None:
211
- config[mode.Client.ORIGIN]['path'] = args.origin_path
212
-
213
- if not args.origin_name is None:
214
- config[mode.Client.ORIGIN]['name'] = args.origin_name
215
-
216
- if not args.origin_host is None:
217
- config[mode.Client.ORIGIN]['host'] = args.origin_host
218
-
219
- if not args.origin_user is None:
220
- config[mode.Client.ORIGIN]['user'] = args.origin_user
228
+ :param args: argparse.Namespace or dict that may contain resolved_config
229
+ """
230
+ global config
221
231
 
222
- if not args.origin_password is None:
223
- config[mode.Client.ORIGIN]['password'] = args.origin_password
232
+ # Get resolved_config from args (if present)
233
+ resolved_config = getattr(args, 'resolved_config', None)
234
+ if resolved_config is None:
235
+ return
224
236
 
225
- if not args.origin_key is None:
226
- config[mode.Client.ORIGIN]['ssh_key'] = args.origin_key
237
+ # Apply merged config first (global defaults + project defaults)
238
+ if resolved_config.merged_config:
239
+ for key, value in resolved_config.merged_config.items():
240
+ if key not in ('origin', 'target'):
241
+ config[key] = value
227
242
 
228
- if not args.origin_port is None:
229
- config[mode.Client.ORIGIN]['port'] = args.origin_port
243
+ # Apply origin config
244
+ if resolved_config.origin_config:
245
+ config[mode.Client.ORIGIN] = {**config[mode.Client.ORIGIN], **resolved_config.origin_config}
230
246
 
231
- if not args.origin_dump_dir is None:
232
- config[mode.Client.ORIGIN]['dump_dir'] = args.origin_dump_dir
247
+ # Apply target config
248
+ if resolved_config.target_config:
249
+ config[mode.Client.TARGET] = {**config[mode.Client.TARGET], **resolved_config.target_config}
233
250
 
234
- if not args.origin_db_name is None:
235
- check_config_dict_key(mode.Client.ORIGIN, 'db')
236
- config[mode.Client.ORIGIN]['db']['name'] = args.origin_db_name
251
+ # Log the source
252
+ if resolved_config.source:
253
+ output.message(
254
+ output.Subject.INFO,
255
+ f'Configuration resolved from {resolved_config.source}',
256
+ True
257
+ )
237
258
 
238
- if not args.origin_db_host is None:
239
- check_config_dict_key(mode.Client.ORIGIN, 'db')
240
- config[mode.Client.ORIGIN]['db']['host'] = args.origin_db_host
241
259
 
242
- if not args.origin_db_user is None:
243
- check_config_dict_key(mode.Client.ORIGIN, 'db')
244
- config[mode.Client.ORIGIN]['db']['user'] = args.origin_db_user
260
+ # Argument mapping: (arg_name, config_path)
261
+ # config_path is a tuple: (client, key) or (client, nested_key, key) or just (key,) for top-level
262
+ _ARG_MAPPINGS_PRE_RUN = [
263
+ ('type', ('type',)),
264
+ ('tables', ('tables',)),
265
+ ('origin', ('link_origin',)),
266
+ ('target', ('link_target',)),
267
+ ]
268
+
269
+ _ARG_MAPPINGS_MAIN = [
270
+ # Target client mappings
271
+ ('target_path', (mode.Client.TARGET, 'path')),
272
+ ('target_name', (mode.Client.TARGET, 'name')),
273
+ ('target_host', (mode.Client.TARGET, 'host')),
274
+ ('target_user', (mode.Client.TARGET, 'user')),
275
+ ('target_password', (mode.Client.TARGET, 'password')),
276
+ ('target_key', (mode.Client.TARGET, 'ssh_key')),
277
+ ('target_port', (mode.Client.TARGET, 'port')),
278
+ ('target_dump_dir', (mode.Client.TARGET, 'dump_dir')),
279
+ ('target_after_dump', (mode.Client.TARGET, 'after_dump')),
280
+ ('target_db_name', (mode.Client.TARGET, 'db', 'name')),
281
+ ('target_db_host', (mode.Client.TARGET, 'db', 'host')),
282
+ ('target_db_user', (mode.Client.TARGET, 'db', 'user')),
283
+ ('target_db_password', (mode.Client.TARGET, 'db', 'password')),
284
+ ('target_db_port', (mode.Client.TARGET, 'db', 'port')),
285
+ # Origin client mappings
286
+ ('origin_path', (mode.Client.ORIGIN, 'path')),
287
+ ('origin_name', (mode.Client.ORIGIN, 'name')),
288
+ ('origin_host', (mode.Client.ORIGIN, 'host')),
289
+ ('origin_user', (mode.Client.ORIGIN, 'user')),
290
+ ('origin_password', (mode.Client.ORIGIN, 'password')),
291
+ ('origin_key', (mode.Client.ORIGIN, 'ssh_key')),
292
+ ('origin_port', (mode.Client.ORIGIN, 'port')),
293
+ ('origin_dump_dir', (mode.Client.ORIGIN, 'dump_dir')),
294
+ ('origin_db_name', (mode.Client.ORIGIN, 'db', 'name')),
295
+ ('origin_db_host', (mode.Client.ORIGIN, 'db', 'host')),
296
+ ('origin_db_user', (mode.Client.ORIGIN, 'db', 'user')),
297
+ ('origin_db_password', (mode.Client.ORIGIN, 'db', 'password')),
298
+ ('origin_db_port', (mode.Client.ORIGIN, 'db', 'port')),
299
+ # Top-level config mappings
300
+ ('where', ('where',)),
301
+ ('additional_mysqldump_options', ('additional_mysqldump_options',)),
302
+ ]
303
+
304
+
305
+ def _apply_arg_mapping(args, mapping):
306
+ """
307
+ Apply argument mappings to config dict.
245
308
 
246
- if not args.origin_db_password is None:
247
- check_config_dict_key(mode.Client.ORIGIN, 'db')
248
- config[mode.Client.ORIGIN]['db']['password'] = args.origin_db_password
309
+ :param args: Argument namespace
310
+ :param mapping: List of (arg_name, config_path) tuples
311
+ """
312
+ for arg_name, config_path in mapping:
313
+ value = getattr(args, arg_name, None)
314
+ if value is None:
315
+ continue
316
+
317
+ if len(config_path) == 1:
318
+ # Top-level config key
319
+ config[config_path[0]] = value
320
+ elif len(config_path) == 2:
321
+ # Client-level key: (client, key)
322
+ config[config_path[0]][config_path[1]] = value
323
+ elif len(config_path) == 3:
324
+ # Nested client key: (client, nested_key, key)
325
+ check_config_dict_key(config_path[0], config_path[1])
326
+ config[config_path[0]][config_path[1]][config_path[2]] = value
327
+
328
+
329
+ def build_config(args, pre_run=False):
330
+ """
331
+ Apply provided CLI arguments to config dict.
249
332
 
250
- if not args.origin_db_port is None:
251
- check_config_dict_key(mode.Client.ORIGIN, 'db')
252
- config[mode.Client.ORIGIN]['db']['port'] = args.origin_db_port
333
+ :param args: Argument namespace from argparse
334
+ :param pre_run: Boolean, if True only apply link-related args
335
+ :return: config dict
336
+ """
337
+ if args is None or not args:
338
+ return {}
253
339
 
254
- if not args.where is None:
255
- config['where'] = args.where
340
+ _apply_arg_mapping(args, _ARG_MAPPINGS_PRE_RUN)
256
341
 
257
- if not args.additional_mysqldump_options is None:
258
- config['additional_mysqldump_options'] = args.additional_mysqldump_options
342
+ if not pre_run:
343
+ _apply_arg_mapping(args, _ARG_MAPPINGS_MAIN)
259
344
 
260
345
  return config
261
346
 
@@ -275,6 +360,19 @@ def check_options():
275
360
  if 'check_dump' in config:
276
361
  config['check_dump'] = config['check_dump']
277
362
 
363
+ # Check rsync availability if enabled (default: True)
364
+ # Falls back to Paramiko SFTP if rsync is not available
365
+ if config['use_rsync']:
366
+ if helper.check_rsync_version():
367
+ helper.check_sshpass_version()
368
+ else:
369
+ config['use_rsync'] = False
370
+ output.message(
371
+ output.Subject.WARNING,
372
+ 'rsync not found, falling back to SFTP transfer',
373
+ True
374
+ )
375
+
278
376
  reverse_hosts()
279
377
  mode.check_sync_mode()
280
378
 
@@ -286,6 +384,8 @@ def check_authorizations():
286
384
  """
287
385
  check_authorization(mode.Client.ORIGIN)
288
386
  check_authorization(mode.Client.TARGET)
387
+ # Refresh typed config after authorization changes (ssh_agent, password)
388
+ refresh_typed_config()
289
389
 
290
390
 
291
391
  def check_authorization(client):
@@ -311,13 +411,7 @@ def check_authorization(client):
311
411
  elif 'ssh_key' in config[client]:
312
412
  _ssh_key = config[client]['ssh_key']
313
413
  if not os.path.isfile(_ssh_key):
314
- sys.exit(
315
- output.message(
316
- output.Subject.ERROR,
317
- f'SSH {client} private key not found: {_ssh_key}',
318
- False
319
- )
320
- )
414
+ raise ConfigError(f'SSH {client} private key not found: {_ssh_key}')
321
415
  elif 'password' in config[client]:
322
416
  config[client]['password'] = config[client]['password']
323
417
  elif remote_utility.check_keys_from_ssh_agent():
@@ -338,13 +432,11 @@ def get_password_by_user(client):
338
432
  :param client: String
339
433
  :return: String password
340
434
  """
341
- _password = getpass.getpass(
342
- output.message(
343
- output.Subject.INFO,
344
- 'SSH password ' + helper.get_ssh_host_name(client, True) + ': ',
345
- False
346
- )
435
+ _prompt = get_output_manager().build_prompt(
436
+ f'SSH password {helper.get_ssh_host_name(client, True)}: ',
437
+ subject='INFO'
347
438
  )
439
+ _password = getpass.getpass(_prompt)
348
440
 
349
441
  while _password.strip() == '':
350
442
  output.message(
@@ -353,13 +445,11 @@ def get_password_by_user(client):
353
445
  True
354
446
  )
355
447
 
356
- _password = getpass.getpass(
357
- output.message(
358
- output.Subject.INFO,
359
- 'SSH password ' + helper.get_ssh_host_name(client, True) + ': ',
360
- False
361
- )
448
+ _prompt = get_output_manager().build_prompt(
449
+ f'SSH password {helper.get_ssh_host_name(client, True)}: ',
450
+ subject='INFO'
362
451
  )
452
+ _password = getpass.getpass(_prompt)
363
453
 
364
454
  return _password
365
455
 
@@ -377,7 +467,9 @@ def check_args_options(config_file=None,
377
467
  force_password=False,
378
468
  use_rsync=False,
379
469
  use_rsync_options=None,
380
- reverse=False):
470
+ reverse=False,
471
+ with_files=False,
472
+ files_only=False):
381
473
  """
382
474
  Checking arguments and fill options array
383
475
  :param config_file:
@@ -394,6 +486,8 @@ def check_args_options(config_file=None,
394
486
  :param use_rsync:
395
487
  :param use_rsync_options:
396
488
  :param reverse:
489
+ :param with_files:
490
+ :param files_only:
397
491
  :return:
398
492
  """
399
493
  global config
@@ -436,15 +530,11 @@ def check_args_options(config_file=None,
436
530
  if not force_password is None:
437
531
  config['force_password'] = force_password
438
532
 
439
- if not use_rsync is None:
533
+ if use_rsync is not None:
440
534
  config['use_rsync'] = use_rsync
441
535
 
442
- if use_rsync is True:
443
- helper.check_rsync_version()
444
- helper.check_sshpass_version()
445
-
446
- if not use_rsync_options is None:
447
- config['use_rsync_options'] = use_rsync_options
536
+ if use_rsync_options is not None:
537
+ config['use_rsync_options'] = use_rsync_options
448
538
 
449
539
  if not reverse is None:
450
540
  config['reverse'] = reverse
@@ -463,10 +553,19 @@ def check_args_options(config_file=None,
463
553
  True
464
554
  )
465
555
 
556
+ if with_files is not None:
557
+ config['with_files'] = with_files
558
+
559
+ if files_only is not None:
560
+ config['files_only'] = files_only
561
+ if files_only:
562
+ # files_only implies with_files
563
+ config['with_files'] = True
564
+
466
565
 
467
566
  def reverse_hosts():
468
567
  """
469
- Checking authorization for clients
568
+ Reverse origin and target hosts if --reverse flag is set.
470
569
  :return:
471
570
  """
472
571
  if config['reverse']:
@@ -476,6 +575,9 @@ def reverse_hosts():
476
575
  config[mode.Client.ORIGIN] = _target
477
576
  config[mode.Client.TARGET] = _origin
478
577
 
578
+ # Refresh typed config after swapping
579
+ refresh_typed_config()
580
+
479
581
  output.message(
480
582
  output.Subject.INFO,
481
583
  'Reverse origin and target hosts',
@@ -500,22 +602,19 @@ def link_configuration_with_hosts():
500
602
 
501
603
  if config['link_hosts'] == '':
502
604
  # Try to find default hosts.json file in same directory
503
- sys.exit(
504
- output.message(
505
- output.Subject.ERROR,
506
- f'Missing hosts file for linking hosts with configuration. '
507
- f'Use the "-o" / "--hosts" argument to define the filepath for the hosts file, '
508
- f'when using a link parameter within the configuration or define the the '
509
- f'filepath direct in the link entry e.g. "host.yaml@entry1".',
510
- False
511
- )
605
+ raise ConfigError(
606
+ 'Missing hosts file for linking hosts with configuration. '
607
+ 'Use the "-o" / "--hosts" argument to define the filepath for the hosts file, '
608
+ 'when using a link parameter within the configuration or define the '
609
+ 'filepath direct in the link entry e.g. "host.yaml@entry1".'
512
610
  )
513
611
 
514
612
  if config['link_hosts'] != '':
515
613
 
516
614
  # Adjust filepath from relative to absolute
517
- if config['link_hosts'][0] != '/':
518
- config['link_hosts'] = os.path.dirname(os.path.abspath(config['config_file_path'])) + '/' + config['link_hosts']
615
+ if not config['link_hosts'].startswith('/'):
616
+ base_path = Path(config['config_file_path']).resolve().parent if config['config_file_path'] else Path.cwd()
617
+ config['link_hosts'] = str(base_path / config['link_hosts'])
519
618
 
520
619
  if os.path.isfile(config['link_hosts']):
521
620
  with open(config['link_hosts'], 'r') as read_file:
@@ -545,30 +644,14 @@ def link_configuration_with_hosts():
545
644
  config[mode.Client.TARGET] = _hosts[config['link_target']]
546
645
  config[mode.Client.ORIGIN] = _hosts[config['link_origin']]
547
646
  else:
548
- sys.exit(
549
- output.message(
550
- output.Subject.ERROR,
551
- f'Misconfiguration of link hosts {config["link_origin"]}, '
552
- f'{config["link_target"]} in {config["link_hosts"]}',
553
- False
554
- )
647
+ raise ConfigError(
648
+ f'Misconfiguration of link hosts {config["link_origin"]}, '
649
+ f'{config["link_target"]} in {config["link_hosts"]}'
555
650
  )
556
651
  else:
557
- sys.exit(
558
- output.message(
559
- output.Subject.ERROR,
560
- f'Missing link hosts for {config["link_hosts"]}',
561
- False
562
- )
563
- )
652
+ raise ConfigError(f'Missing link hosts for {config["link_hosts"]}')
564
653
  else:
565
- sys.exit(
566
- output.message(
567
- output.Subject.ERROR,
568
- f'Local host file not found: {config["link_hosts"]}',
569
- False
570
- )
571
- )
654
+ raise ConfigError(f'Local host file not found: {config["link_hosts"]}')
572
655
 
573
656
 
574
657
  def check_config_dict_key(client, key):
@@ -1,13 +1,12 @@
1
1
  #!/usr/bin/env python3
2
- # -*- coding: future_fstrings -*-
3
2
 
4
3
  """
5
4
  Validation script
6
5
  """
7
6
 
8
- import sys
9
7
  from jsonschema import validators
10
8
  from db_sync_tool.utility import output
9
+ from db_sync_tool.utility.exceptions import ValidationError
11
10
 
12
11
  #
13
12
  # GLOBALS
@@ -107,10 +106,4 @@ def check(config):
107
106
  True
108
107
  )
109
108
  if errors:
110
- sys.exit(
111
- output.message(
112
- output.Subject.ERROR,
113
- 'Validation error(s)',
114
- do_print=False
115
- )
116
- )
109
+ raise ValidationError('Configuration validation failed')