half-orm-dev 0.17.3a4__py3-none-any.whl → 0.17.3a6__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.
- half_orm_dev/cli/commands/release.py +103 -24
- half_orm_dev/database.py +70 -5
- half_orm_dev/patch_manager.py +1 -1
- half_orm_dev/release_manager.py +352 -56
- half_orm_dev/repo.py +94 -25
- half_orm_dev/templates/README +36 -2
- half_orm_dev/version.txt +1 -1
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/METADATA +1 -1
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/RECORD +13 -13
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/WHEEL +1 -1
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/licenses/AUTHORS +0 -0
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/licenses/LICENSE +0 -0
- {half_orm_dev-0.17.3a4.dist-info → half_orm_dev-0.17.3a6.dist-info}/top_level.txt +0 -0
|
@@ -369,39 +369,118 @@ def release_hotfix(version: Optional[str] = None) -> None:
|
|
|
369
369
|
|
|
370
370
|
|
|
371
371
|
@release.command('apply')
|
|
372
|
-
@click.
|
|
373
|
-
|
|
372
|
+
@click.option(
|
|
373
|
+
'--skip-tests',
|
|
374
|
+
is_flag=True,
|
|
375
|
+
help='Skip running pytest after applying patches'
|
|
376
|
+
)
|
|
377
|
+
def release_apply(skip_tests: bool) -> None:
|
|
374
378
|
"""
|
|
375
|
-
|
|
379
|
+
Apply all patches for integration testing.
|
|
380
|
+
|
|
381
|
+
Restores the database from production schema and applies ALL patches
|
|
382
|
+
(candidates + staged) to test the complete release integration.
|
|
383
|
+
Optionally runs pytest to validate the release.
|
|
376
384
|
|
|
377
|
-
|
|
378
|
-
|
|
385
|
+
Unlike 'patch apply' which only applies validated (staged) patches,
|
|
386
|
+
this command applies ALL patches including candidates that haven't
|
|
387
|
+
passed individual validation yet. This enables testing the complete
|
|
388
|
+
release before promotion.
|
|
379
389
|
|
|
380
390
|
\b
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
391
|
+
Workflow:
|
|
392
|
+
1. Validate on release branch (ho-release/X.Y.Z)
|
|
393
|
+
2. Restore database from model/schema.sql
|
|
394
|
+
3. Apply all RC patches (if any)
|
|
395
|
+
4. Apply ALL TOML patches (candidates + staged)
|
|
396
|
+
5. Generate Python code
|
|
397
|
+
6. Run pytest (unless --skip-tests)
|
|
398
|
+
|
|
399
|
+
\b
|
|
400
|
+
Requirements:
|
|
401
|
+
- Must be on ho-release/X.Y.Z branch
|
|
402
|
+
- Development release must exist
|
|
384
403
|
|
|
385
404
|
\b
|
|
386
405
|
Examples:
|
|
387
|
-
|
|
406
|
+
Apply all patches and run tests:
|
|
388
407
|
$ half_orm dev release apply
|
|
389
408
|
|
|
390
|
-
|
|
391
|
-
$ half_orm dev release apply
|
|
392
|
-
|
|
393
|
-
Test stage release:
|
|
394
|
-
$ half_orm dev release apply 1.3.5-stage
|
|
409
|
+
Apply patches without running tests:
|
|
410
|
+
$ half_orm dev release apply --skip-tests
|
|
395
411
|
|
|
396
412
|
\b
|
|
397
|
-
|
|
413
|
+
Output:
|
|
414
|
+
✓ Applied 5 patches for version 1.3.6
|
|
415
|
+
✓ Tests passed (42 tests)
|
|
416
|
+
|
|
417
|
+
Or on failure:
|
|
418
|
+
✗ Tests failed
|
|
419
|
+
<test output>
|
|
398
420
|
"""
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
421
|
+
try:
|
|
422
|
+
# Get repository instance
|
|
423
|
+
repo = Repo()
|
|
424
|
+
release_mgr = repo.release_manager
|
|
425
|
+
|
|
426
|
+
# Display context
|
|
427
|
+
click.echo("🔄 Applying all release patches for integration testing...")
|
|
428
|
+
click.echo()
|
|
429
|
+
|
|
430
|
+
# Apply release
|
|
431
|
+
result = release_mgr.apply_release(run_tests=not skip_tests)
|
|
432
|
+
|
|
433
|
+
# Display results
|
|
434
|
+
version = result['version']
|
|
435
|
+
patches = result['patches_applied']
|
|
436
|
+
candidates = result.get('candidates_merged', [])
|
|
437
|
+
patch_count = len(patches)
|
|
438
|
+
|
|
439
|
+
click.echo(f"✓ {utils.Color.green('Patches applied successfully!')}")
|
|
440
|
+
click.echo()
|
|
441
|
+
click.echo(f" Version: {utils.Color.bold(version)}")
|
|
442
|
+
click.echo(f" Patches: {utils.Color.bold(str(patch_count))}")
|
|
443
|
+
if candidates:
|
|
444
|
+
click.echo(f" Candidates merged: {utils.Color.bold(str(len(candidates)))}")
|
|
445
|
+
click.echo()
|
|
446
|
+
|
|
447
|
+
# Show candidates merged (simulated merges)
|
|
448
|
+
if candidates:
|
|
449
|
+
click.echo(" Candidate branches merged (simulated):")
|
|
450
|
+
for patch_id in candidates:
|
|
451
|
+
click.echo(f" • ho-patch/{patch_id}")
|
|
452
|
+
click.echo()
|
|
453
|
+
|
|
454
|
+
# Show patches applied
|
|
455
|
+
if patches:
|
|
456
|
+
click.echo(" Applied patches (SQL):")
|
|
457
|
+
for patch_id in patches:
|
|
458
|
+
click.echo(f" • {patch_id}")
|
|
459
|
+
click.echo()
|
|
460
|
+
|
|
461
|
+
# Show test results
|
|
462
|
+
if skip_tests:
|
|
463
|
+
click.echo(f"⚠️ {utils.Color.bold('Tests skipped')} (--skip-tests flag)")
|
|
464
|
+
elif result['tests_passed'] is None:
|
|
465
|
+
click.echo(f"⚠️ {utils.Color.bold('Tests not run')} (pytest not found)")
|
|
466
|
+
elif result['tests_passed']:
|
|
467
|
+
click.echo(f"✓ {utils.Color.green('Tests passed!')}")
|
|
468
|
+
else:
|
|
469
|
+
click.echo(f"✗ {utils.Color.red('Tests failed!')}")
|
|
470
|
+
click.echo()
|
|
471
|
+
click.echo("Test output:")
|
|
472
|
+
click.echo(result['test_output'])
|
|
473
|
+
sys.exit(1)
|
|
474
|
+
|
|
475
|
+
click.echo()
|
|
476
|
+
click.echo("📝 Next steps:")
|
|
477
|
+
if result['tests_passed'] or result['tests_passed'] is None:
|
|
478
|
+
click.echo(f" • Promote to RC: {utils.Color.bold('half_orm dev release promote rc')}")
|
|
479
|
+
else:
|
|
480
|
+
click.echo(f" • Fix failing tests")
|
|
481
|
+
click.echo(f" • Re-run: {utils.Color.bold('half_orm dev release apply')}")
|
|
482
|
+
|
|
483
|
+
except ReleaseManagerError as e:
|
|
484
|
+
click.echo(f"❌ {utils.Color.red('Release apply failed:')}", err=True)
|
|
485
|
+
click.echo(f" {str(e)}", err=True)
|
|
486
|
+
sys.exit(1)
|
half_orm_dev/database.py
CHANGED
|
@@ -93,6 +93,10 @@ class Database:
|
|
|
93
93
|
if get_release and self.__repo.devel:
|
|
94
94
|
self.__last_release = self.last_release
|
|
95
95
|
|
|
96
|
+
@property
|
|
97
|
+
def name(self):
|
|
98
|
+
return self.__name
|
|
99
|
+
|
|
96
100
|
@property
|
|
97
101
|
def last_release(self):
|
|
98
102
|
"Returns the last release"
|
|
@@ -177,10 +181,22 @@ class Database:
|
|
|
177
181
|
*command_args
|
|
178
182
|
)
|
|
179
183
|
|
|
180
|
-
def register_release(self, major, minor, patch, changelog=None):
|
|
181
|
-
"
|
|
184
|
+
def register_release(self, major, minor, patch, pre_release='', pre_release_num='', changelog=None):
|
|
185
|
+
"""
|
|
186
|
+
Register the release into half_orm_meta.hop_release.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
major: Major version number
|
|
190
|
+
minor: Minor version number
|
|
191
|
+
patch: Patch version number
|
|
192
|
+
pre_release: Pre-release type ('alpha', 'beta', 'rc', or '' for production)
|
|
193
|
+
pre_release_num: Pre-release number (e.g., '1' for rc1)
|
|
194
|
+
changelog: Optional changelog text
|
|
195
|
+
"""
|
|
182
196
|
return self.__model.get_relation_class('half_orm_meta.hop_release')(
|
|
183
|
-
major=major, minor=minor, patch=patch,
|
|
197
|
+
major=major, minor=minor, patch=patch,
|
|
198
|
+
pre_release=pre_release, pre_release_num=pre_release_num,
|
|
199
|
+
changelog=changelog
|
|
184
200
|
).ho_insert()
|
|
185
201
|
|
|
186
202
|
def _generate_schema_sql(self, version: str, model_dir: Path) -> Path:
|
|
@@ -271,6 +287,7 @@ class Database:
|
|
|
271
287
|
|
|
272
288
|
# Construct versioned schema file path
|
|
273
289
|
schema_file = model_dir / f"schema-{version}.sql"
|
|
290
|
+
temp_schema_file = model_dir / f".schema-{version}.sql.tmp"
|
|
274
291
|
|
|
275
292
|
# Generate schema dump using pg_dump
|
|
276
293
|
try:
|
|
@@ -278,16 +295,45 @@ class Database:
|
|
|
278
295
|
'pg_dump',
|
|
279
296
|
self.__name,
|
|
280
297
|
'--schema-only',
|
|
298
|
+
'--no-owner',
|
|
281
299
|
'-f',
|
|
282
|
-
str(
|
|
300
|
+
str(temp_schema_file)
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
# Filter out version-specific lines for cross-version compatibility
|
|
304
|
+
content = temp_schema_file.read_text()
|
|
305
|
+
filtered_lines = []
|
|
306
|
+
# SET commands that are version-specific and should be removed
|
|
307
|
+
version_specific_sets = (
|
|
308
|
+
'SET transaction_timeout', # PG17+
|
|
283
309
|
)
|
|
310
|
+
for line in content.split('\n'):
|
|
311
|
+
# Skip \restrict and \unrestrict lines
|
|
312
|
+
if line.startswith('\\restrict') or line.startswith('\\unrestrict'):
|
|
313
|
+
continue
|
|
314
|
+
# Skip "-- Dumped from/by" comments (version-specific)
|
|
315
|
+
if line.startswith('-- Dumped from') or line.startswith('-- Dumped by'):
|
|
316
|
+
continue
|
|
317
|
+
# Skip version-specific SET commands
|
|
318
|
+
if any(line.startswith(s) for s in version_specific_sets):
|
|
319
|
+
continue
|
|
320
|
+
filtered_lines.append(line)
|
|
321
|
+
|
|
322
|
+
schema_file.write_text('\n'.join(filtered_lines))
|
|
284
323
|
except Exception as e:
|
|
285
324
|
raise Exception(f"Failed to generate schema SQL: {e}") from e
|
|
325
|
+
finally:
|
|
326
|
+
# Clean up temporary file
|
|
327
|
+
if temp_schema_file.exists():
|
|
328
|
+
temp_schema_file.unlink()
|
|
286
329
|
|
|
287
330
|
# Generate metadata dump (half_orm_meta data only)
|
|
331
|
+
# Keep only COPY statements to avoid version-specific SET commands
|
|
288
332
|
metadata_file = model_dir / f"metadata-{version}.sql"
|
|
333
|
+
temp_file = model_dir / f".metadata-{version}.sql.tmp"
|
|
289
334
|
|
|
290
335
|
try:
|
|
336
|
+
# Dump to temporary file
|
|
291
337
|
self.execute_pg_command(
|
|
292
338
|
'pg_dump',
|
|
293
339
|
self.__name,
|
|
@@ -296,10 +342,29 @@ class Database:
|
|
|
296
342
|
'--table=half_orm_meta.hop_release',
|
|
297
343
|
'--table=half_orm_meta.hop_release_issue',
|
|
298
344
|
'-f',
|
|
299
|
-
str(
|
|
345
|
+
str(temp_file)
|
|
300
346
|
)
|
|
347
|
+
|
|
348
|
+
# Filter to keep only COPY blocks (COPY ... FROM stdin; ... \.)
|
|
349
|
+
content = temp_file.read_text()
|
|
350
|
+
filtered_lines = []
|
|
351
|
+
in_copy_block = False
|
|
352
|
+
for line in content.split('\n'):
|
|
353
|
+
if line.startswith('COPY '):
|
|
354
|
+
in_copy_block = True
|
|
355
|
+
if in_copy_block:
|
|
356
|
+
filtered_lines.append(line)
|
|
357
|
+
if line == '\\.':
|
|
358
|
+
in_copy_block = False
|
|
359
|
+
filtered_lines.append('') # Empty line between blocks
|
|
360
|
+
|
|
361
|
+
metadata_file.write_text('\n'.join(filtered_lines))
|
|
301
362
|
except Exception as e:
|
|
302
363
|
raise Exception(f"Failed to generate metadata SQL: {e}") from e
|
|
364
|
+
finally:
|
|
365
|
+
# Clean up temporary file
|
|
366
|
+
if temp_file.exists():
|
|
367
|
+
temp_file.unlink()
|
|
303
368
|
|
|
304
369
|
# Create or update symlink
|
|
305
370
|
symlink_path = model_dir / "schema.sql"
|
half_orm_dev/patch_manager.py
CHANGED
|
@@ -1916,7 +1916,7 @@ class PatchManager:
|
|
|
1916
1916
|
# This also syncs .hop/ to all active branches automatically via decorator
|
|
1917
1917
|
try:
|
|
1918
1918
|
self._repo.commit_and_sync_to_active_branches(
|
|
1919
|
-
message=f"[HOP] move patch #{patch_id} from candidate to stage %{version}"
|
|
1919
|
+
message=f"[HOP] move patch #{patch_id} from candidate to stage %{version}\nFixes #{patch_id}."
|
|
1920
1920
|
)
|
|
1921
1921
|
except Exception as e:
|
|
1922
1922
|
raise PatchManagerError(f"Failed to commit/push changes: {e}")
|
half_orm_dev/release_manager.py
CHANGED
|
@@ -744,27 +744,64 @@ class ReleaseManager:
|
|
|
744
744
|
for patch_id in stage_patches:
|
|
745
745
|
self._repo.patch_manager.apply_patch_files(patch_id, self._repo.model)
|
|
746
746
|
|
|
747
|
-
def
|
|
747
|
+
def _collect_all_version_patches(self, version: str) -> List[str]:
|
|
748
748
|
"""
|
|
749
|
-
|
|
749
|
+
Collect all patches for a version including hotfixes.
|
|
750
|
+
|
|
751
|
+
Returns patches from:
|
|
752
|
+
1. Base release (X.Y.Z.txt)
|
|
753
|
+
2. All hotfixes in order (X.Y.Z-hotfix1.txt, X.Y.Z-hotfix2.txt, ...)
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
version: Base version string (e.g., "1.2.0")
|
|
757
|
+
|
|
758
|
+
Returns:
|
|
759
|
+
List of all patch IDs in application order
|
|
760
|
+
|
|
761
|
+
Examples:
|
|
762
|
+
# With 1.2.0.txt containing [a, b] and 1.2.0-hotfix1.txt containing [c]
|
|
763
|
+
patches = mgr._collect_all_version_patches("1.2.0")
|
|
764
|
+
# Returns: ["a", "b", "c"]
|
|
765
|
+
"""
|
|
766
|
+
all_patches = []
|
|
767
|
+
|
|
768
|
+
# 1. Base release patches
|
|
769
|
+
base_file = f"{version}.txt"
|
|
770
|
+
all_patches.extend(self.read_release_patches(base_file))
|
|
771
|
+
|
|
772
|
+
# 2. Hotfix patches in order
|
|
773
|
+
hotfix_files = sorted(self._releases_dir.glob(f"{version}-hotfix*.txt"))
|
|
774
|
+
for hotfix_file in hotfix_files:
|
|
775
|
+
all_patches.extend(self.read_release_patches(hotfix_file.name))
|
|
776
|
+
|
|
777
|
+
return all_patches
|
|
778
|
+
|
|
779
|
+
def _generate_data_sql_file(self, patch_list: List[str], version: str) -> Optional[Path]:
|
|
780
|
+
"""
|
|
781
|
+
Generate model/data-X.Y.Z.sql file from patches with @HOP:data annotation.
|
|
750
782
|
|
|
751
783
|
Collects all SQL files marked with `-- @HOP:data` from the patch list
|
|
752
784
|
and concatenates them into a single data SQL file for from-scratch
|
|
753
|
-
installations.
|
|
785
|
+
installations (clone, restore_database_from_schema).
|
|
786
|
+
|
|
787
|
+
This file is only generated for production releases. RC and hotfix
|
|
788
|
+
versions don't need this file because:
|
|
789
|
+
- In production upgrades, data is inserted by patch application
|
|
790
|
+
- This file is only for from-scratch installations
|
|
754
791
|
|
|
755
792
|
Args:
|
|
756
793
|
patch_list: List of patch IDs to process
|
|
757
|
-
|
|
794
|
+
version: Version string (e.g., "0.17.0")
|
|
758
795
|
|
|
759
796
|
Returns:
|
|
760
|
-
Path to generated file, or None if no data files found
|
|
797
|
+
Path to generated file (model/data-X.Y.Z.sql), or None if no data files found
|
|
761
798
|
|
|
762
799
|
Examples:
|
|
763
800
|
self._generate_data_sql_file(
|
|
764
801
|
["456-auth", "457-roles"],
|
|
765
|
-
"
|
|
802
|
+
"0.17.0"
|
|
766
803
|
)
|
|
767
|
-
# Generates
|
|
804
|
+
# Generates model/data-0.17.0.sql with data from both patches
|
|
768
805
|
"""
|
|
769
806
|
if not patch_list:
|
|
770
807
|
return None
|
|
@@ -777,16 +814,17 @@ class ReleaseManager:
|
|
|
777
814
|
# No data files found - skip generation
|
|
778
815
|
return None
|
|
779
816
|
|
|
780
|
-
# Generate output file
|
|
781
|
-
|
|
817
|
+
# Generate output file in model/ directory
|
|
818
|
+
output_filename = f"data-{version}.sql"
|
|
819
|
+
output_path = Path(self._repo.model_dir) / output_filename
|
|
782
820
|
|
|
783
821
|
with output_path.open('w', encoding='utf-8') as out_file:
|
|
784
822
|
# Write header
|
|
785
|
-
out_file.write(f"-- Data file for {
|
|
823
|
+
out_file.write(f"-- Data file for version {version}\n")
|
|
786
824
|
out_file.write(f"-- Generated from patches: {', '.join(patch_list)}\n")
|
|
787
825
|
out_file.write(f"-- This file contains reference data (DML) for from-scratch installations\n")
|
|
788
826
|
out_file.write(f"--\n")
|
|
789
|
-
out_file.write(f"-- Usage:
|
|
827
|
+
out_file.write(f"-- Usage: Automatically loaded by restore_database_from_schema()\n")
|
|
790
828
|
out_file.write(f"--\n\n")
|
|
791
829
|
|
|
792
830
|
# Concatenate all data files
|
|
@@ -811,26 +849,30 @@ class ReleaseManager:
|
|
|
811
849
|
|
|
812
850
|
except Exception as e:
|
|
813
851
|
raise ReleaseManagerError(
|
|
814
|
-
f"Failed to generate data SQL file {
|
|
852
|
+
f"Failed to generate data SQL file data-{version}.sql: {e}"
|
|
815
853
|
)
|
|
816
854
|
|
|
817
855
|
def get_all_release_context_patches(self) -> List[str]:
|
|
818
856
|
"""
|
|
819
|
-
|
|
857
|
+
Get all validated patches for the next release context.
|
|
858
|
+
|
|
859
|
+
Sequential application of incremental RCs + staged patches from TOML.
|
|
860
|
+
- rc1: initial patches (e.g., 123, 456, 789)
|
|
861
|
+
- rc2: new patches (e.g., 999)
|
|
862
|
+
- rc3: new patches (e.g., 888, 777)
|
|
863
|
+
- TOML: only "staged" patches (validated via patch merge)
|
|
820
864
|
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
- rc3: patches nouveaux (ex: 888, 777)
|
|
825
|
-
- TOML: TOUS les patches (candidates + staged) dans l'ordre
|
|
865
|
+
"candidate" patches are NOT included because they have not passed
|
|
866
|
+
the validation process (tests) that occurs during patch merge.
|
|
867
|
+
Only "staged" patches are guaranteed to have passed tests.
|
|
826
868
|
|
|
827
|
-
|
|
869
|
+
The current patch is applied separately by apply_patch_complete_workflow.
|
|
828
870
|
|
|
829
|
-
|
|
871
|
+
Note: A future "release apply" command could allow applying all patches
|
|
872
|
+
(candidates + staged) in a temporary branch for integration testing.
|
|
830
873
|
|
|
831
874
|
Returns:
|
|
832
|
-
|
|
833
|
-
Inclut RC files + TOUS les patches du TOML (candidates + staged)
|
|
875
|
+
Ordered list of validated patch IDs (RC + staged)
|
|
834
876
|
|
|
835
877
|
Examples:
|
|
836
878
|
# Production: 1.3.5
|
|
@@ -839,14 +881,15 @@ class ReleaseManager:
|
|
|
839
881
|
# 1.3.6-patches.toml: {"234": "candidate", "567": "staged"}
|
|
840
882
|
|
|
841
883
|
patches = mgr.get_all_release_context_patches()
|
|
842
|
-
# → ["123", "456", "789", "999", "
|
|
884
|
+
# → ["123", "456", "789", "999", "567"]
|
|
885
|
+
# Note: "234" (candidate) is not included - not yet validated
|
|
843
886
|
|
|
844
|
-
#
|
|
887
|
+
# For apply-patch on patch 234:
|
|
845
888
|
# 1. Restore DB (1.3.5)
|
|
846
889
|
# 2. Apply 123, 456, 789 (rc1)
|
|
847
890
|
# 3. Apply 999 (rc2)
|
|
848
|
-
# 4. Apply
|
|
849
|
-
# 5. Apply
|
|
891
|
+
# 4. Apply 567 (staged from TOML)
|
|
892
|
+
# 5. Apply 234 (current patch, applied separately)
|
|
850
893
|
"""
|
|
851
894
|
next_version = self.get_next_release_version()
|
|
852
895
|
|
|
@@ -855,20 +898,21 @@ class ReleaseManager:
|
|
|
855
898
|
|
|
856
899
|
all_patches = []
|
|
857
900
|
|
|
858
|
-
# 1.
|
|
901
|
+
# 1. Apply all RCs in order (incremental)
|
|
859
902
|
rc_files = self._get_label_files(next_version, 'rc')
|
|
860
903
|
for rc_file in rc_files:
|
|
861
904
|
patches = self.read_release_patches(rc_file)
|
|
862
|
-
#
|
|
905
|
+
# Each RC is incremental, no deduplication needed
|
|
863
906
|
all_patches.extend(patches)
|
|
864
907
|
|
|
865
|
-
# 2.
|
|
866
|
-
#
|
|
908
|
+
# 2. Apply only "staged" patches from TOML
|
|
909
|
+
# "candidate" patches are excluded because they have not yet passed
|
|
910
|
+
# the validation process (tests) that occurs during patch merge
|
|
911
|
+
# Current patch is applied separately by apply_patch_complete_workflow
|
|
867
912
|
release_file = ReleaseFile(next_version, self._releases_dir)
|
|
868
913
|
if release_file.exists():
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
all_patches.extend(all_toml_patches)
|
|
914
|
+
staged_patches = release_file.get_patches(status="staged")
|
|
915
|
+
all_patches.extend(staged_patches)
|
|
872
916
|
|
|
873
917
|
return all_patches
|
|
874
918
|
|
|
@@ -1869,7 +1913,7 @@ class ReleaseManager:
|
|
|
1869
1913
|
# → Raises: "Backup exists and user declined overwrite"
|
|
1870
1914
|
"""
|
|
1871
1915
|
# Create backups directory if doesn't exist
|
|
1872
|
-
backups_dir = Path(self._repo.base_dir) / "backups"
|
|
1916
|
+
backups_dir = Path(self._repo.base_dir) / ".hop" / "backups"
|
|
1873
1917
|
backups_dir.mkdir(exist_ok=True)
|
|
1874
1918
|
|
|
1875
1919
|
# Build backup filename
|
|
@@ -1893,6 +1937,7 @@ class ReleaseManager:
|
|
|
1893
1937
|
try:
|
|
1894
1938
|
self._repo.database.execute_pg_command(
|
|
1895
1939
|
'pg_dump',
|
|
1940
|
+
self._repo.database.name,
|
|
1896
1941
|
'-f', str(backup_file),
|
|
1897
1942
|
)
|
|
1898
1943
|
except Exception as e:
|
|
@@ -2290,10 +2335,18 @@ class ReleaseManager:
|
|
|
2290
2335
|
# 1. Apply patches to database (for validation)
|
|
2291
2336
|
self._apply_release_patches(version)
|
|
2292
2337
|
|
|
2293
|
-
# 2.
|
|
2338
|
+
# 2. Register the RC version in half_orm_meta.hop_release
|
|
2339
|
+
version_parts = version.split('.')
|
|
2340
|
+
major, minor, patch_num = map(int, version_parts)
|
|
2341
|
+
self._repo.database.register_release(
|
|
2342
|
+
major, minor, patch_num,
|
|
2343
|
+
pre_release='rc', pre_release_num=str(rc_number)
|
|
2344
|
+
)
|
|
2345
|
+
|
|
2346
|
+
# 3. Checkout release branch
|
|
2294
2347
|
self._repo.hgit.checkout(release_branch)
|
|
2295
2348
|
|
|
2296
|
-
#
|
|
2349
|
+
# 4. Create RC tag on release branch
|
|
2297
2350
|
self._repo.hgit.create_tag(rc_tag, f"Release Candidate %{version}")
|
|
2298
2351
|
|
|
2299
2352
|
# Push tag
|
|
@@ -2309,14 +2362,10 @@ class ReleaseManager:
|
|
|
2309
2362
|
|
|
2310
2363
|
# Keep TOML file for continued development (don't delete it)
|
|
2311
2364
|
|
|
2312
|
-
#
|
|
2313
|
-
|
|
2314
|
-
data_file = self._generate_data_sql_file(
|
|
2315
|
-
rc_patches,
|
|
2316
|
-
f"data-{version}-rc{rc_number}.sql"
|
|
2317
|
-
)
|
|
2365
|
+
# Note: data-X.Y.Z.sql is only generated for production releases
|
|
2366
|
+
# RC releases don't need it - data is inserted via patch application
|
|
2318
2367
|
|
|
2319
|
-
# Commit RC snapshot
|
|
2368
|
+
# Commit RC snapshot (in .hop/releases/)
|
|
2320
2369
|
# This also syncs .hop/ to all active branches automatically
|
|
2321
2370
|
self._repo.commit_and_sync_to_active_branches(
|
|
2322
2371
|
message=f"[HOP] Promote release %{version} to RC {rc_number}"
|
|
@@ -2473,6 +2522,11 @@ class ReleaseManager:
|
|
|
2473
2522
|
# The database should already be in the correct state from RC
|
|
2474
2523
|
pass
|
|
2475
2524
|
|
|
2525
|
+
# Register the release version in half_orm_meta.hop_release
|
|
2526
|
+
version_parts = version.split('.')
|
|
2527
|
+
major, minor, patch_num = map(int, version_parts)
|
|
2528
|
+
self._repo.database.register_release(major, minor, patch_num)
|
|
2529
|
+
|
|
2476
2530
|
# Generate schema dump for this production version
|
|
2477
2531
|
model_dir = Path(self._repo.model_dir)
|
|
2478
2532
|
self._repo.database._generate_schema_sql(version, model_dir)
|
|
@@ -2488,13 +2542,12 @@ class ReleaseManager:
|
|
|
2488
2542
|
if toml_file.exists():
|
|
2489
2543
|
toml_file.unlink()
|
|
2490
2544
|
|
|
2491
|
-
# Generate data-X.Y.Z.sql if any patches have @HOP:data files
|
|
2492
|
-
# This
|
|
2545
|
+
# Generate model/data-X.Y.Z.sql if any patches have @HOP:data files
|
|
2546
|
+
# This file is used for from-scratch installations (clone, restore)
|
|
2493
2547
|
prod_patches = self.read_release_patches(prod_file.name)
|
|
2494
|
-
self._generate_data_sql_file(
|
|
2495
|
-
|
|
2496
|
-
|
|
2497
|
-
)
|
|
2548
|
+
data_file = self._generate_data_sql_file(prod_patches, version)
|
|
2549
|
+
if data_file:
|
|
2550
|
+
self._repo.hgit.add(str(data_file))
|
|
2498
2551
|
|
|
2499
2552
|
self._repo.commit_and_sync_to_active_branches(
|
|
2500
2553
|
message=f"[HOP] Promote release %{version} to production",
|
|
@@ -2909,14 +2962,12 @@ class ReleaseManager:
|
|
|
2909
2962
|
if toml_file.exists():
|
|
2910
2963
|
self._repo.hgit.rm(str(toml_file))
|
|
2911
2964
|
|
|
2912
|
-
#
|
|
2913
|
-
|
|
2914
|
-
|
|
2915
|
-
|
|
2916
|
-
f"data-{version}-hotfix{hotfix_num}.sql"
|
|
2917
|
-
)
|
|
2965
|
+
# Regenerate model/data-X.Y.Z.sql with all patches (original release + all hotfixes)
|
|
2966
|
+
# This ensures from-scratch installations get all data
|
|
2967
|
+
all_patches = self._collect_all_version_patches(version)
|
|
2968
|
+
data_file = self._generate_data_sql_file(all_patches, version)
|
|
2918
2969
|
if data_file:
|
|
2919
|
-
self._repo.hgit.add(str(data_file))
|
|
2970
|
+
self._repo.hgit.add(str(data_file))
|
|
2920
2971
|
|
|
2921
2972
|
# 6. Apply release patches and generate SQL dumps
|
|
2922
2973
|
self._apply_release_patches(version, True)
|
|
@@ -2953,3 +3004,248 @@ class ReleaseManager:
|
|
|
2953
3004
|
raise
|
|
2954
3005
|
except Exception as e:
|
|
2955
3006
|
raise ReleaseManagerError(f"Failed to promote hotfix: {e}")
|
|
3007
|
+
|
|
3008
|
+
def get_all_release_patches_for_testing(self) -> List[str]:
|
|
3009
|
+
"""
|
|
3010
|
+
Get ALL patches for integration testing (candidates + staged).
|
|
3011
|
+
|
|
3012
|
+
Unlike get_all_release_context_patches() which excludes candidates,
|
|
3013
|
+
this method returns ALL patches including those not yet validated.
|
|
3014
|
+
Used by 'release apply' for complete integration testing.
|
|
3015
|
+
|
|
3016
|
+
Returns:
|
|
3017
|
+
Ordered list of ALL patch IDs (RC + candidates + staged)
|
|
3018
|
+
|
|
3019
|
+
Examples:
|
|
3020
|
+
# Production: 1.3.5
|
|
3021
|
+
# 1.3.6-rc1.txt: 123, 456, 789
|
|
3022
|
+
# 1.3.6-patches.toml: {"234": "candidate", "567": "staged"}
|
|
3023
|
+
|
|
3024
|
+
patches = mgr.get_all_release_patches_for_testing()
|
|
3025
|
+
# → ["123", "456", "789", "234", "567"]
|
|
3026
|
+
# All patches included for complete integration testing
|
|
3027
|
+
"""
|
|
3028
|
+
next_version = self.get_next_release_version()
|
|
3029
|
+
|
|
3030
|
+
if not next_version:
|
|
3031
|
+
return []
|
|
3032
|
+
|
|
3033
|
+
all_patches = []
|
|
3034
|
+
|
|
3035
|
+
# 1. Apply all RCs in order (incremental)
|
|
3036
|
+
rc_files = self._get_label_files(next_version, 'rc')
|
|
3037
|
+
for rc_file in rc_files:
|
|
3038
|
+
patches = self.read_release_patches(rc_file)
|
|
3039
|
+
all_patches.extend(patches)
|
|
3040
|
+
|
|
3041
|
+
# 2. Apply ALL patches from TOML (candidates + staged)
|
|
3042
|
+
# For integration testing, we want to test the complete release
|
|
3043
|
+
release_file = ReleaseFile(next_version, self._releases_dir)
|
|
3044
|
+
if release_file.exists():
|
|
3045
|
+
all_toml_patches = release_file.get_patches() # No status filter = all
|
|
3046
|
+
all_patches.extend(all_toml_patches)
|
|
3047
|
+
|
|
3048
|
+
return all_patches
|
|
3049
|
+
|
|
3050
|
+
def _cleanup_validate_branch(self, original_branch: Optional[str],
|
|
3051
|
+
validate_branch: Optional[str]) -> None:
|
|
3052
|
+
"""
|
|
3053
|
+
Cleanup temporary validation branch after apply_release.
|
|
3054
|
+
|
|
3055
|
+
Switches back to the original branch and deletes the temporary
|
|
3056
|
+
validation branch. Errors are silently ignored for robustness.
|
|
3057
|
+
|
|
3058
|
+
Args:
|
|
3059
|
+
original_branch: Branch to switch back to (may be None)
|
|
3060
|
+
validate_branch: Temporary branch to delete (may be None)
|
|
3061
|
+
"""
|
|
3062
|
+
try:
|
|
3063
|
+
if original_branch:
|
|
3064
|
+
self._repo.hgit.checkout(original_branch)
|
|
3065
|
+
except Exception:
|
|
3066
|
+
pass # Best effort
|
|
3067
|
+
|
|
3068
|
+
try:
|
|
3069
|
+
if validate_branch:
|
|
3070
|
+
self._repo.hgit.delete_branch(validate_branch, force=True)
|
|
3071
|
+
except Exception:
|
|
3072
|
+
pass # Best effort
|
|
3073
|
+
|
|
3074
|
+
def apply_release(self, run_tests: bool = True) -> dict:
|
|
3075
|
+
"""
|
|
3076
|
+
Apply all patches from current release for integration testing.
|
|
3077
|
+
|
|
3078
|
+
Creates a temporary validation branch (ho-validate/release-X.Y.Z),
|
|
3079
|
+
merges candidate patch branches, restores the database, applies ALL
|
|
3080
|
+
patches (including candidates), optionally runs tests, then cleans up.
|
|
3081
|
+
This simulates a complete release merge without modifying the release branch.
|
|
3082
|
+
|
|
3083
|
+
Unlike 'patch apply' which only applies staged patches,
|
|
3084
|
+
'release apply' applies ALL patches (candidates + staged) to
|
|
3085
|
+
validate the complete integration.
|
|
3086
|
+
|
|
3087
|
+
Args:
|
|
3088
|
+
run_tests: Whether to run pytest after applying patches (default: True)
|
|
3089
|
+
|
|
3090
|
+
Returns:
|
|
3091
|
+
Dict containing:
|
|
3092
|
+
- version: Release version being tested
|
|
3093
|
+
- patches_applied: List of patch IDs applied
|
|
3094
|
+
- candidates_merged: List of candidate patch branches merged
|
|
3095
|
+
- files_applied: List of SQL/Python files applied
|
|
3096
|
+
- tests_passed: Boolean (None if tests not run)
|
|
3097
|
+
- test_output: Test output (None if tests not run)
|
|
3098
|
+
- status: 'success' or 'failed'
|
|
3099
|
+
- error: Error message if failed
|
|
3100
|
+
|
|
3101
|
+
Raises:
|
|
3102
|
+
ReleaseManagerError: If no release in development or apply fails
|
|
3103
|
+
|
|
3104
|
+
Workflow:
|
|
3105
|
+
1. Detect current development release
|
|
3106
|
+
2. Validate we're on release branch
|
|
3107
|
+
3. Create temporary validation branch
|
|
3108
|
+
4. Merge candidate patch branches (simulate future merges)
|
|
3109
|
+
5. Restore database from production schema
|
|
3110
|
+
6. Apply ALL patches (RC + staged + candidates)
|
|
3111
|
+
7. Generate Python code
|
|
3112
|
+
8. Optionally run tests
|
|
3113
|
+
9. Cleanup: switch back and delete temp branch
|
|
3114
|
+
10. Return results
|
|
3115
|
+
|
|
3116
|
+
Examples:
|
|
3117
|
+
# Test current release with tests
|
|
3118
|
+
result = release_mgr.apply_release()
|
|
3119
|
+
if result['status'] == 'success':
|
|
3120
|
+
print(f"Release {result['version']} ready!")
|
|
3121
|
+
|
|
3122
|
+
# Test without running tests
|
|
3123
|
+
result = release_mgr.apply_release(run_tests=False)
|
|
3124
|
+
"""
|
|
3125
|
+
import subprocess
|
|
3126
|
+
|
|
3127
|
+
validate_branch = None
|
|
3128
|
+
original_branch = None
|
|
3129
|
+
|
|
3130
|
+
try:
|
|
3131
|
+
# 1. Detect current development release
|
|
3132
|
+
next_version = self.get_next_release_version()
|
|
3133
|
+
if not next_version:
|
|
3134
|
+
raise ReleaseManagerError(
|
|
3135
|
+
"No development release found.\n"
|
|
3136
|
+
"Create one with: half_orm dev release create <level>"
|
|
3137
|
+
)
|
|
3138
|
+
|
|
3139
|
+
# 2. Validate we're on a release branch
|
|
3140
|
+
original_branch = self._repo.hgit.branch
|
|
3141
|
+
expected_branch = f"ho-release/{next_version}"
|
|
3142
|
+
if original_branch != expected_branch:
|
|
3143
|
+
raise ReleaseManagerError(
|
|
3144
|
+
f"Must be on release branch {expected_branch}\n"
|
|
3145
|
+
f"Currently on: {original_branch}\n"
|
|
3146
|
+
f"Switch with: git checkout {expected_branch}"
|
|
3147
|
+
)
|
|
3148
|
+
|
|
3149
|
+
# 3. Create temporary validation branch
|
|
3150
|
+
validate_branch = f"ho-validate/release-{next_version}"
|
|
3151
|
+
|
|
3152
|
+
# Delete existing validation branch if it exists
|
|
3153
|
+
try:
|
|
3154
|
+
self._repo.hgit.delete_branch(validate_branch, force=True)
|
|
3155
|
+
except Exception:
|
|
3156
|
+
pass # Branch doesn't exist, that's fine
|
|
3157
|
+
|
|
3158
|
+
# Create and checkout validation branch
|
|
3159
|
+
self._repo.hgit.create_branch(validate_branch)
|
|
3160
|
+
self._repo.hgit.checkout(validate_branch)
|
|
3161
|
+
|
|
3162
|
+
# 4. Merge candidate patch branches to simulate future merges
|
|
3163
|
+
# Staged patches are already merged on ho-release, only candidates need merging
|
|
3164
|
+
release_file = ReleaseFile(next_version, self._releases_dir)
|
|
3165
|
+
candidates_merged = []
|
|
3166
|
+
if release_file.exists():
|
|
3167
|
+
candidate_patches = release_file.get_patches(status="candidate")
|
|
3168
|
+
for patch_id in candidate_patches:
|
|
3169
|
+
patch_branch = f"ho-patch/{patch_id}"
|
|
3170
|
+
try:
|
|
3171
|
+
self._repo.hgit.merge(patch_branch)
|
|
3172
|
+
candidates_merged.append(patch_id)
|
|
3173
|
+
except Exception as e:
|
|
3174
|
+
raise ReleaseManagerError(
|
|
3175
|
+
f"Failed to merge candidate branch {patch_branch}: {e}\n"
|
|
3176
|
+
f"Fix merge conflicts on the patch branch first."
|
|
3177
|
+
)
|
|
3178
|
+
|
|
3179
|
+
# 5. Restore database from production schema
|
|
3180
|
+
self._repo.restore_database_from_schema()
|
|
3181
|
+
|
|
3182
|
+
# 6. Get and apply ALL patches (RC + staged + candidates)
|
|
3183
|
+
all_patches = self.get_all_release_patches_for_testing()
|
|
3184
|
+
all_applied_files = []
|
|
3185
|
+
|
|
3186
|
+
for patch_id in all_patches:
|
|
3187
|
+
files = self._repo.patch_manager.apply_patch_files(
|
|
3188
|
+
patch_id, self._repo.model
|
|
3189
|
+
)
|
|
3190
|
+
all_applied_files.extend(files)
|
|
3191
|
+
|
|
3192
|
+
# 7. Generate Python code
|
|
3193
|
+
from half_orm_dev import modules
|
|
3194
|
+
modules.generate(self._repo)
|
|
3195
|
+
|
|
3196
|
+
# 8. Optionally run tests
|
|
3197
|
+
tests_passed = None
|
|
3198
|
+
test_output = None
|
|
3199
|
+
|
|
3200
|
+
if run_tests:
|
|
3201
|
+
try:
|
|
3202
|
+
result = subprocess.run(
|
|
3203
|
+
['pytest', '-v'],
|
|
3204
|
+
cwd=self._repo.base_dir,
|
|
3205
|
+
capture_output=True,
|
|
3206
|
+
text=True,
|
|
3207
|
+
timeout=600 # 10 minute timeout
|
|
3208
|
+
)
|
|
3209
|
+
tests_passed = result.returncode == 0
|
|
3210
|
+
test_output = result.stdout + result.stderr
|
|
3211
|
+
except subprocess.TimeoutExpired:
|
|
3212
|
+
tests_passed = False
|
|
3213
|
+
test_output = "Tests timed out after 10 minutes"
|
|
3214
|
+
except FileNotFoundError:
|
|
3215
|
+
tests_passed = None
|
|
3216
|
+
test_output = "pytest not found - tests skipped"
|
|
3217
|
+
|
|
3218
|
+
# 9. Cleanup: switch back to original branch and delete temp branch
|
|
3219
|
+
self._repo.hgit.checkout(original_branch)
|
|
3220
|
+
try:
|
|
3221
|
+
self._repo.hgit.delete_branch(validate_branch, force=True)
|
|
3222
|
+
except Exception:
|
|
3223
|
+
pass # Best effort cleanup
|
|
3224
|
+
|
|
3225
|
+
# 10. Return results
|
|
3226
|
+
return {
|
|
3227
|
+
'version': next_version,
|
|
3228
|
+
'patches_applied': all_patches,
|
|
3229
|
+
'candidates_merged': candidates_merged,
|
|
3230
|
+
'files_applied': all_applied_files,
|
|
3231
|
+
'tests_passed': tests_passed,
|
|
3232
|
+
'test_output': test_output,
|
|
3233
|
+
'status': 'success' if tests_passed is not False else 'failed',
|
|
3234
|
+
'error': None
|
|
3235
|
+
}
|
|
3236
|
+
|
|
3237
|
+
except ReleaseManagerError:
|
|
3238
|
+
# Cleanup on error
|
|
3239
|
+
self._cleanup_validate_branch(original_branch, validate_branch)
|
|
3240
|
+
raise
|
|
3241
|
+
except Exception as e:
|
|
3242
|
+
# Cleanup on error
|
|
3243
|
+
self._cleanup_validate_branch(original_branch, validate_branch)
|
|
3244
|
+
|
|
3245
|
+
# Restore DB to clean state on failure
|
|
3246
|
+
try:
|
|
3247
|
+
self._repo.restore_database_from_schema()
|
|
3248
|
+
except Exception:
|
|
3249
|
+
pass # Best effort cleanup
|
|
3250
|
+
|
|
3251
|
+
raise ReleaseManagerError(f"Release apply failed: {e}")
|
half_orm_dev/repo.py
CHANGED
|
@@ -2218,17 +2218,19 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
2218
2218
|
|
|
2219
2219
|
def restore_database_from_schema(self) -> None:
|
|
2220
2220
|
"""
|
|
2221
|
-
Restore database from model/schema.sql and
|
|
2221
|
+
Restore database from model/schema.sql, metadata, and data files.
|
|
2222
2222
|
|
|
2223
2223
|
Restores database to clean production state by dropping all user schemas
|
|
2224
|
-
|
|
2224
|
+
and loading schema, metadata, and reference data. Used for from-scratch
|
|
2225
|
+
installations (clone) and patch development (patch apply).
|
|
2225
2226
|
|
|
2226
2227
|
Process:
|
|
2227
2228
|
1. Verify model/schema.sql exists (file or symlink)
|
|
2228
2229
|
2. Drop all user schemas with CASCADE (no superuser privileges needed)
|
|
2229
2230
|
3. Load schema structure from model/schema.sql using psql -f
|
|
2230
2231
|
4. Load half_orm_meta data from model/metadata-X.Y.Z.sql using psql -f (if exists)
|
|
2231
|
-
5.
|
|
2232
|
+
5. Load reference data from model/data-*.sql files up to current version
|
|
2233
|
+
6. Reload halfORM Model metadata cache
|
|
2232
2234
|
|
|
2233
2235
|
The method uses DROP SCHEMA CASCADE instead of dropdb/createdb, allowing
|
|
2234
2236
|
operation without CREATEDB privilege or superuser access. This makes it
|
|
@@ -2238,20 +2240,24 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
2238
2240
|
- Accepts model/schema.sql as regular file or symlink
|
|
2239
2241
|
- Symlink typically points to versioned schema-X.Y.Z.sql file
|
|
2240
2242
|
- Follows symlink automatically during psql execution
|
|
2241
|
-
- Deduces
|
|
2242
|
-
-
|
|
2243
|
+
- Deduces version from schema.sql symlink target for metadata and data files
|
|
2244
|
+
- Missing metadata/data files are silently skipped (backward compatibility)
|
|
2245
|
+
|
|
2246
|
+
Data Files:
|
|
2247
|
+
- model/data-X.Y.Z.sql contains reference data from @HOP:data patches
|
|
2248
|
+
- All data files up to current version are loaded in version order
|
|
2249
|
+
- Example: for version 1.2.0, loads data-0.1.0.sql, data-1.0.0.sql, data-1.2.0.sql
|
|
2243
2250
|
|
|
2244
2251
|
Error Handling:
|
|
2245
2252
|
- Raises RepoError if model/schema.sql not found
|
|
2246
2253
|
- Raises RepoError if schema drop fails
|
|
2247
|
-
- Raises RepoError if psql schema load fails
|
|
2248
|
-
- Raises RepoError if psql metadata load fails (when file exists)
|
|
2254
|
+
- Raises RepoError if psql schema/metadata/data load fails
|
|
2249
2255
|
- Database state rolled back on any failure
|
|
2250
2256
|
|
|
2251
2257
|
Usage Context:
|
|
2258
|
+
- Called by clone_repo workflow (from-scratch installation)
|
|
2252
2259
|
- Called by apply-patch workflow (Step 1: Database Restoration)
|
|
2253
|
-
- Ensures clean state before applying
|
|
2254
|
-
- Part of isolated patch testing strategy
|
|
2260
|
+
- Ensures clean state with all reference data before applying patches
|
|
2255
2261
|
|
|
2256
2262
|
Returns:
|
|
2257
2263
|
None
|
|
@@ -2263,31 +2269,23 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
2263
2269
|
Examples:
|
|
2264
2270
|
# Restore database from model/schema.sql before applying patch
|
|
2265
2271
|
repo.restore_database_from_schema()
|
|
2266
|
-
# Database now contains
|
|
2272
|
+
# Database now contains: schema + metadata + reference data
|
|
2267
2273
|
|
|
2268
2274
|
# Typical apply-patch workflow
|
|
2269
|
-
repo.restore_database_from_schema() # Step 1: Clean state +
|
|
2275
|
+
repo.restore_database_from_schema() # Step 1: Clean state + all data
|
|
2270
2276
|
patch_mgr.apply_patch_files("456-user-auth", repo.model) # Step 2: Apply patch
|
|
2271
2277
|
|
|
2272
|
-
# With versioned
|
|
2278
|
+
# With versioned files
|
|
2273
2279
|
# If schema.sql → schema-1.2.3.sql exists
|
|
2274
|
-
# Then metadata-1.2.3.sql
|
|
2275
|
-
|
|
2276
|
-
# Error handling
|
|
2277
|
-
try:
|
|
2278
|
-
repo.restore_database_from_schema()
|
|
2279
|
-
except RepoError as e:
|
|
2280
|
-
print(f"Database restoration failed: {e}")
|
|
2281
|
-
# Handle error: check schema.sql exists, verify permissions
|
|
2280
|
+
# Then loads: metadata-1.2.3.sql, data-0.1.0.sql, data-1.0.0.sql, data-1.2.3.sql
|
|
2282
2281
|
|
|
2283
2282
|
Notes:
|
|
2284
2283
|
- Uses DROP SCHEMA CASCADE - no superuser or CREATEDB privilege required
|
|
2285
2284
|
- Works on cloud databases (AWS RDS, Azure Database, etc.)
|
|
2286
2285
|
- Uses Model.reconnect(reload=True) to refresh metadata cache
|
|
2287
2286
|
- Supports both schema.sql file and schema.sql -> schema-X.Y.Z.sql symlink
|
|
2288
|
-
- Metadata
|
|
2287
|
+
- Metadata and data files are optional (backward compatibility)
|
|
2289
2288
|
- All PostgreSQL commands use repository connection configuration
|
|
2290
|
-
- Version deduction: schema.sql → schema-1.2.3.sql ⇒ metadata-1.2.3.sql
|
|
2291
2289
|
"""
|
|
2292
2290
|
# 1. Verify model/schema.sql exists
|
|
2293
2291
|
schema_path = Path(self.model_dir) / "schema.sql"
|
|
@@ -2318,15 +2316,16 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
2318
2316
|
self.database.execute_pg_command(
|
|
2319
2317
|
'psql', '-d', self.name, '-f', str(metadata_path)
|
|
2320
2318
|
)
|
|
2321
|
-
# Optional: Log success (can be removed if too verbose)
|
|
2322
|
-
# print(f"✓ Loaded metadata from {metadata_path.name}")
|
|
2323
2319
|
except Exception as e:
|
|
2324
2320
|
raise RepoError(
|
|
2325
2321
|
f"Failed to load metadata from {metadata_path.name}: {e}"
|
|
2326
2322
|
) from e
|
|
2327
2323
|
# else: metadata file doesn't exist, continue without error (backward compatibility)
|
|
2328
2324
|
|
|
2329
|
-
# 5.
|
|
2325
|
+
# 5. Load data files from model/data-*.sql (all versions up to current)
|
|
2326
|
+
self._load_data_files(schema_path)
|
|
2327
|
+
|
|
2328
|
+
# 6. Reload half_orm metadata cache
|
|
2330
2329
|
self.model.reconnect(reload=True)
|
|
2331
2330
|
|
|
2332
2331
|
except RepoError:
|
|
@@ -2380,6 +2379,76 @@ See docs/half_orm_dev.md for complete documentation.
|
|
|
2380
2379
|
|
|
2381
2380
|
return metadata_path
|
|
2382
2381
|
|
|
2382
|
+
def _load_data_files(self, schema_path: Path) -> None:
|
|
2383
|
+
"""
|
|
2384
|
+
Load all data files from model/data-*.sql up to current version.
|
|
2385
|
+
|
|
2386
|
+
Data files contain reference data (DML) from patches with @HOP:data annotation.
|
|
2387
|
+
They are loaded in version order for from-scratch installations.
|
|
2388
|
+
|
|
2389
|
+
Args:
|
|
2390
|
+
schema_path: Path to model/schema.sql (used to deduce current version)
|
|
2391
|
+
|
|
2392
|
+
Process:
|
|
2393
|
+
1. Deduce current version from schema.sql symlink
|
|
2394
|
+
2. Find all data-*.sql files in model/
|
|
2395
|
+
3. Sort by version (semantic versioning)
|
|
2396
|
+
4. Load each file up to current version using psql -f
|
|
2397
|
+
|
|
2398
|
+
Examples:
|
|
2399
|
+
# schema.sql → schema-1.2.0.sql
|
|
2400
|
+
# model/ contains: data-0.1.0.sql, data-1.0.0.sql, data-1.2.0.sql, data-2.0.0.sql
|
|
2401
|
+
# Loads: data-0.1.0.sql, data-1.0.0.sql, data-1.2.0.sql (skips 2.0.0)
|
|
2402
|
+
"""
|
|
2403
|
+
# Deduce current version from schema.sql symlink
|
|
2404
|
+
if not schema_path.is_symlink():
|
|
2405
|
+
return # No version info, skip data loading
|
|
2406
|
+
|
|
2407
|
+
try:
|
|
2408
|
+
target = Path(os.readlink(schema_path))
|
|
2409
|
+
except OSError:
|
|
2410
|
+
return
|
|
2411
|
+
|
|
2412
|
+
match = re.match(r'schema-(\d+\.\d+\.\d+)\.sql$', target.name)
|
|
2413
|
+
if not match:
|
|
2414
|
+
return
|
|
2415
|
+
|
|
2416
|
+
current_version = match.group(1)
|
|
2417
|
+
current_tuple = tuple(map(int, current_version.split('.')))
|
|
2418
|
+
|
|
2419
|
+
# Find all data files
|
|
2420
|
+
model_dir = schema_path.parent
|
|
2421
|
+
data_files = list(model_dir.glob("data-*.sql"))
|
|
2422
|
+
|
|
2423
|
+
if not data_files:
|
|
2424
|
+
return # No data files to load
|
|
2425
|
+
|
|
2426
|
+
# Parse and sort by version
|
|
2427
|
+
versioned_files = []
|
|
2428
|
+
for data_file in data_files:
|
|
2429
|
+
match = re.match(r'data-(\d+\.\d+\.\d+)\.sql$', data_file.name)
|
|
2430
|
+
if match:
|
|
2431
|
+
version = match.group(1)
|
|
2432
|
+
version_tuple = tuple(map(int, version.split('.')))
|
|
2433
|
+
versioned_files.append((version_tuple, data_file))
|
|
2434
|
+
|
|
2435
|
+
# Sort by version tuple
|
|
2436
|
+
versioned_files.sort(key=lambda x: x[0])
|
|
2437
|
+
|
|
2438
|
+
# Load each file up to current version
|
|
2439
|
+
for version_tuple, data_file in versioned_files:
|
|
2440
|
+
if version_tuple > current_tuple:
|
|
2441
|
+
break # Stop at versions beyond current
|
|
2442
|
+
|
|
2443
|
+
try:
|
|
2444
|
+
self.database.execute_pg_command(
|
|
2445
|
+
'psql', '-d', self.name, '-f', str(data_file)
|
|
2446
|
+
)
|
|
2447
|
+
except Exception as e:
|
|
2448
|
+
raise RepoError(
|
|
2449
|
+
f"Failed to load data from {data_file.name}: {e}"
|
|
2450
|
+
) from e
|
|
2451
|
+
|
|
2383
2452
|
@classmethod
|
|
2384
2453
|
def clone_repo(cls,
|
|
2385
2454
|
git_origin: str,
|
half_orm_dev/templates/README
CHANGED
|
@@ -170,10 +170,11 @@ half_orm dev upgrade <version> # Deploy specific version
|
|
|
170
170
|
{package_name}/
|
|
171
171
|
├── .hop/ # half-orm-dev metadata
|
|
172
172
|
│ ├── config # Repository configuration
|
|
173
|
-
│ ├── model/ # Database schemas
|
|
173
|
+
│ ├── model/ # Database schemas and data
|
|
174
174
|
│ │ ├── schema.sql # Current production schema (symlink)
|
|
175
175
|
│ │ ├── schema-X.Y.Z.sql # Versioned schemas
|
|
176
|
-
│ │
|
|
176
|
+
│ │ ├── metadata-X.Y.Z.sql # half_orm_meta data dumps
|
|
177
|
+
│ │ └── data-X.Y.Z.sql # Reference data from @HOP:data patches
|
|
177
178
|
│ └── releases/ # Release tracking files
|
|
178
179
|
│ ├── X.Y.Z-patches.toml # Development releases (mutable)
|
|
179
180
|
│ ├── X.Y.Z-rcN.txt # Release candidates (immutable)
|
|
@@ -199,6 +200,39 @@ All development happens on patch branches, merged into release branches, then pr
|
|
|
199
200
|
|
|
200
201
|
---
|
|
201
202
|
|
|
203
|
+
## 💾 Data Persistence (@HOP:data)
|
|
204
|
+
|
|
205
|
+
For reference data that must be loaded with every database installation (lookup tables, default roles, etc.), use the `@HOP:data` annotation:
|
|
206
|
+
|
|
207
|
+
```sql
|
|
208
|
+
-- @HOP:data
|
|
209
|
+
-- This file will be included in model/data-X.Y.Z.sql
|
|
210
|
+
|
|
211
|
+
INSERT INTO roles (name, description)
|
|
212
|
+
VALUES ('admin', 'Administrator')
|
|
213
|
+
ON CONFLICT (name) DO NOTHING;
|
|
214
|
+
|
|
215
|
+
INSERT INTO permissions (name)
|
|
216
|
+
VALUES ('read'), ('write'), ('delete')
|
|
217
|
+
ON CONFLICT DO NOTHING;
|
|
218
|
+
```
|
|
219
|
+
|
|
220
|
+
### How it works
|
|
221
|
+
|
|
222
|
+
1. **In patches**: SQL files starting with `-- @HOP:data` contain reference data
|
|
223
|
+
2. **Production promote**: All `@HOP:data` files are consolidated into `model/data-X.Y.Z.sql`
|
|
224
|
+
3. **Clone/Restore**: Data files are loaded automatically after schema restoration
|
|
225
|
+
4. **Production upgrade**: Data is inserted via normal patch application (no special handling)
|
|
226
|
+
|
|
227
|
+
### Best practices
|
|
228
|
+
|
|
229
|
+
- Use `ON CONFLICT DO NOTHING` or `ON CONFLICT DO UPDATE` for idempotency
|
|
230
|
+
- Keep data files small and focused (one concern per file)
|
|
231
|
+
- Number your SQL files to control execution order: `01_roles.sql`, `02_permissions.sql`
|
|
232
|
+
- Only use for **reference data**, not user-generated data
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
202
236
|
## 📚 Documentation
|
|
203
237
|
|
|
204
238
|
- **half-orm-dev**: https://github.com/half-orm/half-orm-dev
|
half_orm_dev/version.txt
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.17.3-
|
|
1
|
+
0.17.3-a6
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
half_orm_dev/__init__.py,sha256=0JpUPey1gacxXuIFGcpD2nTGso73fkak72qzTHttAJk,18
|
|
2
2
|
half_orm_dev/cli_extension.py,sha256=kwX3M11_rwr0pFcqHK_bpI3Pp4ztfTCVz2gLfTmzfeA,1066
|
|
3
|
-
half_orm_dev/database.py,sha256=
|
|
3
|
+
half_orm_dev/database.py,sha256=0sq96tseMUWAluoxoxwbTIJrzvBYMzW163D8Ssdj73g,62793
|
|
4
4
|
half_orm_dev/decorators.py,sha256=JKv_Z_JZUr-s-Vz551temHZhhecPfbvyhTbByRDjVAQ,4901
|
|
5
5
|
half_orm_dev/hgit.py,sha256=VdzCCQ__xG1IGJaGq4-rrhbA1bNkDw_dBqkUNIeTONg,58045
|
|
6
6
|
half_orm_dev/migration_manager.py,sha256=9RpciH8nyQrF0xV31kAeaYKkQl24Di1VHt-mAjjHhzM,14854
|
|
7
7
|
half_orm_dev/modules.py,sha256=4jfVb2yboRgb9mcO0sMF-iLigcZFTHEm4VRLN6GQXM4,16796
|
|
8
|
-
half_orm_dev/patch_manager.py,sha256=
|
|
8
|
+
half_orm_dev/patch_manager.py,sha256=HAnQR4m8E0hyKCpPme_XiHI4K7qFkCT2DvSgCaa524s,100993
|
|
9
9
|
half_orm_dev/patch_validator.py,sha256=QNe1L6k_xwsnrOTcb3vkW2D0LbqrCRcZOGPnVyspVRk,10871
|
|
10
10
|
half_orm_dev/release_file.py,sha256=0c9NBhAQ6YpiC3HWj8VtZcfvvZxW2ITk1NEQ60AO0sI,9880
|
|
11
|
-
half_orm_dev/release_manager.py,sha256=
|
|
12
|
-
half_orm_dev/repo.py,sha256=
|
|
11
|
+
half_orm_dev/release_manager.py,sha256=AXjjtIWAdv0yy6DkP_eGYKqDDvejmbxnz_QiMRLvwws,125510
|
|
12
|
+
half_orm_dev/repo.py,sha256=h-nsB6z2xph9ortO02g9DcPXeef9Rk2D5WI3Yc3h1M4,98253
|
|
13
13
|
half_orm_dev/utils.py,sha256=M3yViUFfsO7Cp9MYSoUSkCZ6R9w_4jW45UDZUOT8FhI,1493
|
|
14
|
-
half_orm_dev/version.txt,sha256=
|
|
14
|
+
half_orm_dev/version.txt,sha256=ODC9rbINfRfZYa57a3OOULq415Mxf7YFomwPl96Qm3M,10
|
|
15
15
|
half_orm_dev/cli/__init__.py,sha256=0CbMj8OIhZmglWakK7NhYPn302erUTEg2VHOdm1hRTQ,163
|
|
16
16
|
half_orm_dev/cli/main.py,sha256=3SVTl5WraNTSY6o7LfvE1dUHKg_RcuVaHHDIn_oINv4,11701
|
|
17
17
|
half_orm_dev/cli/commands/__init__.py,sha256=UhWf0AnWqy4gyFo2SJQv8pL_YJ43pE_c9TgopcjzKDg,1490
|
|
@@ -21,7 +21,7 @@ half_orm_dev/cli/commands/clone.py,sha256=JUDDt-vz_WvGkm5HDFuZ3KZbclLyPaE4h665n8
|
|
|
21
21
|
half_orm_dev/cli/commands/init.py,sha256=N0TXUL1ExW-DdpNrs4xiXymtSHHLh5fCbPMTBjw2Iwg,12548
|
|
22
22
|
half_orm_dev/cli/commands/migrate.py,sha256=iEz3DoFX22WwaYDo_WUKaF-pFohaLWoUrDmdLCin2wc,4047
|
|
23
23
|
half_orm_dev/cli/commands/patch.py,sha256=sl8mv1mlq-KnKItFV_HBfZ93G5CbdviQRIAYocuMDDo,12810
|
|
24
|
-
half_orm_dev/cli/commands/release.py,sha256=
|
|
24
|
+
half_orm_dev/cli/commands/release.py,sha256=yPp_3NaukhZF3smo6zNsFKtxHwB2RgZnUlomdlM6Sno,17574
|
|
25
25
|
half_orm_dev/cli/commands/restore.py,sha256=n9SP8n1EQUduvDoA0qxpSUQpphc48X-NovnocyGl98I,236
|
|
26
26
|
half_orm_dev/cli/commands/sync.py,sha256=D0Prr8W1ySYjP3D8H4MB05KHccFbhB8z2qB3Bs00swA,274
|
|
27
27
|
half_orm_dev/cli/commands/todo.py,sha256=kL5QU-IjPWmnrKG8L4qk1vb5PDZfY88EFExICiNeLhA,2981
|
|
@@ -38,7 +38,7 @@ half_orm_dev/patches/sql/half_orm_meta.sql,sha256=Vl2YzEWpWdam-tC0ZE8iNMeTRzEHpx
|
|
|
38
38
|
half_orm_dev/templates/.gitignore,sha256=RmvQ9D46T9vpRxhYjjY5WUjGVbuyFUMsH059wC7sPBM,140
|
|
39
39
|
half_orm_dev/templates/MANIFEST.in,sha256=53BeBuKi8UtBWB6IG3VQZk9Ow8Iye6Zs14sP-gVyVDA,25
|
|
40
40
|
half_orm_dev/templates/Pipfile,sha256=u3lGJSk5HZwz-EOTrOdBYrkhGV6zgVtrrRPivrO5rmA,182
|
|
41
|
-
half_orm_dev/templates/README,sha256=
|
|
41
|
+
half_orm_dev/templates/README,sha256=YgNl52Jk7jvwTaB7fLgrXHxOtCECQEl8AqgvImrzaWA,7522
|
|
42
42
|
half_orm_dev/templates/conftest_template,sha256=DopLw67b5cptCYUtmAcQzr5Gz_kzNwpMO6r3goihiks,1206
|
|
43
43
|
half_orm_dev/templates/init_module_template,sha256=o3RAnhGayYUF7NEyI8bcI6JHmAZb2wPVNF-FdrjOnQU,345
|
|
44
44
|
half_orm_dev/templates/module_template_1,sha256=hRa0PiI6-dpBKNXJ9PuDuGocdrq712ujlSJGfJcXOh8,271
|
|
@@ -50,9 +50,9 @@ half_orm_dev/templates/sql_adapter,sha256=kAP5y7Qml3DKsbZLUeoVpeXjbQcWltHjkDznED
|
|
|
50
50
|
half_orm_dev/templates/warning,sha256=4hlZ_rRdpmkXxOeRoVd9xnXBARYXn95e-iXrD1f2u7k,490
|
|
51
51
|
half_orm_dev/templates/git-hooks/pre-commit,sha256=Hf084pqeiOebrv4xzA0aiaHbIXswmmNO-dSIXUfzMK0,4707
|
|
52
52
|
half_orm_dev/templates/git-hooks/prepare-commit-msg,sha256=zknOGGoaWKC97zfga2Xl2i_psnNo9MJbrEBuN91eHNw,1070
|
|
53
|
-
half_orm_dev-0.17.
|
|
54
|
-
half_orm_dev-0.17.
|
|
55
|
-
half_orm_dev-0.17.
|
|
56
|
-
half_orm_dev-0.17.
|
|
57
|
-
half_orm_dev-0.17.
|
|
58
|
-
half_orm_dev-0.17.
|
|
53
|
+
half_orm_dev-0.17.3a6.dist-info/licenses/AUTHORS,sha256=eWxqzRdLOt2gX0FMQj_wui03Od3jdlwa8xNe9tl84g0,113
|
|
54
|
+
half_orm_dev-0.17.3a6.dist-info/licenses/LICENSE,sha256=ufhxlSi6mttkGQTsGWrEoB3WA_fCPJ6-k07GSVBgyPw,644
|
|
55
|
+
half_orm_dev-0.17.3a6.dist-info/METADATA,sha256=POjMIdgH4C38nJeXy2VOtLR7jknjFrhhUmfrSCyimbQ,16149
|
|
56
|
+
half_orm_dev-0.17.3a6.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
57
|
+
half_orm_dev-0.17.3a6.dist-info/top_level.txt,sha256=M5hEsWfn5Kw0HL-VnNmS6Jw-3cwRyjims5a8cr18eTM,13
|
|
58
|
+
half_orm_dev-0.17.3a6.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|