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
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """
4
+ Config Resolver Module
5
+
6
+ Provides automatic configuration discovery and interactive host selection.
7
+
8
+ Lookup order:
9
+ 1. -f specified? → Load that file
10
+ 2. .db-sync-tool/[name].yaml? → Load project config
11
+ 3. ~/.db-sync-tool/hosts.yaml + name? → Use host reference
12
+ 4. No args + .db-sync-tool/? → Interactive config selection
13
+ 5. No args + ~/.db-sync-tool/? → Interactive host selection
14
+ 6. Nothing found? → Error (as before)
15
+ """
16
+
17
+ import logging
18
+ from dataclasses import dataclass, field
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ import yaml
23
+ from rich.console import Console
24
+ from rich.panel import Panel
25
+ from rich.prompt import Confirm, IntPrompt
26
+
27
+ from db_sync_tool.utility.exceptions import ConfigError, NoConfigFoundError
28
+
29
+ logger = logging.getLogger('db_sync_tool.config_resolver')
30
+
31
+
32
+ # Directory names
33
+ GLOBAL_CONFIG_DIR = '.db-sync-tool'
34
+ PROJECT_CONFIG_DIR = '.db-sync-tool'
35
+
36
+ # File names
37
+ HOSTS_FILE = 'hosts.yaml'
38
+ DEFAULTS_FILE = 'defaults.yaml'
39
+
40
+
41
+ @dataclass
42
+ class HostDefinition:
43
+ """A single host definition from hosts.yaml."""
44
+
45
+ name: str
46
+ host: str | None = None
47
+ user: str | None = None
48
+ path: str | None = None
49
+ port: int | None = None
50
+ ssh_key: str | None = None
51
+ protect: bool = False
52
+ db: dict[str, Any] = field(default_factory=dict)
53
+
54
+ @classmethod
55
+ def from_dict(cls, name: str, data: dict[str, Any]) -> 'HostDefinition':
56
+ """Create HostDefinition from dictionary."""
57
+ return cls(
58
+ name=name,
59
+ host=data.get('host'),
60
+ user=data.get('user'),
61
+ path=data.get('path'),
62
+ port=data.get('port'),
63
+ ssh_key=data.get('ssh_key'),
64
+ protect=data.get('protect', False),
65
+ db=data.get('db', {}),
66
+ )
67
+
68
+ def to_client_config(self) -> dict[str, Any]:
69
+ """Convert to client configuration dictionary."""
70
+ config: dict[str, Any] = {}
71
+ if self.host:
72
+ config['host'] = self.host
73
+ if self.user:
74
+ config['user'] = self.user
75
+ if self.path:
76
+ config['path'] = self.path
77
+ if self.port:
78
+ config['port'] = self.port
79
+ if self.ssh_key:
80
+ config['ssh_key'] = self.ssh_key
81
+ if self.db:
82
+ config['db'] = self.db
83
+ return config
84
+
85
+ @property
86
+ def is_remote(self) -> bool:
87
+ """Check if this host is remote (has SSH host)."""
88
+ return self.host is not None
89
+
90
+ @property
91
+ def display_name(self) -> str:
92
+ """Get display name for UI."""
93
+ if self.host:
94
+ return f"{self.name} ({self.host})"
95
+ return f"{self.name} (local)"
96
+
97
+
98
+ @dataclass
99
+ class ProjectConfig:
100
+ """A project-specific sync configuration."""
101
+
102
+ name: str
103
+ file_path: Path
104
+ origin: str | dict[str, Any] | None = None
105
+ target: str | dict[str, Any] | None = None
106
+ config: dict[str, Any] = field(default_factory=dict)
107
+
108
+ @classmethod
109
+ def from_file(cls, file_path: Path) -> 'ProjectConfig':
110
+ """Load project config from YAML file."""
111
+ with open(file_path) as f:
112
+ data = yaml.safe_load(f) or {}
113
+
114
+ return cls(
115
+ name=file_path.stem,
116
+ file_path=file_path,
117
+ origin=data.get('origin'),
118
+ target=data.get('target'),
119
+ config=data,
120
+ )
121
+
122
+ def get_description(self, hosts: dict[str, HostDefinition]) -> str:
123
+ """Get human-readable description of the sync."""
124
+ origin_name = self._get_endpoint_name(self.origin, hosts)
125
+ target_name = self._get_endpoint_name(self.target, hosts)
126
+ return f"{origin_name} → {target_name}"
127
+
128
+ def _get_endpoint_name(
129
+ self, endpoint: str | dict[str, Any] | None, hosts: dict[str, HostDefinition]
130
+ ) -> str:
131
+ """Get display name for an endpoint."""
132
+ if endpoint is None:
133
+ return "?"
134
+ if isinstance(endpoint, str):
135
+ if endpoint in hosts:
136
+ return hosts[endpoint].display_name
137
+ return endpoint
138
+ if isinstance(endpoint, dict):
139
+ if 'host' in endpoint:
140
+ return endpoint.get('name', endpoint['host'])
141
+ return endpoint.get('name', 'local')
142
+ return str(endpoint)
143
+
144
+
145
+ @dataclass
146
+ class ResolvedConfig:
147
+ """Result of config resolution."""
148
+
149
+ config_file: Path | None = None
150
+ origin_config: dict[str, Any] = field(default_factory=dict)
151
+ target_config: dict[str, Any] = field(default_factory=dict)
152
+ merged_config: dict[str, Any] = field(default_factory=dict)
153
+ source: str = "" # Description of where config came from
154
+
155
+
156
+ class ConfigResolver:
157
+ """
158
+ Resolves configuration from multiple sources.
159
+
160
+ Supports:
161
+ - Explicit config file (-f config.yaml)
162
+ - Project configs (.db-sync-tool/*.yaml)
163
+ - Global hosts (~/.db-sync-tool/hosts.yaml)
164
+ - Interactive selection when no args provided
165
+ """
166
+
167
+ def __init__(self, console: Console | None = None):
168
+ """
169
+ Initialize ConfigResolver.
170
+
171
+ :param console: Rich console for interactive prompts
172
+ """
173
+ self.console = console or Console()
174
+ self._global_dir: Path | None = None
175
+ self._project_dir: Path | None = None
176
+ self._global_hosts: dict[str, HostDefinition] = {}
177
+ self._global_defaults: dict[str, Any] = {}
178
+ self._project_defaults: dict[str, Any] = {}
179
+ self._project_configs: dict[str, ProjectConfig] = {}
180
+
181
+ @property
182
+ def global_config_dir(self) -> Path:
183
+ """Get global config directory (~/.db-sync-tool/)."""
184
+ if self._global_dir is None:
185
+ self._global_dir = Path.home() / GLOBAL_CONFIG_DIR
186
+ return self._global_dir
187
+
188
+ @property
189
+ def project_config_dir(self) -> Path | None:
190
+ """Get project config directory (.db-sync-tool/ in cwd or parents)."""
191
+ if self._project_dir is None:
192
+ self._project_dir = self._find_project_config_dir()
193
+ return self._project_dir
194
+
195
+ def _find_project_config_dir(self) -> Path | None:
196
+ """Search for .db-sync-tool/ directory in cwd and parents."""
197
+ cwd = Path.cwd()
198
+ for parent in [cwd, *cwd.parents]:
199
+ project_dir = parent / PROJECT_CONFIG_DIR
200
+ if project_dir.is_dir():
201
+ return project_dir
202
+ return None
203
+
204
+ def load_global_config(self) -> None:
205
+ """Load global hosts and defaults from ~/.db-sync-tool/."""
206
+ if not self.global_config_dir.is_dir():
207
+ return
208
+
209
+ # Load hosts.yaml
210
+ hosts_file = self.global_config_dir / HOSTS_FILE
211
+ if hosts_file.is_file():
212
+ with open(hosts_file) as f:
213
+ data = yaml.safe_load(f) or {}
214
+ self._global_hosts = {
215
+ name: HostDefinition.from_dict(name, config)
216
+ for name, config in data.items()
217
+ }
218
+
219
+ # Load defaults.yaml
220
+ defaults_file = self.global_config_dir / DEFAULTS_FILE
221
+ if defaults_file.is_file():
222
+ with open(defaults_file) as f:
223
+ self._global_defaults = yaml.safe_load(f) or {}
224
+
225
+ def load_project_config(self) -> None:
226
+ """Load project configs from .db-sync-tool/."""
227
+ if self.project_config_dir is None:
228
+ return
229
+
230
+ # Load project defaults.yaml
231
+ defaults_file = self.project_config_dir / DEFAULTS_FILE
232
+ if defaults_file.is_file():
233
+ with open(defaults_file) as f:
234
+ self._project_defaults = yaml.safe_load(f) or {}
235
+
236
+ # Load all project config files (*.yaml except defaults.yaml)
237
+ for config_file in self.project_config_dir.glob('*.yaml'):
238
+ if config_file.name == DEFAULTS_FILE:
239
+ continue
240
+ try:
241
+ project_config = ProjectConfig.from_file(config_file)
242
+ self._project_configs[project_config.name] = project_config
243
+ except Exception as e:
244
+ logger.warning(
245
+ f"Failed to load project config '{config_file}': {e}"
246
+ )
247
+ continue
248
+
249
+ # Also check *.yml files
250
+ for config_file in self.project_config_dir.glob('*.yml'):
251
+ if config_file.name == 'defaults.yml':
252
+ continue
253
+ try:
254
+ project_config = ProjectConfig.from_file(config_file)
255
+ self._project_configs[project_config.name] = project_config
256
+ except Exception as e:
257
+ logger.warning(
258
+ f"Failed to load project config '{config_file}': {e}"
259
+ )
260
+ continue
261
+
262
+ def resolve(
263
+ self,
264
+ config_file: str | None = None,
265
+ origin: str | None = None,
266
+ target: str | None = None,
267
+ interactive: bool = True,
268
+ ) -> ResolvedConfig:
269
+ """
270
+ Resolve configuration from available sources.
271
+
272
+ Lookup order:
273
+ 1. Explicit config file (-f)
274
+ 2. Project config by name
275
+ 3. Host references (origin/target names)
276
+ 4. Interactive selection
277
+
278
+ :param config_file: Explicit config file path
279
+ :param origin: Origin host name or None
280
+ :param target: Target host name or None
281
+ :param interactive: Allow interactive prompts
282
+ :return: ResolvedConfig with merged configuration
283
+ """
284
+ # Load available configs
285
+ self.load_global_config()
286
+ self.load_project_config()
287
+
288
+ # 1. Explicit config file takes priority
289
+ if config_file:
290
+ return self._resolve_explicit_file(Path(config_file))
291
+
292
+ # 2. Check if origin arg matches a project config name
293
+ if origin and not target and origin in self._project_configs:
294
+ return self._resolve_project_config(self._project_configs[origin])
295
+
296
+ # 3. Origin and target specified as host names
297
+ if origin and target:
298
+ return self._resolve_host_references(origin, target)
299
+
300
+ # 4. Interactive selection
301
+ if interactive:
302
+ return self._resolve_interactive()
303
+
304
+ raise NoConfigFoundError(
305
+ 'Configuration is missing, use a separate file or provide host parameter'
306
+ )
307
+
308
+ def _resolve_explicit_file(self, config_file: Path) -> ResolvedConfig:
309
+ """Resolve configuration from explicit file."""
310
+ if not config_file.is_file():
311
+ raise ConfigError(f'Configuration file not found: {config_file}')
312
+
313
+ return ResolvedConfig(
314
+ config_file=config_file,
315
+ source=f"explicit file: {config_file}",
316
+ )
317
+
318
+ def _resolve_project_config(self, project: ProjectConfig) -> ResolvedConfig:
319
+ """Resolve configuration from project config."""
320
+ # Start with global defaults, then project defaults, then project config
321
+ merged = self._merge_defaults(project.config)
322
+
323
+ # Resolve host references in origin/target
324
+ origin_config = self._resolve_endpoint(project.origin)
325
+ target_config = self._resolve_endpoint(project.target)
326
+
327
+ return ResolvedConfig(
328
+ config_file=project.file_path,
329
+ origin_config=origin_config,
330
+ target_config=target_config,
331
+ merged_config=merged,
332
+ source=f"project config: {project.name}",
333
+ )
334
+
335
+ def _resolve_host_references(self, origin: str, target: str) -> ResolvedConfig:
336
+ """Resolve configuration from host name references."""
337
+ if origin not in self._global_hosts:
338
+ raise ConfigError(
339
+ f"Host '{origin}' not found in {self.global_config_dir / HOSTS_FILE}"
340
+ )
341
+ if target not in self._global_hosts:
342
+ raise ConfigError(
343
+ f"Host '{target}' not found in {self.global_config_dir / HOSTS_FILE}"
344
+ )
345
+
346
+ origin_host = self._global_hosts[origin]
347
+ target_host = self._global_hosts[target]
348
+
349
+ # Check protect flag
350
+ if target_host.protect:
351
+ self._warn_protected_target(target_host)
352
+
353
+ merged = self._merge_defaults({})
354
+
355
+ return ResolvedConfig(
356
+ origin_config=origin_host.to_client_config(),
357
+ target_config=target_host.to_client_config(),
358
+ merged_config=merged,
359
+ source=f"host references: {origin} → {target}",
360
+ )
361
+
362
+ def _resolve_endpoint(self, endpoint: str | dict[str, Any] | None) -> dict[str, Any]:
363
+ """Resolve a single endpoint (origin or target)."""
364
+ if endpoint is None:
365
+ return {}
366
+ if isinstance(endpoint, str):
367
+ # Host reference
368
+ if endpoint in self._global_hosts:
369
+ return self._global_hosts[endpoint].to_client_config()
370
+ raise ConfigError(
371
+ f"Host '{endpoint}' not found in {self.global_config_dir / HOSTS_FILE}"
372
+ )
373
+ if isinstance(endpoint, dict):
374
+ return endpoint
375
+ return {}
376
+
377
+ def _resolve_interactive(self) -> ResolvedConfig:
378
+ """Interactive config/host selection."""
379
+ # Check for project configs first
380
+ if self._project_configs:
381
+ return self._interactive_project_selection()
382
+
383
+ # Fall back to global hosts
384
+ if self._global_hosts:
385
+ return self._interactive_host_selection()
386
+
387
+ raise NoConfigFoundError(
388
+ "No configuration found. Create .db-sync-tool/ or ~/.db-sync-tool/ "
389
+ "with config files, or use -f to specify a config file."
390
+ )
391
+
392
+ def _interactive_project_selection(self) -> ResolvedConfig:
393
+ """Interactive selection from project configs."""
394
+ self.console.print()
395
+ self.console.print(
396
+ Panel(
397
+ f"Project configs found: [cyan]{self.project_config_dir}[/cyan]",
398
+ title="db-sync-tool",
399
+ border_style="cyan",
400
+ )
401
+ )
402
+ self.console.print()
403
+
404
+ # List available configs
405
+ configs = list(self._project_configs.values())
406
+ for i, cfg in enumerate(configs, 1):
407
+ desc = cfg.get_description(self._global_hosts)
408
+ self.console.print(f" [bold cyan][{i}][/bold cyan] {cfg.name:12} {desc}")
409
+
410
+ self.console.print()
411
+
412
+ # Get user selection
413
+ choice = IntPrompt.ask(
414
+ "Selection",
415
+ choices=[str(i) for i in range(1, len(configs) + 1)],
416
+ console=self.console,
417
+ )
418
+
419
+ selected = configs[choice - 1]
420
+ self.console.print()
421
+
422
+ # Show preview and confirm
423
+ self._show_sync_preview(selected)
424
+
425
+ if not Confirm.ask("Continue?", default=False, console=self.console):
426
+ raise ConfigError("Aborted by user")
427
+
428
+ return self._resolve_project_config(selected)
429
+
430
+ def _interactive_host_selection(self) -> ResolvedConfig:
431
+ """Interactive selection from global hosts."""
432
+ self.console.print()
433
+ self.console.print(
434
+ Panel(
435
+ f"Global hosts: [cyan]{self.global_config_dir / HOSTS_FILE}[/cyan]",
436
+ title="db-sync-tool",
437
+ border_style="cyan",
438
+ )
439
+ )
440
+ self.console.print()
441
+
442
+ # List available hosts
443
+ hosts = list(self._global_hosts.values())
444
+ for i, host in enumerate(hosts, 1):
445
+ protect_marker = " [red](protected)[/red]" if host.protect else ""
446
+ self.console.print(f" [bold cyan][{i}][/bold cyan] {host.display_name}{protect_marker}")
447
+
448
+ self.console.print()
449
+
450
+ # Get origin selection
451
+ self.console.print("[bold]Origin (source):[/bold]")
452
+ origin_choice = IntPrompt.ask(
453
+ "Selection",
454
+ choices=[str(i) for i in range(1, len(hosts) + 1)],
455
+ console=self.console,
456
+ )
457
+ origin_host = hosts[origin_choice - 1]
458
+
459
+ self.console.print()
460
+
461
+ # Get target selection
462
+ self.console.print("[bold]Target (destination):[/bold]")
463
+ target_choice = IntPrompt.ask(
464
+ "Selection",
465
+ choices=[str(i) for i in range(1, len(hosts) + 1)],
466
+ console=self.console,
467
+ )
468
+ target_host = hosts[target_choice - 1]
469
+
470
+ self.console.print()
471
+
472
+ # Check protect flag
473
+ if target_host.protect:
474
+ self._warn_protected_target(target_host)
475
+
476
+ # Confirm
477
+ self.console.print(
478
+ Panel(
479
+ f"[bold]{origin_host.display_name}[/bold] → [bold]{target_host.display_name}[/bold]",
480
+ title="Sync Preview",
481
+ border_style="yellow",
482
+ )
483
+ )
484
+
485
+ if not Confirm.ask("Continue?", default=False, console=self.console):
486
+ raise ConfigError("Aborted by user")
487
+
488
+ return self._resolve_host_references(origin_host.name, target_host.name)
489
+
490
+ def _show_sync_preview(self, project: ProjectConfig) -> None:
491
+ """Show sync preview panel."""
492
+ desc = project.get_description(self._global_hosts)
493
+
494
+ # Check if origin is production
495
+ origin_name = (
496
+ project.origin if isinstance(project.origin, str) else None
497
+ )
498
+ warning = ""
499
+ if origin_name and origin_name in self._global_hosts:
500
+ origin_host = self._global_hosts[origin_name]
501
+ if origin_host.protect:
502
+ warning = "\n[yellow bold]Warning: Production system as source![/yellow bold]"
503
+
504
+ self.console.print(
505
+ Panel(
506
+ f"[bold]{desc}[/bold]{warning}",
507
+ title="Sync Preview",
508
+ border_style="yellow",
509
+ )
510
+ )
511
+
512
+ def _warn_protected_target(self, host: HostDefinition) -> None:
513
+ """Warn about protected target and require confirmation."""
514
+ self.console.print()
515
+ self.console.print(
516
+ Panel(
517
+ f"[bold red]DANGER![/bold red]\n\n"
518
+ f"Target [bold]{host.display_name}[/bold] is marked as [bold red]protected[/bold red].\n"
519
+ f"This is typically a production system.\n\n"
520
+ f"Syncing to this target will [bold]overwrite[/bold] the database!",
521
+ title="Protected Target",
522
+ border_style="red",
523
+ )
524
+ )
525
+ self.console.print()
526
+
527
+ if not Confirm.ask(
528
+ "[bold red]Are you absolutely sure you want to continue?[/bold red]",
529
+ default=False,
530
+ console=self.console,
531
+ ):
532
+ raise ConfigError("Aborted: Target is protected")
533
+
534
+ def _merge_defaults(self, config: dict[str, Any]) -> dict[str, Any]:
535
+ """Merge global and project defaults with config."""
536
+ # Start with global defaults
537
+ merged: dict[str, Any] = dict(self._global_defaults)
538
+
539
+ # Apply project defaults
540
+ self._deep_merge(merged, self._project_defaults)
541
+
542
+ # Apply specific config
543
+ self._deep_merge(merged, config)
544
+
545
+ return merged
546
+
547
+ def _deep_merge(self, base: dict[str, Any], overlay: dict[str, Any]) -> None:
548
+ """Deep merge overlay into base dict (in-place)."""
549
+ for key, value in overlay.items():
550
+ if key in base and isinstance(base[key], dict) and isinstance(value, dict):
551
+ self._deep_merge(base[key], value)
552
+ else:
553
+ base[key] = value
554
+
555
+ def has_project_configs(self) -> bool:
556
+ """Check if project configs are available."""
557
+ self.load_project_config()
558
+ return bool(self._project_configs)
559
+
560
+ def has_global_hosts(self) -> bool:
561
+ """Check if global hosts are available."""
562
+ self.load_global_config()
563
+ return bool(self._global_hosts)
564
+
565
+ def get_project_config_names(self) -> list[str]:
566
+ """Get list of available project config names."""
567
+ self.load_project_config()
568
+ return list(self._project_configs.keys())
569
+
570
+ def get_global_host_names(self) -> list[str]:
571
+ """Get list of available global host names."""
572
+ self.load_global_config()
573
+ return list(self._global_hosts.keys())