half-orm-dev 0.17.3a9__tar.gz → 0.17.4a2__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 (72) hide show
  1. {half_orm_dev-0.17.3a9/half_orm_dev.egg-info → half_orm_dev-0.17.4a2}/PKG-INFO +1 -1
  2. half_orm_dev-0.17.4a2/half_orm_dev/bootstrap_manager.py +334 -0
  3. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/__init__.py +3 -0
  4. half_orm_dev-0.17.4a2/half_orm_dev/cli/commands/bootstrap.py +139 -0
  5. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/check.py +19 -0
  6. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/clone.py +15 -2
  7. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/patch.py +23 -8
  8. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/main.py +2 -2
  9. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/database.py +7 -5
  10. half_orm_dev-0.17.4a2/half_orm_dev/file_executor.py +126 -0
  11. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/hgit.py +129 -0
  12. half_orm_dev-0.17.4a2/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +103 -0
  13. half_orm_dev-0.17.4a2/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +203 -0
  14. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/modules.py +6 -10
  15. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patch_manager.py +309 -207
  16. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/sql/half_orm_meta.sql +29 -0
  17. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/release_manager.py +64 -2
  18. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/repo.py +220 -37
  19. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/init_module_template +2 -1
  20. half_orm_dev-0.17.4a2/half_orm_dev/version.txt +1 -0
  21. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2/half_orm_dev.egg-info}/PKG-INFO +1 -1
  22. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/SOURCES.txt +5 -0
  23. half_orm_dev-0.17.3a9/half_orm_dev/version.txt +0 -1
  24. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/AUTHORS +0 -0
  25. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/LICENSE +0 -0
  26. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/README.md +0 -0
  27. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/__init__.py +0 -0
  28. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/__init__.py +0 -0
  29. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/apply.py +0 -0
  30. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/init.py +0 -0
  31. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/migrate.py +0 -0
  32. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/release.py +0 -0
  33. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/restore.py +0 -0
  34. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/sync.py +0 -0
  35. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/todo.py +0 -0
  36. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/undo.py +0 -0
  37. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/update.py +0 -0
  38. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/upgrade.py +0 -0
  39. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli_extension.py +0 -0
  40. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/decorators.py +0 -0
  41. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/migration_manager.py +0 -0
  42. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  43. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  44. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  45. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patch_validator.py +0 -0
  46. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  47. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  48. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  49. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/log +0 -0
  50. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/release_file.py +0 -0
  51. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/scripts/repair-metadata.py +0 -0
  52. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/.gitignore +0 -0
  53. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/MANIFEST.in +0 -0
  54. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/Pipfile +0 -0
  55. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/README +0 -0
  56. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/conftest_template +0 -0
  57. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  58. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  59. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_1 +0 -0
  60. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_2 +0 -0
  61. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_3 +0 -0
  62. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/pyproject.toml +0 -0
  63. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/relation_test +0 -0
  64. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/sql_adapter +0 -0
  65. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/warning +0 -0
  66. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/utils.py +0 -0
  67. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  68. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/requires.txt +0 -0
  69. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/top_level.txt +0 -0
  70. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/pyproject.toml +0 -0
  71. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/setup.cfg +0 -0
  72. {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 0.17.3a9
3
+ Version: 0.17.4a2
4
4
  Summary: half_orm development Framework.
5
5
  Author-email: Joël Maïzi <joel.maizi@collorg.org>
6
6
  License-Expression: GPL-3.0-or-later
@@ -0,0 +1,334 @@
1
+ """
2
+ BootstrapManager module for half-orm-dev
3
+
4
+ Manages bootstrap scripts for data initialization. Bootstrap files are
5
+ SQL and Python scripts that initialize application data after database setup.
6
+
7
+ Files are named: <number>-<patch_id>-<version>.<ext>
8
+ Example: 1-init-users-0.1.0.sql, 2-seed-config-0.1.0.py
9
+
10
+ Scripts are executed in numeric order and tracked in half_orm_meta.bootstrap table.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import click
17
+ from pathlib import Path
18
+ from typing import List, Set, Tuple, Optional, TYPE_CHECKING
19
+
20
+ from half_orm_dev.file_executor import (
21
+ execute_sql_file, execute_python_file, FileExecutionError
22
+ )
23
+
24
+ if TYPE_CHECKING:
25
+ from half_orm_dev.repo import Repo
26
+
27
+
28
+ class BootstrapManagerError(Exception):
29
+ """Base exception for BootstrapManager operations."""
30
+ pass
31
+
32
+
33
+ class BootstrapManager:
34
+ """
35
+ Manages bootstrap scripts for data initialization.
36
+
37
+ Bootstrap scripts are SQL and Python files that initialize application
38
+ data after the database schema is created. They are tracked in the
39
+ half_orm_meta.bootstrap table to ensure each script is executed only once.
40
+
41
+ Attributes:
42
+ _repo: Repository instance
43
+ _bootstrap_dir: Path to bootstrap/ directory
44
+ """
45
+
46
+ def __init__(self, repo: 'Repo'):
47
+ """
48
+ Initialize BootstrapManager.
49
+
50
+ Args:
51
+ repo: Repository instance
52
+ """
53
+ self._repo = repo
54
+ self._bootstrap_dir = Path(repo.base_dir) / 'bootstrap'
55
+
56
+ @property
57
+ def bootstrap_dir(self) -> Path:
58
+ """Get path to bootstrap directory."""
59
+ return self._bootstrap_dir
60
+
61
+ def _ensure_bootstrap_table(self) -> None:
62
+ """
63
+ Ensure half_orm_meta.bootstrap table exists.
64
+
65
+ Creates the table if it doesn't exist. This handles the case
66
+ where the table hasn't been created yet (pre-migration databases).
67
+ """
68
+ sql = """
69
+ CREATE TABLE IF NOT EXISTS half_orm_meta.bootstrap (
70
+ filename TEXT PRIMARY KEY,
71
+ version TEXT NOT NULL,
72
+ executed_at TIMESTAMP DEFAULT NOW()
73
+ );
74
+ """
75
+ try:
76
+ self._repo.database.model.execute_query(sql)
77
+ except Exception:
78
+ # Schema might not exist yet, ignore
79
+ pass
80
+
81
+ def get_bootstrap_files(self) -> List[Path]:
82
+ """
83
+ List bootstrap files sorted by numeric prefix.
84
+
85
+ Returns files matching pattern: <number>-<patch_id>-<version>.<ext>
86
+ Sorted numerically on the first field (not lexicographically).
87
+
88
+ Returns:
89
+ List of Path objects for bootstrap files in execution order
90
+ """
91
+ if not self._bootstrap_dir.exists():
92
+ return []
93
+
94
+ files = []
95
+ for file_path in self._bootstrap_dir.iterdir():
96
+ if file_path.is_file() and file_path.suffix in ('.sql', '.py'):
97
+ # Skip README or other non-bootstrap files
98
+ if not re.match(r'^\d+-', file_path.name):
99
+ continue
100
+ files.append(file_path)
101
+
102
+ # Sort by numeric prefix
103
+ def get_numeric_prefix(path: Path) -> int:
104
+ match = re.match(r'^(\d+)-', path.name)
105
+ return int(match.group(1)) if match else 0
106
+
107
+ return sorted(files, key=get_numeric_prefix)
108
+
109
+ def get_executed_files(self) -> Set[str]:
110
+ """
111
+ Get set of already executed filenames from database.
112
+
113
+ Queries half_orm_meta.bootstrap table to get filenames
114
+ that have already been executed.
115
+
116
+ Returns:
117
+ Set of filename strings that have been executed
118
+ """
119
+ # Ensure table exists before querying
120
+ self._ensure_bootstrap_table()
121
+
122
+ try:
123
+ result = self._repo.database.model.execute_query(
124
+ "SELECT filename FROM half_orm_meta.bootstrap"
125
+ )
126
+ return {row[0] for row in result} if result else set()
127
+ except Exception:
128
+ # Table might not exist yet (pre-migration)
129
+ return set()
130
+
131
+ def get_pending_files(self) -> List[Path]:
132
+ """
133
+ Get bootstrap files not yet executed.
134
+
135
+ Returns:
136
+ List of Path objects for files pending execution
137
+ """
138
+ all_files = self.get_bootstrap_files()
139
+ executed = self.get_executed_files()
140
+
141
+ return [f for f in all_files if f.name not in executed]
142
+
143
+ def execute_file(self, file_path: Path) -> None:
144
+ """
145
+ Execute SQL or Python bootstrap file.
146
+
147
+ Args:
148
+ file_path: Path to bootstrap file
149
+
150
+ Raises:
151
+ BootstrapManagerError: If execution fails
152
+ """
153
+ try:
154
+ if file_path.suffix == '.sql':
155
+ execute_sql_file(file_path, self._repo.database.model)
156
+ elif file_path.suffix == '.py':
157
+ output = execute_python_file(file_path, cwd=self._bootstrap_dir)
158
+ if output:
159
+ click.echo(f" Output: {output}")
160
+ else:
161
+ raise BootstrapManagerError(
162
+ f"Unsupported file type: {file_path.suffix}"
163
+ )
164
+ except FileExecutionError as e:
165
+ raise BootstrapManagerError(str(e)) from e
166
+
167
+ def record_execution(self, filename: str, version: str) -> None:
168
+ """
169
+ Record execution in half_orm_meta.bootstrap table.
170
+
171
+ Args:
172
+ filename: Name of the executed file
173
+ version: Version extracted from filename
174
+ """
175
+ sql = """
176
+ INSERT INTO half_orm_meta.bootstrap (filename, version)
177
+ VALUES (%s, %s)
178
+ ON CONFLICT (filename) DO UPDATE SET
179
+ version = EXCLUDED.version,
180
+ executed_at = NOW()
181
+ """
182
+ self._repo.database.model.execute_query(sql, (filename, version))
183
+
184
+ def run_bootstrap(
185
+ self,
186
+ dry_run: bool = False,
187
+ force: bool = False,
188
+ exclude_patch_id: Optional[str] = None
189
+ ) -> dict:
190
+ """
191
+ Execute pending bootstrap files.
192
+
193
+ Args:
194
+ dry_run: If True, show what would be executed without executing
195
+ force: If True, re-execute all files (ignore tracking)
196
+ exclude_patch_id: If provided, skip files belonging to this patch
197
+ (used during patch apply to avoid executing the
198
+ bootstrap file that was just created for the current patch)
199
+
200
+ Returns:
201
+ Dict with execution results:
202
+ - 'executed': List of executed filenames
203
+ - 'skipped': List of skipped filenames (already executed)
204
+ - 'excluded': List of excluded filenames (matching exclude_patch_id)
205
+ - 'errors': List of (filename, error) tuples
206
+ """
207
+ result = {
208
+ 'executed': [],
209
+ 'skipped': [],
210
+ 'excluded': [],
211
+ 'errors': []
212
+ }
213
+
214
+ if force:
215
+ files_to_execute = self.get_bootstrap_files()
216
+ else:
217
+ files_to_execute = self.get_pending_files()
218
+ # Calculate skipped
219
+ all_files = self.get_bootstrap_files()
220
+ executed = self.get_executed_files()
221
+ result['skipped'] = [f.name for f in all_files if f.name in executed]
222
+
223
+ if not files_to_execute:
224
+ return result
225
+
226
+ for file_path in files_to_execute:
227
+ filename = file_path.name
228
+ version = self._extract_version_from_filename(filename)
229
+
230
+ # Check if this file belongs to the excluded patch
231
+ if exclude_patch_id and self._file_belongs_to_patch(filename, exclude_patch_id):
232
+ result['excluded'].append(filename)
233
+ continue
234
+
235
+ if dry_run:
236
+ result['executed'].append(filename)
237
+ continue
238
+
239
+ try:
240
+ click.echo(f" • Executing {filename}...")
241
+ self.execute_file(file_path)
242
+ self.record_execution(filename, version)
243
+ result['executed'].append(filename)
244
+ except BootstrapManagerError as e:
245
+ result['errors'].append((filename, str(e)))
246
+ # Stop on first error
247
+ break
248
+
249
+ return result
250
+
251
+ def _parse_filename(self, filename: str) -> Tuple[int, str, str]:
252
+ """
253
+ Parse bootstrap filename into components.
254
+
255
+ Expected format: <number>-<patch_id>-<version>.<ext>
256
+ Example: '1-init-users-0.1.0.sql' -> (1, 'init-users', '0.1.0')
257
+
258
+ Args:
259
+ filename: Bootstrap filename to parse
260
+
261
+ Returns:
262
+ Tuple of (number, patch_id, version)
263
+
264
+ Raises:
265
+ ValueError: If filename doesn't match expected format
266
+ """
267
+ # Pattern: number-patch_id-X.Y.Z.ext
268
+ match = re.match(r'^(\d+)-(.+)-(\d+\.\d+\.\d+)\.(sql|py)$', filename)
269
+ if not match:
270
+ raise ValueError(f"Invalid bootstrap filename format: {filename}")
271
+
272
+ number = int(match.group(1))
273
+ patch_id = match.group(2)
274
+ version = match.group(3)
275
+
276
+ return number, patch_id, version
277
+
278
+ def _file_belongs_to_patch(self, filename: str, patch_id: str) -> bool:
279
+ """
280
+ Check if a bootstrap file belongs to a specific patch.
281
+
282
+ Args:
283
+ filename: Bootstrap filename (e.g., '1-my-patch-0.1.0.sql')
284
+ patch_id: Patch identifier to check against
285
+
286
+ Returns:
287
+ True if the file belongs to the patch, False otherwise
288
+ """
289
+ try:
290
+ _, file_patch_id, _ = self._parse_filename(filename)
291
+ return file_patch_id == patch_id
292
+ except ValueError:
293
+ return False
294
+
295
+ def _extract_version_from_filename(self, filename: str) -> str:
296
+ """
297
+ Extract version from bootstrap filename.
298
+
299
+ Args:
300
+ filename: Bootstrap filename
301
+
302
+ Returns:
303
+ Version string or 'unknown' if parsing fails
304
+ """
305
+ try:
306
+ _, _, version = self._parse_filename(filename)
307
+ return version
308
+ except ValueError:
309
+ return 'unknown'
310
+
311
+ def get_next_bootstrap_number(self) -> int:
312
+ """
313
+ Get next available number for bootstrap file.
314
+
315
+ Returns:
316
+ Next number (1-based) for naming a new bootstrap file
317
+ """
318
+ files = self.get_bootstrap_files()
319
+ if not files:
320
+ return 1
321
+
322
+ # Get max number from existing files
323
+ max_num = 0
324
+ for file_path in files:
325
+ match = re.match(r'^(\d+)-', file_path.name)
326
+ if match:
327
+ num = int(match.group(1))
328
+ max_num = max(max_num, num)
329
+
330
+ return max_num + 1
331
+
332
+ def ensure_bootstrap_dir(self) -> None:
333
+ """Create bootstrap directory if it doesn't exist."""
334
+ self._bootstrap_dir.mkdir(exist_ok=True)
@@ -14,6 +14,7 @@ from .update import update
14
14
  from .upgrade import upgrade
15
15
  from .check import check
16
16
  from .migrate import migrate
17
+ from .bootstrap import bootstrap
17
18
  from .todo import apply_release
18
19
  from .todo import rollback
19
20
 
@@ -32,6 +33,7 @@ ALL_COMMANDS = {
32
33
  'upgrade': upgrade, # Adapted for production
33
34
  'check': check, # Project health check and updates
34
35
  'migrate': migrate, # Repository migration after upgrade
36
+ 'bootstrap': bootstrap, # Execute data initialization scripts
35
37
  # 🚧 (stubs)
36
38
  'apply_release': apply_release,
37
39
 
@@ -52,6 +54,7 @@ __all__ = [
52
54
  'upgrade',
53
55
  'check',
54
56
  'migrate',
57
+ 'bootstrap',
55
58
  'rollback',
56
59
  # Adapted commands
57
60
  'sync_package',
@@ -0,0 +1,139 @@
1
+ """
2
+ Bootstrap command - Execute data initialization scripts.
3
+
4
+ Runs bootstrap scripts from the bootstrap/ directory to initialize
5
+ application data after database setup.
6
+ """
7
+
8
+ import click
9
+ from half_orm_dev.repo import Repo
10
+ from half_orm_dev.bootstrap_manager import BootstrapManager, BootstrapManagerError
11
+ from half_orm import utils
12
+
13
+
14
+ @click.command()
15
+ @click.option(
16
+ '--dry-run',
17
+ is_flag=True,
18
+ help='Show what would be executed without executing'
19
+ )
20
+ @click.option(
21
+ '--force',
22
+ is_flag=True,
23
+ help='Re-execute all files (ignore tracking)'
24
+ )
25
+ @click.option(
26
+ '--verbose', '-v',
27
+ is_flag=True,
28
+ help='Show detailed information'
29
+ )
30
+ def bootstrap(dry_run: bool, force: bool, verbose: bool) -> None:
31
+ """
32
+ Execute bootstrap scripts to initialize application data.
33
+
34
+ Bootstrap scripts are SQL and Python files in the bootstrap/ directory
35
+ that initialize application data after the database schema is created.
36
+
37
+ Files are named: <number>-<patch_id>-<version>.<ext>
38
+ Example: 1-init-users-0.1.0.sql, 2-seed-config-0.1.0.py
39
+
40
+ Scripts are executed in numeric order and tracked in the database
41
+ to ensure each script is executed only once.
42
+
43
+ EXAMPLES:
44
+ # Execute pending bootstrap scripts
45
+ half_orm dev bootstrap
46
+
47
+ # Preview what would be executed
48
+ half_orm dev bootstrap --dry-run
49
+
50
+ # Re-execute all scripts (ignore tracking)
51
+ half_orm dev bootstrap --force
52
+
53
+ NOTES:
54
+ - SQL files are executed via halfORM
55
+ - Python files are executed as subprocesses
56
+ - Execution is tracked in half_orm_meta.bootstrap table
57
+ - Use --force to re-execute previously run scripts
58
+ """
59
+ try:
60
+ repo = Repo()
61
+ bootstrap_mgr = BootstrapManager(repo)
62
+
63
+ # Check if bootstrap directory exists
64
+ if not bootstrap_mgr.bootstrap_dir.exists():
65
+ click.echo(f"ℹ️ No bootstrap directory found at {bootstrap_mgr.bootstrap_dir}")
66
+ click.echo(f" Create bootstrap/ directory with data scripts to use this command.")
67
+ return
68
+
69
+ # Get files info
70
+ all_files = bootstrap_mgr.get_bootstrap_files()
71
+ if not all_files:
72
+ click.echo(f"ℹ️ No bootstrap files found in {bootstrap_mgr.bootstrap_dir}")
73
+ return
74
+
75
+ # Display header
76
+ if dry_run:
77
+ click.echo(f"🔍 {utils.Color.bold('Dry run mode')} - showing what would be executed")
78
+ click.echo()
79
+
80
+ if force:
81
+ click.echo(f"⚠️ {utils.Color.bold('Force mode')} - re-executing all files")
82
+ click.echo()
83
+
84
+ # Run bootstrap
85
+ result = bootstrap_mgr.run_bootstrap(dry_run=dry_run, force=force)
86
+
87
+ # Display results
88
+ _display_results(result, dry_run, verbose)
89
+
90
+ except BootstrapManagerError as e:
91
+ click.echo(utils.Color.red(f"❌ Bootstrap error: {e}"), err=True)
92
+ raise click.Abort()
93
+ except Exception as e:
94
+ click.echo(utils.Color.red(f"❌ Error: {e}"), err=True)
95
+ if verbose:
96
+ import traceback
97
+ traceback.print_exc()
98
+ raise click.Abort()
99
+
100
+
101
+ def _display_results(result: dict, dry_run: bool, verbose: bool) -> None:
102
+ """Display bootstrap execution results."""
103
+ executed = result.get('executed', [])
104
+ skipped = result.get('skipped', [])
105
+ errors = result.get('errors', [])
106
+
107
+ # Display executed files
108
+ if executed:
109
+ verb = "Would execute" if dry_run else "Executed"
110
+ click.echo(f"✓ {utils.Color.green(f'{verb} {len(executed)} file(s):')}")
111
+ for filename in executed:
112
+ click.echo(f" • {filename}")
113
+ click.echo()
114
+
115
+ # Display skipped files (already executed)
116
+ if skipped and verbose:
117
+ click.echo(f"ℹ️ {utils.Color.blue(f'Skipped {len(skipped)} already executed file(s):')}")
118
+ for filename in skipped:
119
+ click.echo(f" • {filename}")
120
+ click.echo()
121
+ elif skipped and not verbose:
122
+ click.echo(f"ℹ️ Skipped {len(skipped)} already executed file(s) (use -v to see list)")
123
+ click.echo()
124
+
125
+ # Display errors
126
+ if errors:
127
+ click.echo(utils.Color.red(f"❌ {len(errors)} error(s) occurred:"))
128
+ for filename, error_msg in errors:
129
+ click.echo(f" • {filename}: {error_msg}")
130
+ click.echo()
131
+
132
+ # Summary
133
+ if not executed and not errors:
134
+ if skipped:
135
+ click.echo(f"✓ {utils.Color.green('All bootstrap files have already been executed.')}")
136
+ else:
137
+ click.echo(f"ℹ️ No bootstrap files to execute.")
138
+ elif not errors and not dry_run:
139
+ click.echo(f"✓ {utils.Color.green('Bootstrap completed successfully.')}")
@@ -117,6 +117,25 @@ def _display_check_results(repo, result: dict, dry_run: bool, verbose: bool):
117
117
  elif verbose:
118
118
  click.echo(f"✓ {utils.Color.green('Pre-commit hook up to date')}")
119
119
 
120
+ # Branch sync results
121
+ branch_sync = result.get('branch_sync', {})
122
+ synced = branch_sync.get('synced', [])
123
+ created = branch_sync.get('created', [])
124
+ sync_errors = branch_sync.get('errors', [])
125
+
126
+ if synced or created:
127
+ total = len(synced) + len(created)
128
+ click.echo(f"\n🔄 {utils.Color.bold('Branches synchronized')} ({total}):")
129
+ for branch in synced:
130
+ click.echo(f" ✓ {utils.Color.green(branch)} (updated)")
131
+ for branch in created:
132
+ click.echo(f" ✓ {utils.Color.green(branch)} (new)")
133
+
134
+ if sync_errors:
135
+ click.echo(f"\n⚠️ {utils.Color.bold('Sync errors')} ({len(sync_errors)}):")
136
+ for branch, error in sync_errors:
137
+ click.echo(f" ✗ {utils.Color.red(branch)}: {error}")
138
+
120
139
  # Active branches
121
140
  active = result.get('active_branches', {})
122
141
  patch_branches = active.get('patch_branches', [])
@@ -14,9 +14,13 @@ from half_orm_dev.repo import Repo, RepoError
14
14
  @click.argument('git_origin')
15
15
  @click.option('--database-name', default=None, help='Custom local database name (default: use project name)')
16
16
  @click.option('--dest-dir', default=None, help='Destination directory name (default: infer from git URL)')
17
+ @click.option('--host', default='localhost', help='PostgreSQL host (default: localhost)')
18
+ @click.option('--port', default=5432, type=int, help='PostgreSQL port (default: 5432)')
19
+ @click.option('--user', default=None, help='Database user (default: $USER)')
20
+ @click.option('--password', default=None, help='Database password (prompts if missing)')
17
21
  @click.option('--production', is_flag=True, help='Production mode (default: False)')
18
22
  @click.option('--no-create-db', is_flag=True, help='Skip database creation (database must exist)')
19
- def clone(git_origin, database_name, dest_dir, production, no_create_db):
23
+ def clone(git_origin, database_name, dest_dir, host, port, user, password, production, no_create_db):
20
24
  """
21
25
  Clone existing half_orm_dev project and setup local database.
22
26
 
@@ -55,12 +59,21 @@ def clone(git_origin, database_name, dest_dir, production, no_create_db):
55
59
  click.echo(f"🔄 Cloning half_orm project from {git_origin}...")
56
60
  click.echo()
57
61
 
62
+ # Build connection options
63
+ connection_options = {
64
+ 'host': host,
65
+ 'port': port,
66
+ 'user': user,
67
+ 'password': password,
68
+ 'production': production
69
+ }
70
+
58
71
  # Execute clone
59
72
  Repo.clone_repo(
60
73
  git_origin=git_origin,
61
74
  database_name=database_name,
62
75
  dest_dir=dest_dir,
63
- production=production,
76
+ connection_options=connection_options,
64
77
  create_db=not no_create_db
65
78
  )
66
79
 
@@ -13,6 +13,7 @@ Replaces legacy commands:
13
13
  """
14
14
 
15
15
  import click
16
+ from pathlib import Path
16
17
  from typing import Optional
17
18
 
18
19
  from half_orm_dev.repo import Repo
@@ -107,7 +108,13 @@ def patch_create(patch_id: str, description: Optional[str] = None, before: Optio
107
108
 
108
109
 
109
110
  @patch.command('apply')
110
- def patch_apply() -> None:
111
+ @click.option(
112
+ '--from-dump',
113
+ type=click.Path(exists=True, dir_okay=False, resolve_path=True),
114
+ help='Restore from pg_dump SQL file instead of schema.sql. '
115
+ 'Useful for testing with production data.'
116
+ )
117
+ def patch_apply(from_dump: Optional[str]) -> None:
111
118
  """
112
119
  Apply current patch files to database.
113
120
 
@@ -115,15 +122,14 @@ def patch_apply() -> None:
115
122
  patch from current branch name and executes complete workflow:
116
123
  database restoration, patch application, and code generation.
117
124
 
118
- This command has no parameters - patch detection is automatic from
119
- the current Git branch. All business logic is delegated to
120
- PatchManager.apply_patch_complete_workflow().
125
+ Patch detection is automatic from the current Git branch.
126
+ All business logic is delegated to PatchManager.apply_patch_complete_workflow().
121
127
 
122
128
  \b
123
129
  Workflow:
124
130
  1. Validate current branch is ho-patch/*
125
131
  2. Extract patch_id from branch name
126
- 3. Restore database from model/schema.sql
132
+ 3. Restore database from model/schema.sql (or --from-dump file)
127
133
  4. Apply patch SQL/Python files in lexicographic order
128
134
  5. Generate halfORM Python code via modules.py
129
135
  6. Display detailed report with next steps
@@ -136,9 +142,12 @@ def patch_apply() -> None:
136
142
 
137
143
  \b
138
144
  Examples:
139
- On branch ho-patch/456-user-auth:
145
+ # Standard workflow (restore from schema.sql):
140
146
  $ half_orm dev patch apply
141
147
 
148
+ # Using production dump for realistic data:
149
+ $ half_orm dev patch apply --from-dump /path/to/prod_dump.sql
150
+
142
151
  \b
143
152
  Output:
144
153
  ✓ Current branch: ho-patch/456-user-auth
@@ -183,15 +192,21 @@ def patch_apply() -> None:
183
192
  # Display current context
184
193
  click.echo(f"✓ Current branch: {utils.Color.bold(current_branch)}")
185
194
  click.echo(f"✓ Detected patch: {utils.Color.bold(patch_id)}")
195
+ if from_dump:
196
+ click.echo(f"✓ Using dump file: {utils.Color.bold(from_dump)}")
186
197
  click.echo()
187
198
 
188
199
  # Delegate to PatchManager
189
200
  click.echo("Applying patch...")
190
- result = repo.patch_manager.apply_patch_complete_workflow(patch_id)
201
+ dump_path = Path(from_dump) if from_dump else None
202
+ result = repo.patch_manager.apply_patch_complete_workflow(patch_id, from_dump=dump_path)
191
203
 
192
204
  # Display success
193
205
  click.echo(f"✓ {utils.Color.green('Patch applied successfully!')}")
194
- click.echo(f"✓ Database restored from model/schema.sql")
206
+ if result.get('used_dump'):
207
+ click.echo(f"✓ Database restored from dump file")
208
+ else:
209
+ click.echo(f"✓ Database restored from model/schema.sql")
195
210
  click.echo()
196
211
 
197
212
  # Display applied files
@@ -59,10 +59,10 @@ class Hop:
59
59
  # Development mode (metadata present)
60
60
  if self.__repo.database.production:
61
61
  # PRODUCTION ENVIRONMENT - Release deployment only
62
- return ['update', 'upgrade', 'check']
62
+ return ['update', 'upgrade', 'bootstrap']
63
63
  else:
64
64
  # DEVELOPMENT ENVIRONMENT - Patch development
65
- return ['patch', 'release', 'check']
65
+ return ['patch', 'release', 'check', 'bootstrap']
66
66
 
67
67
  @property
68
68
  def repo_checked(self):