half-orm-dev 1.0.0a29__tar.gz → 1.0.0a31__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-1.0.0a29/half_orm_dev.egg-info → half_orm_dev-1.0.0a31}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/bootstrap_manager.py +6 -2
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/__init__.py +4 -3
- half_orm_dev-1.0.0a31/half_orm_dev/cli/commands/recover.py +46 -0
- half_orm_dev-1.0.0a31/half_orm_dev/cli/commands/rollback.py +103 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/main.py +2 -2
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/database.py +23 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/decorators.py +43 -11
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/file_executor.py +68 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/release_manager.py +253 -160
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/repo.py +155 -0
- half_orm_dev-1.0.0a31/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31/half_orm_dev.egg-info}/PKG-INFO +1 -1
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/SOURCES.txt +2 -1
- half_orm_dev-1.0.0a29/half_orm_dev/cli/commands/update.py +0 -73
- half_orm_dev-1.0.0a29/half_orm_dev/version.txt +0 -1
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/LICENSE +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/README.md +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/bootstrap.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/clone.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/init.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/restore.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/todo.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/hgit.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migration_manager.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/modules.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patch_manager.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/py.typed +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/.gitignore +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/pre-push +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/init_module_template +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_1 +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_2 +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/requires.txt +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/setup.cfg +0 -0
- {half_orm_dev-1.0.0a29 → half_orm_dev-1.0.0a31}/setup.py +0 -0
|
@@ -18,7 +18,7 @@ from pathlib import Path
|
|
|
18
18
|
from typing import List, Set, Tuple, Optional, TYPE_CHECKING
|
|
19
19
|
|
|
20
20
|
from half_orm_dev.file_executor import (
|
|
21
|
-
execute_sql_file,
|
|
21
|
+
execute_sql_file, execute_python_bootstrap, FileExecutionError
|
|
22
22
|
)
|
|
23
23
|
|
|
24
24
|
if TYPE_CHECKING:
|
|
@@ -180,7 +180,11 @@ class BootstrapManager:
|
|
|
180
180
|
if file_path.suffix == '.sql':
|
|
181
181
|
execute_sql_file(file_path, self._repo.database.model)
|
|
182
182
|
elif file_path.suffix == '.py':
|
|
183
|
-
output =
|
|
183
|
+
output = execute_python_bootstrap(
|
|
184
|
+
file_path,
|
|
185
|
+
model=self._repo.database.model,
|
|
186
|
+
cwd=self._bootstrap_dir
|
|
187
|
+
)
|
|
184
188
|
if output:
|
|
185
189
|
click.echo(f" Output: {output}")
|
|
186
190
|
else:
|
|
@@ -10,15 +10,15 @@ from .init import init
|
|
|
10
10
|
from .clone import clone
|
|
11
11
|
from .patch import patch
|
|
12
12
|
from .release import release
|
|
13
|
-
from .update import update
|
|
14
13
|
from .upgrade import upgrade
|
|
15
14
|
from .check import check
|
|
16
15
|
from .set_git_origin import set_git_origin
|
|
17
16
|
from .migrate import migrate
|
|
18
17
|
from .revert_migration import revert_migration
|
|
19
18
|
from .bootstrap import bootstrap
|
|
19
|
+
from .rollback import rollback
|
|
20
|
+
from .recover import recover
|
|
20
21
|
from .todo import apply_release
|
|
21
|
-
from .todo import rollback
|
|
22
22
|
|
|
23
23
|
# ♻️ Adapted existing commands
|
|
24
24
|
from .todo import sync_package # Unchanged
|
|
@@ -31,7 +31,6 @@ ALL_COMMANDS = {
|
|
|
31
31
|
'clone': clone,
|
|
32
32
|
'patch': patch,
|
|
33
33
|
'release': release,
|
|
34
|
-
'update': update, # Adapted for production
|
|
35
34
|
'upgrade': upgrade, # Adapted for production
|
|
36
35
|
'check': check, # Project health check and updates
|
|
37
36
|
'set-git-origin': set_git_origin, # Update git remote origin URL
|
|
@@ -43,6 +42,7 @@ ALL_COMMANDS = {
|
|
|
43
42
|
|
|
44
43
|
# 🚧 Emergency workflow (stubs)
|
|
45
44
|
'rollback': rollback,
|
|
45
|
+
'recover': recover,
|
|
46
46
|
|
|
47
47
|
# ♻️ Adapted commands
|
|
48
48
|
'sync-package': sync_package, # Unchanged
|
|
@@ -60,6 +60,7 @@ __all__ = [
|
|
|
60
60
|
'migrate',
|
|
61
61
|
'bootstrap',
|
|
62
62
|
'rollback',
|
|
63
|
+
'recover',
|
|
63
64
|
# Adapted commands
|
|
64
65
|
'sync_package',
|
|
65
66
|
'ALL_COMMANDS'
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Recover command - Complete or clean up an interrupted sync operation.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
import click
|
|
7
|
+
from half_orm_dev.repo import Repo
|
|
8
|
+
from half_orm import utils
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.command()
|
|
12
|
+
def recover() -> None:
|
|
13
|
+
"""Complete or clean up a sync interrupted by a crash or network failure.
|
|
14
|
+
|
|
15
|
+
Reads .git/hop-sync-lock to verify ownership of the interrupted operation,
|
|
16
|
+
then either pushes branches whose sync commit completed (Phase 2 finish)
|
|
17
|
+
or restores clean state for branches interrupted mid-commit (Phase 1 cleanup).
|
|
18
|
+
Releases the distributed lock when done.
|
|
19
|
+
"""
|
|
20
|
+
repo = Repo()
|
|
21
|
+
result = repo.recover()
|
|
22
|
+
|
|
23
|
+
if result['errors'] and not result['pushed_branches'] and not result['cleaned_branches']:
|
|
24
|
+
for error in result['errors']:
|
|
25
|
+
click.echo(utils.Color.red(f"Error: {error}"), err=True)
|
|
26
|
+
sys.exit(1)
|
|
27
|
+
|
|
28
|
+
if result['lock_tag']:
|
|
29
|
+
click.echo(f"Recovering from interrupted sync (lock: {result['lock_tag']})")
|
|
30
|
+
|
|
31
|
+
if result['pushed_branches']:
|
|
32
|
+
for branch in result['pushed_branches']:
|
|
33
|
+
click.echo(f" Pushed: {branch}")
|
|
34
|
+
|
|
35
|
+
if result['cleaned_branches']:
|
|
36
|
+
for branch in result['cleaned_branches']:
|
|
37
|
+
click.echo(f" Cleaned: {branch}")
|
|
38
|
+
|
|
39
|
+
if not result['pushed_branches'] and not result['cleaned_branches']:
|
|
40
|
+
click.echo("Nothing to recover — lock released.")
|
|
41
|
+
|
|
42
|
+
if result['errors']:
|
|
43
|
+
for error in result['errors']:
|
|
44
|
+
click.echo(utils.Color.yellow(f"Warning: {error}"), err=True)
|
|
45
|
+
|
|
46
|
+
click.echo(utils.Color.green("Recovery complete."))
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Rollback command - Restore production to a previous version.
|
|
3
|
+
|
|
4
|
+
Restores the database from a stored snapshot and checks out the
|
|
5
|
+
corresponding immutable ho-prod-X.Y.Z branch.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from half_orm_dev.repo import Repo
|
|
10
|
+
from half_orm_dev.release_manager import ReleaseManagerError
|
|
11
|
+
from half_orm import utils
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@click.command()
|
|
15
|
+
@click.option(
|
|
16
|
+
'--to-version', '-t',
|
|
17
|
+
type=str,
|
|
18
|
+
default=None,
|
|
19
|
+
help='Target version (default: previous version)'
|
|
20
|
+
)
|
|
21
|
+
@click.option(
|
|
22
|
+
'--yes', '-y',
|
|
23
|
+
is_flag=True,
|
|
24
|
+
help='Skip confirmation prompt'
|
|
25
|
+
)
|
|
26
|
+
def rollback(to_version: str, yes: bool) -> None:
|
|
27
|
+
"""
|
|
28
|
+
Rollback production to a previous version.
|
|
29
|
+
|
|
30
|
+
Restores the database from the snapshot created before the last upgrade
|
|
31
|
+
and checks out the corresponding ho-prod-X.Y.Z branch.
|
|
32
|
+
|
|
33
|
+
Requirements:
|
|
34
|
+
• Must be on a ho-prod-X.Y.Z branch
|
|
35
|
+
• Snapshot must exist for the target version
|
|
36
|
+
|
|
37
|
+
\b
|
|
38
|
+
Examples:
|
|
39
|
+
# Rollback to previous version (default)
|
|
40
|
+
half_orm dev rollback
|
|
41
|
+
|
|
42
|
+
# Rollback to a specific version
|
|
43
|
+
half_orm dev rollback --to-version 0.2.23
|
|
44
|
+
"""
|
|
45
|
+
try:
|
|
46
|
+
repo = Repo()
|
|
47
|
+
mgr = repo.release_manager
|
|
48
|
+
|
|
49
|
+
current_version = repo.database.last_release_s
|
|
50
|
+
available = mgr._list_rollback_versions()
|
|
51
|
+
|
|
52
|
+
if not available:
|
|
53
|
+
click.echo(utils.Color.red("❌ No snapshots available for rollback."), err=True)
|
|
54
|
+
raise click.Abort()
|
|
55
|
+
|
|
56
|
+
# Resolve default target
|
|
57
|
+
target = to_version
|
|
58
|
+
if target is None:
|
|
59
|
+
from packaging.version import Version
|
|
60
|
+
candidates = [v for v in available if Version(v) < Version(current_version)]
|
|
61
|
+
target = max(candidates, key=lambda v: Version(v)) if candidates else None
|
|
62
|
+
if target is None:
|
|
63
|
+
click.echo(
|
|
64
|
+
utils.Color.red(f"❌ No previous version available for {current_version}."),
|
|
65
|
+
err=True
|
|
66
|
+
)
|
|
67
|
+
raise click.Abort()
|
|
68
|
+
|
|
69
|
+
# Display options
|
|
70
|
+
click.echo(f"Current version: {utils.Color.bold(current_version)}")
|
|
71
|
+
click.echo(f"\nAvailable rollback versions:")
|
|
72
|
+
for v in available:
|
|
73
|
+
marker = f" {utils.Color.bold('← default')}" if v == target else ""
|
|
74
|
+
click.echo(f" • {utils.Color.bold(v)}{marker}")
|
|
75
|
+
|
|
76
|
+
click.echo(f"\nWill rollback: {current_version} → {utils.Color.bold(target)}")
|
|
77
|
+
click.echo(f"⚠️ This will REPLACE the current database with snapshot ho-prod-{target}.")
|
|
78
|
+
|
|
79
|
+
if not yes:
|
|
80
|
+
if not click.confirm("\nProceed?", default=False):
|
|
81
|
+
click.echo("Cancelled.")
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
click.echo("\nRolling back...")
|
|
85
|
+
result = mgr.rollback_production(to_version=target)
|
|
86
|
+
|
|
87
|
+
click.echo(
|
|
88
|
+
f"\n✓ {utils.Color.green('Rollback complete:')} "
|
|
89
|
+
f"{result['from_version']} → {utils.Color.bold(result['to_version'])}"
|
|
90
|
+
)
|
|
91
|
+
click.echo(f" Branch: {result['branch']}")
|
|
92
|
+
click.echo(f" Snapshot: {result['snapshot']}")
|
|
93
|
+
click.echo(
|
|
94
|
+
f"\n💡 The snapshot {result['snapshot']} is still available.\n"
|
|
95
|
+
f" To re-upgrade: half_orm dev upgrade"
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
except ReleaseManagerError as e:
|
|
99
|
+
click.echo(utils.Color.red(f"\n❌ {e}"), err=True)
|
|
100
|
+
raise click.Abort()
|
|
101
|
+
except Exception as e:
|
|
102
|
+
click.echo(utils.Color.red(f"\n❌ Unexpected error: {e}"), err=True)
|
|
103
|
+
raise click.Abort()
|
|
@@ -49,7 +49,7 @@ class Hop:
|
|
|
49
49
|
|
|
50
50
|
# PRODUCTION ENVIRONMENT — read-only, no migrations, no dev commands
|
|
51
51
|
if self.__repo.database.production:
|
|
52
|
-
return ['
|
|
52
|
+
return ['upgrade', 'rollback']
|
|
53
53
|
|
|
54
54
|
if self.__repo.needs_migration():
|
|
55
55
|
return ['migrate']
|
|
@@ -61,7 +61,7 @@ class Hop:
|
|
|
61
61
|
|
|
62
62
|
# DEVELOPMENT ENVIRONMENT - Patch development
|
|
63
63
|
return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin',
|
|
64
|
-
'revert-migration']
|
|
64
|
+
'revert-migration', 'recover']
|
|
65
65
|
|
|
66
66
|
@property
|
|
67
67
|
def repo_checked(self):
|
|
@@ -1335,6 +1335,29 @@ class Database:
|
|
|
1335
1335
|
'dropdb', '--if-exists', snapshot_name
|
|
1336
1336
|
)
|
|
1337
1337
|
|
|
1338
|
+
def restore_from_snapshot(self, snapshot_name: str) -> None:
|
|
1339
|
+
"""Restore this database from a snapshot (drop + recreate from template).
|
|
1340
|
+
|
|
1341
|
+
Requires CREATEDB privilege. Call terminate_active_connections() first,
|
|
1342
|
+
then reconnect() afterwards.
|
|
1343
|
+
|
|
1344
|
+
Args:
|
|
1345
|
+
snapshot_name: Name of the snapshot database to restore from.
|
|
1346
|
+
"""
|
|
1347
|
+
params = self._get_connection_params()
|
|
1348
|
+
self._execute_pg_command('postgres', params, 'dropdb', self.__name)
|
|
1349
|
+
self._execute_pg_command('postgres', params, 'createdb', '-T', snapshot_name, self.__name)
|
|
1350
|
+
|
|
1351
|
+
def list_snapshots(self) -> list:
|
|
1352
|
+
"""Return snapshot names matching {db}_hop_snap_* pattern, sorted ascending."""
|
|
1353
|
+
params = self._get_connection_params()
|
|
1354
|
+
result = self._execute_pg_command(
|
|
1355
|
+
'postgres', params,
|
|
1356
|
+
'psql', '-d', 'postgres', '-t', '-c',
|
|
1357
|
+
f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname"
|
|
1358
|
+
)
|
|
1359
|
+
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
1360
|
+
|
|
1338
1361
|
def get_postgres_version(self) -> tuple:
|
|
1339
1362
|
"""
|
|
1340
1363
|
Get PostgreSQL server version.
|
|
@@ -4,11 +4,23 @@ Decorators for half-orm-dev.
|
|
|
4
4
|
Provides common decorators for ReleaseManager and PatchManager.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
+
import os
|
|
7
8
|
import signal
|
|
8
9
|
import sys
|
|
9
10
|
import inspect
|
|
10
11
|
from functools import wraps
|
|
11
12
|
|
|
13
|
+
from git.exc import GitCommandError
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _has_recovery_refs(repo) -> bool:
|
|
17
|
+
"""Return True if refs/hop/sync/before/* exist (Phase 2 incomplete for some branch)."""
|
|
18
|
+
try:
|
|
19
|
+
refs = repo.hgit._HGit__git_repo.git.for_each_ref('refs/hop/sync/before/')
|
|
20
|
+
return bool(refs.strip())
|
|
21
|
+
except GitCommandError:
|
|
22
|
+
return False
|
|
23
|
+
|
|
12
24
|
|
|
13
25
|
def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
14
26
|
"""
|
|
@@ -43,13 +55,12 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
43
55
|
repo = getattr(self, '_repo', self)
|
|
44
56
|
lock_tag = None
|
|
45
57
|
locked_branch = None
|
|
58
|
+
# Set to True when Phase 2 pushed some but not all branches.
|
|
59
|
+
# In that case the lock and lock file must survive so 'hop recover'
|
|
60
|
+
# can complete the work.
|
|
61
|
+
_keep_lock_for_recovery = False
|
|
46
62
|
try:
|
|
47
63
|
# CRITICAL: Sync ho-prod with origin and validate version BEFORE acquiring any lock
|
|
48
|
-
# This ensures:
|
|
49
|
-
# 1. ho-prod is up-to-date (pull)
|
|
50
|
-
# 2. All branches are fetched (prune)
|
|
51
|
-
# 3. Repository hop_version is validated against installed version
|
|
52
|
-
# 4. Operation is blocked if version is outdated (prevents dangerous operations)
|
|
53
64
|
repo.sync_and_validate_ho_prod()
|
|
54
65
|
|
|
55
66
|
# Determine branch name dynamically
|
|
@@ -58,6 +69,14 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
58
69
|
# Acquire lock
|
|
59
70
|
lock_tag = repo.hgit.acquire_branch_lock(locked_branch, timeout_minutes=timeout_minutes)
|
|
60
71
|
|
|
72
|
+
# Write lock file so 'hop recover' can identify ownership after a crash
|
|
73
|
+
_sync_lock_path = os.path.join(repo.base_dir, '.git', 'hop-sync-lock')
|
|
74
|
+
try:
|
|
75
|
+
with open(_sync_lock_path, 'w') as _f:
|
|
76
|
+
_f.write(lock_tag)
|
|
77
|
+
except OSError:
|
|
78
|
+
pass
|
|
79
|
+
|
|
61
80
|
# Execute the method
|
|
62
81
|
result = func(self, *args, **kwargs)
|
|
63
82
|
|
|
@@ -66,20 +85,27 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
66
85
|
sync_result = repo.sync_hop_to_active_branches(
|
|
67
86
|
reason=f"{func.__name__}"
|
|
68
87
|
)
|
|
69
|
-
# Log sync errors but don't fail the operation
|
|
70
88
|
if sync_result.get('errors'):
|
|
71
89
|
for error in sync_result['errors']:
|
|
72
90
|
print(f"Warning: .hop/ sync error: {error}", file=sys.stderr)
|
|
91
|
+
# Recovery refs remaining means Phase 2 is incomplete
|
|
92
|
+
_keep_lock_for_recovery = _has_recovery_refs(repo)
|
|
73
93
|
except Exception as e:
|
|
74
|
-
# Don't fail the decorated method if sync fails
|
|
75
94
|
print(f"Warning: Failed to sync .hop/ to active branches: {e}", file=sys.stderr)
|
|
95
|
+
_keep_lock_for_recovery = _has_recovery_refs(repo)
|
|
96
|
+
|
|
97
|
+
if _keep_lock_for_recovery:
|
|
98
|
+
print(
|
|
99
|
+
"Warning: Sync partially failed. "
|
|
100
|
+
"Run 'hop recover' to complete the operation.",
|
|
101
|
+
file=sys.stderr
|
|
102
|
+
)
|
|
76
103
|
|
|
77
104
|
return result
|
|
78
105
|
finally:
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
if lock_tag:
|
|
106
|
+
if lock_tag and not _keep_lock_for_recovery:
|
|
107
|
+
# Normal completion or pre-sync failure: release lock and clean up.
|
|
108
|
+
# Block SIGINT to prevent Ctrl+C from leaving an orphan lock tag.
|
|
83
109
|
interrupted = False
|
|
84
110
|
original_handler = signal.getsignal(signal.SIGINT)
|
|
85
111
|
signal.signal(signal.SIGINT, lambda s, f: setattr(
|
|
@@ -93,6 +119,12 @@ def with_dynamic_branch_lock(branch_getter, timeout_minutes: int = 30):
|
|
|
93
119
|
if interrupted:
|
|
94
120
|
raise KeyboardInterrupt()
|
|
95
121
|
|
|
122
|
+
_sync_lock_path = os.path.join(repo.base_dir, '.git', 'hop-sync-lock')
|
|
123
|
+
try:
|
|
124
|
+
os.unlink(_sync_lock_path)
|
|
125
|
+
except FileNotFoundError:
|
|
126
|
+
pass
|
|
127
|
+
|
|
96
128
|
return wrapper
|
|
97
129
|
return decorator
|
|
98
130
|
|
|
@@ -5,6 +5,8 @@ This module provides common file execution functionality used by both
|
|
|
5
5
|
PatchManager and BootstrapManager.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import ast
|
|
9
|
+
import importlib.util
|
|
8
10
|
import re
|
|
9
11
|
import subprocess
|
|
10
12
|
import sys
|
|
@@ -98,6 +100,72 @@ def execute_python_file(file_path: Path, cwd: Optional[Path] = None) -> str:
|
|
|
98
100
|
raise FileExecutionError(f"Failed to execute Python file {file_path.name}: {e}") from e
|
|
99
101
|
|
|
100
102
|
|
|
103
|
+
def _has_run_entrypoint(file_path: Path) -> bool:
|
|
104
|
+
"""Return True if the file defines a top-level run() function."""
|
|
105
|
+
try:
|
|
106
|
+
tree = ast.parse(file_path.read_text(encoding='utf-8'))
|
|
107
|
+
except (OSError, SyntaxError):
|
|
108
|
+
return False
|
|
109
|
+
return any(
|
|
110
|
+
isinstance(node, ast.FunctionDef) and node.name == 'run'
|
|
111
|
+
for node in tree.body
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def execute_python_bootstrap(file_path: Path, model, cwd: Optional[Path] = None) -> str:
|
|
116
|
+
"""
|
|
117
|
+
Execute a Python bootstrap script.
|
|
118
|
+
|
|
119
|
+
Fast path — if the script defines a top-level run(model) function it is
|
|
120
|
+
loaded in-process via importlib and called with the live database model,
|
|
121
|
+
sharing the existing connection.
|
|
122
|
+
|
|
123
|
+
Slow path — scripts without run(model) are executed as a subprocess
|
|
124
|
+
(backwards-compatible with pre-API scripts).
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
file_path: Path to Python bootstrap script
|
|
128
|
+
model: halfORM Model instance (shared database connection)
|
|
129
|
+
cwd: Working directory for execution (default: file's parent)
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
Return value of run() converted to str, or subprocess stdout.
|
|
133
|
+
Empty string if run() returns None.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
FileExecutionError: If execution fails
|
|
137
|
+
"""
|
|
138
|
+
if cwd is None:
|
|
139
|
+
cwd = file_path.parent
|
|
140
|
+
|
|
141
|
+
if not _has_run_entrypoint(file_path):
|
|
142
|
+
return execute_python_file(file_path, cwd)
|
|
143
|
+
|
|
144
|
+
module_name = f"_hop_bootstrap_{file_path.stem.replace('-', '_').replace('.', '_')}"
|
|
145
|
+
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
|
146
|
+
module = importlib.util.module_from_spec(spec)
|
|
147
|
+
|
|
148
|
+
cwd_str = str(cwd)
|
|
149
|
+
inserted = cwd_str not in sys.path
|
|
150
|
+
if inserted:
|
|
151
|
+
sys.path.insert(0, cwd_str)
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
spec.loader.exec_module(module)
|
|
155
|
+
result = module.run(model)
|
|
156
|
+
return str(result) if result is not None else ''
|
|
157
|
+
except FileExecutionError:
|
|
158
|
+
raise
|
|
159
|
+
except Exception as e:
|
|
160
|
+
raise FileExecutionError(
|
|
161
|
+
f"Python execution failed in {file_path.name}: {e}"
|
|
162
|
+
) from e
|
|
163
|
+
finally:
|
|
164
|
+
if inserted and cwd_str in sys.path:
|
|
165
|
+
sys.path.remove(cwd_str)
|
|
166
|
+
sys.modules.pop(module_name, None)
|
|
167
|
+
|
|
168
|
+
|
|
101
169
|
_HOP_MARKER = re.compile(r"(--|#)\s*@hop:(bootstrap|data)")
|
|
102
170
|
|
|
103
171
|
|