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.
- {half_orm_dev-0.17.3a9/half_orm_dev.egg-info → half_orm_dev-0.17.4a2}/PKG-INFO +1 -1
- half_orm_dev-0.17.4a2/half_orm_dev/bootstrap_manager.py +334 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/__init__.py +3 -0
- half_orm_dev-0.17.4a2/half_orm_dev/cli/commands/bootstrap.py +139 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/check.py +19 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/clone.py +15 -2
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/patch.py +23 -8
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/main.py +2 -2
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/database.py +7 -5
- half_orm_dev-0.17.4a2/half_orm_dev/file_executor.py +126 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/hgit.py +129 -0
- half_orm_dev-0.17.4a2/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +103 -0
- half_orm_dev-0.17.4a2/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +203 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/modules.py +6 -10
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patch_manager.py +309 -207
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/sql/half_orm_meta.sql +29 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/release_manager.py +64 -2
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/repo.py +220 -37
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/init_module_template +2 -1
- half_orm_dev-0.17.4a2/half_orm_dev/version.txt +1 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2/half_orm_dev.egg-info}/PKG-INFO +1 -1
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/SOURCES.txt +5 -0
- half_orm_dev-0.17.3a9/half_orm_dev/version.txt +0 -1
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/AUTHORS +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/LICENSE +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/README.md +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/update.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/decorators.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/migration_manager.py +0 -0
- {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
- {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
- {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
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patch_validator.py +0 -0
- {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
- {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
- {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
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/Pipfile +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/pyproject.toml +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/setup.cfg +0 -0
- {half_orm_dev-0.17.3a9 → half_orm_dev-0.17.4a2}/setup.py +0 -0
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
119
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', '
|
|
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):
|