half-orm-dev 1.0.0a32__tar.gz → 1.0.0a34__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.0a32/half_orm_dev.egg-info → half_orm_dev-1.0.0a34}/PKG-INFO +2 -2
- half_orm_dev-1.0.0a34/half_orm_dev/_bootstrap_runner.py +39 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/__init__.py +3 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/clone.py +2 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/init.py +20 -4
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/release.py +2 -2
- half_orm_dev-1.0.0a34/half_orm_dev/cli/commands/restore.py +62 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/todo.py +0 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/main.py +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/database.py +130 -104
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/file_executor.py +128 -13
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/hgit.py +29 -5
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migration_manager.py +22 -13
- half_orm_dev-1.0.0a34/half_orm_dev/migrations/1/0/0/a33/00_metadata_to_data_sql.py +61 -0
- half_orm_dev-1.0.0a34/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +54 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/modules.py +16 -29
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patch_manager.py +10 -14
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/release_manager.py +5 -16
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/repo.py +188 -131
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/.gitignore +3 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/init_module_template +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/module_template_1 +0 -1
- half_orm_dev-1.0.0a34/half_orm_dev/templates/module_template_2 +7 -0
- half_orm_dev-1.0.0a34/half_orm_dev/venv_setup.py +71 -0
- half_orm_dev-1.0.0a34/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34/half_orm_dev.egg-info}/PKG-INFO +2 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev.egg-info/SOURCES.txt +5 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev.egg-info/requires.txt +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/setup.py +2 -1
- half_orm_dev-1.0.0a34/tests/test_bootstrap_runner.py +65 -0
- half_orm_dev-1.0.0a32/half_orm_dev/cli/commands/restore.py +0 -14
- half_orm_dev-1.0.0a32/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -18
- half_orm_dev-1.0.0a32/half_orm_dev/templates/module_template_2 +0 -7
- half_orm_dev-1.0.0a32/half_orm_dev/version.txt +0 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/LICENSE +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/README.md +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/recover.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/rollback.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/decorators.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patch_validator.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/py.typed +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/git-hooks/pre-push +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a34}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: half_orm_dev
|
|
3
|
-
Version: 1.0.
|
|
3
|
+
Version: 1.0.0a34
|
|
4
4
|
Summary: half_orm development Framework.
|
|
5
5
|
Author-email: Joël Maïzi <joel.maizi@collorg.org>
|
|
6
6
|
License-Expression: GPL-3.0-or-later
|
|
@@ -25,7 +25,7 @@ Requires-Dist: packaging
|
|
|
25
25
|
Requires-Dist: pydash
|
|
26
26
|
Requires-Dist: pytest
|
|
27
27
|
Requires-Dist: pytest-asyncio
|
|
28
|
-
Requires-Dist: half_orm<1.1.0,>=1.0.
|
|
28
|
+
Requires-Dist: half_orm<1.1.0,>=1.0.0
|
|
29
29
|
Requires-Dist: tomli>=2.0.0; python_version < "3.11"
|
|
30
30
|
Requires-Dist: tomli_w>=1.0.0
|
|
31
31
|
Dynamic: license-file
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal entrypoint for running a single bootstrap script in a project's
|
|
3
|
+
own virtual environment, as a subprocess.
|
|
4
|
+
|
|
5
|
+
Invoked as:
|
|
6
|
+
<venv_python> -m half_orm_dev._bootstrap_runner <script_path> <database_name>
|
|
7
|
+
|
|
8
|
+
Not a public API - used exclusively by
|
|
9
|
+
half_orm_dev.file_executor.execute_python_bootstrap() when a venv_python
|
|
10
|
+
is provided (i.e. the project declared its own dependencies via
|
|
11
|
+
requirements.txt). Running via -m rather than a bare script path avoids
|
|
12
|
+
needing to locate this file on disk from inside the target venv; it only
|
|
13
|
+
needs half_orm_dev importable there, which pip install -e . already
|
|
14
|
+
guarantees (pyproject.toml always depends on half_orm_dev).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from half_orm.model import Model
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def main() -> None:
|
|
24
|
+
script_path, database_name = sys.argv[1], sys.argv[2]
|
|
25
|
+
|
|
26
|
+
spec = importlib.util.spec_from_file_location('_bootstrap_script', script_path)
|
|
27
|
+
module = importlib.util.module_from_spec(spec)
|
|
28
|
+
spec.loader.exec_module(module)
|
|
29
|
+
|
|
30
|
+
run = getattr(module, 'run', None)
|
|
31
|
+
if run is not None:
|
|
32
|
+
model = Model(database_name)
|
|
33
|
+
result = run(model)
|
|
34
|
+
if result is not None:
|
|
35
|
+
print(result)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
if __name__ == '__main__':
|
|
39
|
+
main()
|
|
@@ -17,11 +17,11 @@ from .migrate import migrate
|
|
|
17
17
|
from .revert_migration import revert_migration
|
|
18
18
|
from .rollback import rollback
|
|
19
19
|
from .recover import recover
|
|
20
|
+
from .restore import restore
|
|
20
21
|
from .todo import apply_release
|
|
21
22
|
|
|
22
23
|
# ♻️ Adapted existing commands
|
|
23
24
|
from .todo import sync_package # Unchanged
|
|
24
|
-
from .todo import restore # Adapted for new architecture
|
|
25
25
|
|
|
26
26
|
# Registry of all available commands - Git-centric architecture
|
|
27
27
|
ALL_COMMANDS = {
|
|
@@ -41,10 +41,10 @@ ALL_COMMANDS = {
|
|
|
41
41
|
# 🚧 Emergency workflow (stubs)
|
|
42
42
|
'rollback': rollback,
|
|
43
43
|
'recover': recover,
|
|
44
|
+
'restore': restore,
|
|
44
45
|
|
|
45
46
|
# ♻️ Adapted commands
|
|
46
47
|
'sync-package': sync_package, # Unchanged
|
|
47
|
-
'restore': restore, # Adapted
|
|
48
48
|
}
|
|
49
49
|
|
|
50
50
|
__all__ = [
|
|
@@ -58,6 +58,7 @@ __all__ = [
|
|
|
58
58
|
'migrate',
|
|
59
59
|
'rollback',
|
|
60
60
|
'recover',
|
|
61
|
+
'restore',
|
|
61
62
|
# Adapted commands
|
|
62
63
|
'sync_package',
|
|
63
64
|
'ALL_COMMANDS'
|
|
@@ -14,8 +14,8 @@ 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=
|
|
18
|
-
@click.option('--port', default=
|
|
17
|
+
@click.option('--host', default=None, help='PostgreSQL host (default: $PGHOST, then localhost)')
|
|
18
|
+
@click.option('--port', default=None, type=int, help='PostgreSQL port (default: $PGPORT, then 5432)')
|
|
19
19
|
@click.option('--user', default=None, help='Database user (default: $USER)')
|
|
20
20
|
@click.option('--password', default=None, help='Database password (prompts if missing)')
|
|
21
21
|
@click.option('--production', is_flag=True, help='Production mode (default: False)')
|
|
@@ -25,8 +25,8 @@ class ProjectDirectoryExistsError(Exception):
|
|
|
25
25
|
|
|
26
26
|
@click.command('init')
|
|
27
27
|
@click.argument('project_name')
|
|
28
|
-
@click.option('--host', default=
|
|
29
|
-
@click.option('--port', default=
|
|
28
|
+
@click.option('--host', default=None, help='PostgreSQL host (default: $PGHOST, then localhost)')
|
|
29
|
+
@click.option('--port', default=None, type=int, help='PostgreSQL port (default: $PGPORT, then 5432)')
|
|
30
30
|
@click.option('--user', default=None, help='Database user (default: $USER)')
|
|
31
31
|
@click.option('--password', default=None, help='Database password (prompts if missing)')
|
|
32
32
|
@click.option('--git-origin', default=None, help='Git remote origin URL (prompts if missing)')
|
|
@@ -34,7 +34,15 @@ class ProjectDirectoryExistsError(Exception):
|
|
|
34
34
|
@click.option('--force-sync-only', is_flag=True, help='Skip metadata installation, force sync-only mode')
|
|
35
35
|
@click.option('--create-db', is_flag=False, default=True)
|
|
36
36
|
@click.option('--docker', default='', help='Docker container name for PostgreSQL')
|
|
37
|
-
|
|
37
|
+
@click.option(
|
|
38
|
+
'--with-meta', is_flag=False, flag_value='__ALL__', default=None,
|
|
39
|
+
metavar='[NAMES]',
|
|
40
|
+
help='Expose half_orm_meta data on the generated Model. Bare flag '
|
|
41
|
+
'exposes everything; pass a comma-separated allowlist of '
|
|
42
|
+
'fully-qualified relation names to expose only those, e.g. '
|
|
43
|
+
'--with-meta=half_orm_meta.identity.user'
|
|
44
|
+
)
|
|
45
|
+
def init(project_name, host, port, user, password, git_origin, production, force_sync_only, create_db, docker, with_meta):
|
|
38
46
|
"""
|
|
39
47
|
Initialize a new half_orm_dev project with database and code structure.
|
|
40
48
|
|
|
@@ -187,10 +195,18 @@ def init(project_name, host, port, user, password, git_origin, production, force
|
|
|
187
195
|
click.echo(f"📁 Creating project structure...")
|
|
188
196
|
|
|
189
197
|
# Now safe to instantiate Repo (database is configured)
|
|
198
|
+
if with_meta is None:
|
|
199
|
+
with_half_orm_meta = False
|
|
200
|
+
elif with_meta == '__ALL__':
|
|
201
|
+
with_half_orm_meta = True
|
|
202
|
+
else:
|
|
203
|
+
with_half_orm_meta = with_meta
|
|
204
|
+
|
|
190
205
|
repo = Repo()
|
|
191
206
|
repo.init_git_centric_project(
|
|
192
207
|
package_name=package_name,
|
|
193
|
-
git_origin=git_origin
|
|
208
|
+
git_origin=git_origin,
|
|
209
|
+
with_half_orm_meta=with_half_orm_meta
|
|
194
210
|
)
|
|
195
211
|
|
|
196
212
|
# ============================================================
|
|
@@ -180,7 +180,7 @@ def release_promote(target: str) -> None:
|
|
|
180
180
|
2. Validate sequential version rule
|
|
181
181
|
3. Acquire distributed lock on ho-prod
|
|
182
182
|
4. Restore database and apply all patches
|
|
183
|
-
5. Generate schema-X.Y.Z.sql and
|
|
183
|
+
5. Generate schema-X.Y.Z.sql and data-X.Y.Z.sql
|
|
184
184
|
6. Update schema.sql symlink
|
|
185
185
|
7. Rename RC file to production file (git mv)
|
|
186
186
|
8. Commit and push promotion
|
|
@@ -203,7 +203,7 @@ def release_promote(target: str) -> None:
|
|
|
203
203
|
Output:
|
|
204
204
|
✓ Promoted 1.3.5-rc1 → 1.3.5
|
|
205
205
|
✓ Generated schema-1.3.5.sql
|
|
206
|
-
✓ Generated
|
|
206
|
+
✓ Generated data-1.3.5.sql
|
|
207
207
|
✓ Updated schema.sql → schema-1.3.5.sql
|
|
208
208
|
|
|
209
209
|
\b
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Restore command - Restore the development database to a given release.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from half_orm_dev.repo import Repo, RepoError
|
|
9
|
+
from half_orm import utils
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@click.command()
|
|
13
|
+
@click.argument('release')
|
|
14
|
+
def restore(release: str) -> None:
|
|
15
|
+
"""
|
|
16
|
+
Restore the database to the state of RELEASE.
|
|
17
|
+
|
|
18
|
+
Drops all user schemas and reloads them, picking the most precise
|
|
19
|
+
snapshot available for RELEASE:
|
|
20
|
+
|
|
21
|
+
\b
|
|
22
|
+
1. .hop/model/release-RELEASE.sql (production + patches staged
|
|
23
|
+
for that release, still in development)
|
|
24
|
+
2. .hop/model/schema-RELEASE.sql (published snapshot of that
|
|
25
|
+
exact released version)
|
|
26
|
+
|
|
27
|
+
Fails with an error, rather than silently loading a different
|
|
28
|
+
version, if neither file exists.
|
|
29
|
+
|
|
30
|
+
\b
|
|
31
|
+
Examples:
|
|
32
|
+
# Restore to a release currently in development
|
|
33
|
+
half_orm dev restore 0.17.1
|
|
34
|
+
|
|
35
|
+
# Restore to an already-published version
|
|
36
|
+
half_orm dev restore 0.3.5
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
repo = Repo()
|
|
40
|
+
click.echo(f"Restoring database to release {utils.Color.bold(release)}...")
|
|
41
|
+
|
|
42
|
+
release_schema_path = repo.get_release_schema_path(release)
|
|
43
|
+
schema_path = Path(repo.model_dir) / f"schema-{release}.sql"
|
|
44
|
+
|
|
45
|
+
if release_schema_path.exists():
|
|
46
|
+
repo.restore_database_from_release_schema(release)
|
|
47
|
+
elif schema_path.exists():
|
|
48
|
+
repo.restore_database_from_version_schema(release)
|
|
49
|
+
else:
|
|
50
|
+
raise RepoError(
|
|
51
|
+
f"No schema found for release '{release}': neither "
|
|
52
|
+
f"{release_schema_path.name} nor {schema_path.name} "
|
|
53
|
+
f"exists in {repo.model_dir}."
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
click.echo(f"✓ {utils.Color.green('Database restored to')} {utils.Color.bold(release)}")
|
|
57
|
+
except RepoError as e:
|
|
58
|
+
click.echo(utils.Color.red(f"\n❌ {e}"), err=True)
|
|
59
|
+
raise click.Abort()
|
|
60
|
+
except Exception as e:
|
|
61
|
+
click.echo(utils.Color.red(f"\n❌ Unexpected error: {e}"), err=True)
|
|
62
|
+
raise click.Abort()
|
|
@@ -42,7 +42,6 @@ def todo(ctx):
|
|
|
42
42
|
'create-hotfix': 'Create emergency hotfix bypassing normal workflow',
|
|
43
43
|
'rollback': 'Rollback database to previous version using backups/',
|
|
44
44
|
'sync-package': 'Synchronize Python package with database model',
|
|
45
|
-
'restore': 'Restore database to specific version (adapt for new backups)',
|
|
46
45
|
'list-patches': 'List all patches in Patches/ directory',
|
|
47
46
|
'status': 'Show development status with patch/release information',
|
|
48
47
|
'apply-release': 'Apply next release',
|
|
@@ -70,4 +69,3 @@ status = todo
|
|
|
70
69
|
|
|
71
70
|
# ♻️ Commands to adapt (also in todo for now)
|
|
72
71
|
sync_package = todo # Keep functionality, adapt to new architecture
|
|
73
|
-
restore = todo # Adapt for new backup/restore logic
|
|
@@ -81,7 +81,9 @@ class Database:
|
|
|
81
81
|
db_name = self.__repo.database_name
|
|
82
82
|
if db_name:
|
|
83
83
|
try:
|
|
84
|
-
self.__model = Model(
|
|
84
|
+
self.__model = Model(
|
|
85
|
+
db_name, with_half_orm_meta=self.__repo.with_half_orm_meta,
|
|
86
|
+
)
|
|
85
87
|
self.__init(db_name, get_release)
|
|
86
88
|
except OperationalError as err:
|
|
87
89
|
if not self.__repo.new:
|
|
@@ -175,10 +177,10 @@ class Database:
|
|
|
175
177
|
major=0, minor=0, patch=0, changelog='Initial release')
|
|
176
178
|
return self(self.__name)
|
|
177
179
|
|
|
178
|
-
def execute_pg_command(self, *command_args):
|
|
180
|
+
def execute_pg_command(self, *command_args, database_name=None):
|
|
179
181
|
"""Execute PostgreSQL command with instance's connection parameters."""
|
|
180
182
|
return self._execute_pg_command(
|
|
181
|
-
self.__name,
|
|
183
|
+
database_name or self.__name,
|
|
182
184
|
self._get_connection_params(),
|
|
183
185
|
*command_args
|
|
184
186
|
)
|
|
@@ -206,9 +208,12 @@ class Database:
|
|
|
206
208
|
Generate versioned schema SQL dump.
|
|
207
209
|
|
|
208
210
|
Creates model/schema-{version}.sql with current database structure
|
|
209
|
-
using pg_dump --schema-only. Creates model/
|
|
210
|
-
|
|
211
|
-
|
|
211
|
+
using pg_dump --schema-only. Creates model/data-{version}.sql with
|
|
212
|
+
the complete data snapshot (half_orm_meta bookkeeping + all
|
|
213
|
+
application data) using pg_dump --data-only, so a fresh instance
|
|
214
|
+
restored from these two files is immediately usable - no separate
|
|
215
|
+
script execution needed. Updates model/schema.sql symlink to point
|
|
216
|
+
to the new version.
|
|
212
217
|
|
|
213
218
|
This method is used by:
|
|
214
219
|
- init-project: Generate initial schema-0.0.0.sql after database setup
|
|
@@ -239,32 +244,33 @@ class Database:
|
|
|
239
244
|
model_dir = Path("/project/model")
|
|
240
245
|
schema_path = database._generate_schema_sql("0.0.0", model_dir)
|
|
241
246
|
# → Creates model/schema-0.0.0.sql
|
|
242
|
-
# → Creates model/
|
|
247
|
+
# → Creates model/data-0.0.0.sql
|
|
243
248
|
# → Creates symlink model/schema.sql → schema-0.0.0.sql
|
|
244
249
|
# → Returns Path("/project/model/schema-0.0.0.sql")
|
|
245
250
|
|
|
246
251
|
# During deploy-to-prod - save production schema
|
|
247
252
|
schema_path = database._generate_schema_sql("1.3.4", model_dir)
|
|
248
253
|
# → Creates model/schema-1.3.4.sql
|
|
249
|
-
# → Creates model/
|
|
254
|
+
# → Creates model/data-1.3.4.sql
|
|
250
255
|
# → Updates symlink model/schema.sql → schema-1.3.4.sql
|
|
251
256
|
|
|
252
257
|
File Structure Created:
|
|
253
258
|
model/
|
|
254
|
-
├── schema.sql
|
|
255
|
-
├── schema-0.0.0.sql
|
|
256
|
-
├──
|
|
257
|
-
├── schema-1.0.0.sql
|
|
258
|
-
├──
|
|
259
|
-
├── schema-1.3.4.sql
|
|
260
|
-
├──
|
|
259
|
+
├── schema.sql # Symlink to current version
|
|
260
|
+
├── schema-0.0.0.sql # Initial version (structure)
|
|
261
|
+
├── data-0.0.0.sql # Initial version (half_orm_meta + app data)
|
|
262
|
+
├── schema-1.0.0.sql # Production version (structure)
|
|
263
|
+
├── data-1.0.0.sql # Production version (half_orm_meta + app data)
|
|
264
|
+
├── schema-1.3.4.sql # Latest production version (current)
|
|
265
|
+
├── data-1.3.4.sql # Latest production version (current)
|
|
261
266
|
└── ...
|
|
262
267
|
|
|
263
268
|
Notes:
|
|
264
269
|
- Uses pg_dump --schema-only for structure (no data)
|
|
265
|
-
- Uses pg_dump --data-only for
|
|
270
|
+
- Uses pg_dump --data-only for data (all tables: half_orm_meta
|
|
271
|
+
bookkeeping and application data alike)
|
|
266
272
|
- Symlink is relative (schema.sql → schema-X.Y.Z.sql)
|
|
267
|
-
- No symlink for
|
|
273
|
+
- No symlink for data (version deduced from schema.sql)
|
|
268
274
|
- Existing symlink is replaced atomically
|
|
269
275
|
- Version format should be X.Y.Z (semantic versioning)
|
|
270
276
|
"""
|
|
@@ -329,25 +335,74 @@ class Database:
|
|
|
329
335
|
if temp_schema_file.exists():
|
|
330
336
|
temp_schema_file.unlink()
|
|
331
337
|
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
338
|
+
self._generate_data_sql(version, model_dir)
|
|
339
|
+
|
|
340
|
+
# Create or update symlink
|
|
341
|
+
symlink_path = model_dir / "schema.sql"
|
|
342
|
+
symlink_target = f"schema-{version}.sql" # Relative path
|
|
336
343
|
|
|
337
344
|
try:
|
|
338
|
-
#
|
|
345
|
+
# Remove existing symlink if it exists
|
|
346
|
+
if symlink_path.exists() or symlink_path.is_symlink():
|
|
347
|
+
symlink_path.unlink()
|
|
348
|
+
|
|
349
|
+
# Create new symlink (relative)
|
|
350
|
+
symlink_path.symlink_to(symlink_target)
|
|
351
|
+
|
|
352
|
+
except PermissionError as e:
|
|
353
|
+
raise PermissionError(
|
|
354
|
+
f"Permission denied: cannot create symlink in {model_dir}"
|
|
355
|
+
) from e
|
|
356
|
+
except OSError as e:
|
|
357
|
+
raise OSError(
|
|
358
|
+
f"Failed to create symlink {symlink_path} → {symlink_target}: {e}"
|
|
359
|
+
) from e
|
|
360
|
+
|
|
361
|
+
return schema_file
|
|
362
|
+
|
|
363
|
+
def _generate_data_sql(self, version: str, model_dir: Path) -> Path:
|
|
364
|
+
"""
|
|
365
|
+
Generate model/data-{version}.sql: a full pg_dump --data-only
|
|
366
|
+
snapshot (half_orm_meta bookkeeping + all application data) for
|
|
367
|
+
the given version.
|
|
368
|
+
|
|
369
|
+
Extracted out of _generate_schema_sql() so it can also be called
|
|
370
|
+
standalone by the metadata->data migration (which needs to
|
|
371
|
+
backfill this file for a project's already-published current
|
|
372
|
+
version, without touching schema-{version}.sql or the symlink).
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
version: Version string (e.g., "1.3.4")
|
|
376
|
+
model_dir: Path to model/ directory where the file is written
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Path to the generated data file (model/data-{version}.sql)
|
|
380
|
+
|
|
381
|
+
Raises:
|
|
382
|
+
Exception: If pg_dump fails
|
|
383
|
+
"""
|
|
384
|
+
data_file = model_dir / f"data-{version}.sql"
|
|
385
|
+
temp_file = model_dir / f".data-{version}.sql.tmp"
|
|
386
|
+
|
|
387
|
+
try:
|
|
388
|
+
# Dump to temporary file (no --table= restriction: this file
|
|
389
|
+
# must be a complete, self-contained snapshot for a fresh
|
|
390
|
+
# instance - half_orm_meta bookkeeping and application data
|
|
391
|
+
# alike)
|
|
339
392
|
self.execute_pg_command(
|
|
340
393
|
'pg_dump',
|
|
341
394
|
self.__name,
|
|
342
395
|
'--data-only',
|
|
343
|
-
'--table=half_orm_meta.database',
|
|
344
|
-
'--table=half_orm_meta.hop_release',
|
|
345
|
-
'--table=half_orm_meta.hop_release_issue',
|
|
346
396
|
'-f',
|
|
347
397
|
str(temp_file)
|
|
348
398
|
)
|
|
349
399
|
|
|
350
400
|
# Filter to keep only COPY blocks (COPY ... FROM stdin; ... \.)
|
|
401
|
+
# and the SELECT pg_catalog.setval(...) calls pg_dump emits
|
|
402
|
+
# after them - dropping those would leave every serial/identity
|
|
403
|
+
# sequence at its default start value, causing the next INSERT
|
|
404
|
+
# relying on it to collide with rows restored by COPY. Version-
|
|
405
|
+
# specific SET commands are what we actually want to avoid.
|
|
351
406
|
content = temp_file.read_text()
|
|
352
407
|
filtered_lines = []
|
|
353
408
|
in_copy_block = False
|
|
@@ -359,37 +414,17 @@ class Database:
|
|
|
359
414
|
if line == '\\.':
|
|
360
415
|
in_copy_block = False
|
|
361
416
|
filtered_lines.append('') # Empty line between blocks
|
|
417
|
+
elif not in_copy_block and line.startswith('SELECT pg_catalog.setval('):
|
|
418
|
+
filtered_lines.append(line)
|
|
362
419
|
|
|
363
|
-
|
|
420
|
+
data_file.write_text('\n'.join(filtered_lines))
|
|
364
421
|
except Exception as e:
|
|
365
|
-
raise Exception(f"Failed to generate
|
|
422
|
+
raise Exception(f"Failed to generate data SQL: {e}") from e
|
|
366
423
|
finally:
|
|
367
|
-
# Clean up temporary file
|
|
368
424
|
if temp_file.exists():
|
|
369
425
|
temp_file.unlink()
|
|
370
426
|
|
|
371
|
-
|
|
372
|
-
symlink_path = model_dir / "schema.sql"
|
|
373
|
-
symlink_target = f"schema-{version}.sql" # Relative path
|
|
374
|
-
|
|
375
|
-
try:
|
|
376
|
-
# Remove existing symlink if it exists
|
|
377
|
-
if symlink_path.exists() or symlink_path.is_symlink():
|
|
378
|
-
symlink_path.unlink()
|
|
379
|
-
|
|
380
|
-
# Create new symlink (relative)
|
|
381
|
-
symlink_path.symlink_to(symlink_target)
|
|
382
|
-
|
|
383
|
-
except PermissionError as e:
|
|
384
|
-
raise PermissionError(
|
|
385
|
-
f"Permission denied: cannot create symlink in {model_dir}"
|
|
386
|
-
) from e
|
|
387
|
-
except OSError as e:
|
|
388
|
-
raise OSError(
|
|
389
|
-
f"Failed to create symlink {symlink_path} → {symlink_target}: {e}"
|
|
390
|
-
) from e
|
|
391
|
-
|
|
392
|
-
return schema_file
|
|
427
|
+
return data_file
|
|
393
428
|
|
|
394
429
|
@classmethod
|
|
395
430
|
def _save_configuration(cls, database_name, connection_params):
|
|
@@ -632,13 +667,12 @@ class Database:
|
|
|
632
667
|
"""
|
|
633
668
|
# Prepare environment variables for PostgreSQL commands
|
|
634
669
|
env = os.environ.copy()
|
|
635
|
-
|
|
636
|
-
env
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
env['PGPASSWORD'] = connection_params['password']
|
|
670
|
+
pg_env_keys = {'user': 'PGUSER', 'host': 'PGHOST', 'port': 'PGPORT', 'password': 'PGPASSWORD'}
|
|
671
|
+
env.update({
|
|
672
|
+
pg_env_keys[k]: str(v)
|
|
673
|
+
for k, v in connection_params.items()
|
|
674
|
+
if k in pg_env_keys and v
|
|
675
|
+
})
|
|
642
676
|
|
|
643
677
|
# Execute PostgreSQL command
|
|
644
678
|
result = subprocess.run(
|
|
@@ -1101,9 +1135,11 @@ class Database:
|
|
|
1101
1135
|
"""
|
|
1102
1136
|
Collect missing connection parameters interactively.
|
|
1103
1137
|
|
|
1104
|
-
Takes partial connection parameters from CLI options
|
|
1105
|
-
|
|
1106
|
-
standard
|
|
1138
|
+
Takes partial connection parameters from CLI options. Missing
|
|
1139
|
+
user/password are prompted for interactively. Missing host/port
|
|
1140
|
+
are resolved like any standard PostgreSQL client tool - from the
|
|
1141
|
+
PGHOST/PGPORT environment variables, then localhost/5432 - never
|
|
1142
|
+
prompted for.
|
|
1107
1143
|
|
|
1108
1144
|
Args:
|
|
1109
1145
|
database_name (str): PostgreSQL database name for context
|
|
@@ -1127,11 +1163,12 @@ class Database:
|
|
|
1127
1163
|
EOFError: If input stream is closed during prompts
|
|
1128
1164
|
|
|
1129
1165
|
Interactive Behavior:
|
|
1130
|
-
- Only prompts for missing/None
|
|
1131
|
-
- Shows current defaults in prompts: "Host (localhost): "
|
|
1166
|
+
- Only prompts for missing/None user/password
|
|
1132
1167
|
- Uses getpass for secure password input
|
|
1133
1168
|
- Allows empty input to accept defaults
|
|
1134
1169
|
- Confirms production flag if True
|
|
1170
|
+
- host/port are never prompted for (PGHOST/PGPORT env vars,
|
|
1171
|
+
then localhost/5432)
|
|
1135
1172
|
|
|
1136
1173
|
Examples:
|
|
1137
1174
|
# Complete parameters provided - no prompts
|
|
@@ -1149,13 +1186,14 @@ class Database:
|
|
|
1149
1186
|
# Prompts: "User (current_user): " and "Password: [hidden]"
|
|
1150
1187
|
# Returns: {'host': 'localhost', 'port': 5432, 'user': 'prompted_user', 'password': 'prompted_pass', 'production': False}
|
|
1151
1188
|
|
|
1152
|
-
#
|
|
1189
|
+
# Host/port omitted - resolved from PGHOST/PGPORT (or
|
|
1190
|
+
# localhost/5432), no prompt
|
|
1153
1191
|
complete = Database._collect_connection_params(
|
|
1154
1192
|
"my_db",
|
|
1155
|
-
{'host': '
|
|
1193
|
+
{'host': None, 'port': None, 'user': 'dev', 'password': 'secret'}
|
|
1156
1194
|
)
|
|
1157
|
-
# Prompts:
|
|
1158
|
-
# Returns: complete dict with
|
|
1195
|
+
# Prompts: nothing for host/port
|
|
1196
|
+
# Returns: complete dict with PGHOST/PGPORT-resolved host/port
|
|
1159
1197
|
|
|
1160
1198
|
# Production flag confirmation
|
|
1161
1199
|
complete = Database._collect_connection_params(
|
|
@@ -1189,29 +1227,23 @@ class Database:
|
|
|
1189
1227
|
else:
|
|
1190
1228
|
complete_params['password'] = password_input
|
|
1191
1229
|
|
|
1192
|
-
#
|
|
1230
|
+
# Host/port are never prompted for interactively - like psql,
|
|
1231
|
+
# pg_dump, createdb and every other standard PostgreSQL client
|
|
1232
|
+
# tool, they fall back to the PGHOST/PGPORT environment variables
|
|
1233
|
+
# (and finally to localhost/5432) rather than asking the user.
|
|
1193
1234
|
if complete_params.get('host') is None:
|
|
1194
|
-
|
|
1195
|
-
complete_params['host'] = host_input if host_input else 'localhost'
|
|
1235
|
+
complete_params['host'] = os.environ.get('PGHOST', 'localhost')
|
|
1196
1236
|
|
|
1197
|
-
# Prompt for port if None
|
|
1198
1237
|
if complete_params.get('port') is None:
|
|
1199
|
-
|
|
1200
|
-
if
|
|
1238
|
+
port_env = os.environ.get('PGPORT')
|
|
1239
|
+
if port_env:
|
|
1201
1240
|
try:
|
|
1202
|
-
complete_params['port'] = int(
|
|
1241
|
+
complete_params['port'] = int(port_env)
|
|
1203
1242
|
except ValueError:
|
|
1204
|
-
raise ValueError(f"Invalid port number: {
|
|
1243
|
+
raise ValueError(f"Invalid port number in PGPORT: {port_env}")
|
|
1205
1244
|
else:
|
|
1206
1245
|
complete_params['port'] = 5432
|
|
1207
1246
|
|
|
1208
|
-
# Apply defaults for still missing parameters (no prompts needed)
|
|
1209
|
-
if complete_params.get('host') is None:
|
|
1210
|
-
complete_params['host'] = 'localhost'
|
|
1211
|
-
|
|
1212
|
-
if complete_params.get('port') is None:
|
|
1213
|
-
complete_params['port'] = 5432
|
|
1214
|
-
|
|
1215
1247
|
if complete_params.get('user') is None:
|
|
1216
1248
|
complete_params['user'] = os.environ.get('USER', 'postgres')
|
|
1217
1249
|
|
|
@@ -1277,11 +1309,10 @@ class Database:
|
|
|
1277
1309
|
|
|
1278
1310
|
def has_createdb_privilege(self) -> bool:
|
|
1279
1311
|
"""Check if the current PostgreSQL user has the CREATEDB privilege."""
|
|
1280
|
-
|
|
1281
|
-
result = self._execute_pg_command(
|
|
1282
|
-
'postgres', params,
|
|
1312
|
+
result = self.execute_pg_command(
|
|
1283
1313
|
'psql', '-d', 'postgres', '-t', '-c',
|
|
1284
|
-
"SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user"
|
|
1314
|
+
"SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user",
|
|
1315
|
+
database_name='postgres'
|
|
1285
1316
|
)
|
|
1286
1317
|
return result.stdout.strip().lower() == 't'
|
|
1287
1318
|
|
|
@@ -1294,14 +1325,13 @@ class Database:
|
|
|
1294
1325
|
Returns:
|
|
1295
1326
|
Number of connections terminated.
|
|
1296
1327
|
"""
|
|
1297
|
-
params = self._get_connection_params()
|
|
1298
1328
|
sql = (
|
|
1299
1329
|
f"SELECT count(pg_terminate_backend(pid)) FROM pg_stat_activity "
|
|
1300
1330
|
f"WHERE datname = '{self.__name}'"
|
|
1301
1331
|
)
|
|
1302
|
-
result = self.
|
|
1303
|
-
'postgres',
|
|
1304
|
-
'
|
|
1332
|
+
result = self.execute_pg_command(
|
|
1333
|
+
'psql', '-d', 'postgres', '-t', '-c', sql,
|
|
1334
|
+
database_name='postgres'
|
|
1305
1335
|
)
|
|
1306
1336
|
try:
|
|
1307
1337
|
return int(result.stdout.strip())
|
|
@@ -1317,10 +1347,9 @@ class Database:
|
|
|
1317
1347
|
Args:
|
|
1318
1348
|
snapshot_name: Name for the new snapshot database.
|
|
1319
1349
|
"""
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
'postgres'
|
|
1323
|
-
'createdb', '-T', self.__name, snapshot_name
|
|
1350
|
+
self.execute_pg_command(
|
|
1351
|
+
'createdb', '-T', self.__name, snapshot_name,
|
|
1352
|
+
database_name='postgres'
|
|
1324
1353
|
)
|
|
1325
1354
|
|
|
1326
1355
|
def drop_snapshot(self, snapshot_name: str) -> None:
|
|
@@ -1329,10 +1358,9 @@ class Database:
|
|
|
1329
1358
|
Args:
|
|
1330
1359
|
snapshot_name: Name of the snapshot database to drop.
|
|
1331
1360
|
"""
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
'postgres'
|
|
1335
|
-
'dropdb', '--if-exists', snapshot_name
|
|
1361
|
+
self.execute_pg_command(
|
|
1362
|
+
'dropdb', '--if-exists', snapshot_name,
|
|
1363
|
+
database_name='postgres'
|
|
1336
1364
|
)
|
|
1337
1365
|
|
|
1338
1366
|
def restore_from_snapshot(self, snapshot_name: str) -> None:
|
|
@@ -1344,17 +1372,15 @@ class Database:
|
|
|
1344
1372
|
Args:
|
|
1345
1373
|
snapshot_name: Name of the snapshot database to restore from.
|
|
1346
1374
|
"""
|
|
1347
|
-
|
|
1348
|
-
self.
|
|
1349
|
-
self._execute_pg_command('postgres', params, 'createdb', '-T', snapshot_name, self.__name)
|
|
1375
|
+
self.execute_pg_command('dropdb', self.__name, database_name='postgres')
|
|
1376
|
+
self.execute_pg_command('createdb', '-T', snapshot_name, self.__name, database_name='postgres')
|
|
1350
1377
|
|
|
1351
1378
|
def list_snapshots(self) -> list:
|
|
1352
1379
|
"""Return snapshot names matching {db}_hop_snap_* pattern, sorted ascending."""
|
|
1353
|
-
|
|
1354
|
-
result = self._execute_pg_command(
|
|
1355
|
-
'postgres', params,
|
|
1380
|
+
result = self.execute_pg_command(
|
|
1356
1381
|
'psql', '-d', 'postgres', '-t', '-c',
|
|
1357
|
-
f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname"
|
|
1382
|
+
f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname",
|
|
1383
|
+
database_name='postgres'
|
|
1358
1384
|
)
|
|
1359
1385
|
return [line.strip() for line in result.stdout.splitlines() if line.strip()]
|
|
1360
1386
|
|