half-orm-dev 1.0.0a32__tar.gz → 1.0.0a33__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. {half_orm_dev-1.0.0a32/half_orm_dev.egg-info → half_orm_dev-1.0.0a33}/PKG-INFO +2 -2
  2. half_orm_dev-1.0.0a33/half_orm_dev/_bootstrap_runner.py +39 -0
  3. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/__init__.py +3 -2
  4. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/clone.py +2 -2
  5. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/init.py +20 -4
  6. half_orm_dev-1.0.0a33/half_orm_dev/cli/commands/restore.py +62 -0
  7. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/todo.py +0 -2
  8. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/main.py +1 -1
  9. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/database.py +124 -104
  10. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/file_executor.py +127 -12
  11. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/hgit.py +29 -5
  12. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migration_manager.py +22 -13
  13. half_orm_dev-1.0.0a33/half_orm_dev/migrations/1/0/0/a33/00_metadata_to_data_sql.py +61 -0
  14. half_orm_dev-1.0.0a33/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +54 -0
  15. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/modules.py +16 -29
  16. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patch_manager.py +10 -14
  17. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/release_manager.py +4 -15
  18. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/repo.py +170 -124
  19. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/.gitignore +3 -1
  20. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/init_module_template +1 -1
  21. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/module_template_1 +0 -1
  22. half_orm_dev-1.0.0a33/half_orm_dev/templates/module_template_2 +7 -0
  23. half_orm_dev-1.0.0a33/half_orm_dev/venv_setup.py +71 -0
  24. half_orm_dev-1.0.0a33/half_orm_dev/version.txt +1 -0
  25. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33/half_orm_dev.egg-info}/PKG-INFO +2 -2
  26. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/SOURCES.txt +5 -1
  27. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/requires.txt +1 -1
  28. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/setup.py +2 -1
  29. half_orm_dev-1.0.0a33/tests/test_bootstrap_runner.py +65 -0
  30. half_orm_dev-1.0.0a32/half_orm_dev/cli/commands/restore.py +0 -14
  31. half_orm_dev-1.0.0a32/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -18
  32. half_orm_dev-1.0.0a32/half_orm_dev/templates/module_template_2 +0 -7
  33. half_orm_dev-1.0.0a32/half_orm_dev/version.txt +0 -1
  34. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/AUTHORS +0 -0
  35. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/LICENSE +0 -0
  36. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/README.md +0 -0
  37. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/__init__.py +0 -0
  38. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/__init__.py +0 -0
  39. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/apply.py +0 -0
  40. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/check.py +0 -0
  41. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/migrate.py +0 -0
  42. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/patch.py +0 -0
  43. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/recover.py +0 -0
  44. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/release.py +0 -0
  45. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/revert_migration.py +0 -0
  46. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/rollback.py +0 -0
  47. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  48. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/sync.py +0 -0
  49. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/undo.py +0 -0
  50. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/upgrade.py +0 -0
  51. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli_extension.py +0 -0
  52. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/decorators.py +0 -0
  53. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  54. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  55. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  56. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  57. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  58. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  59. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  60. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migrations/1/0/0/a20/01_update_gitignore.py +0 -0
  61. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patch_validator.py +0 -0
  62. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  63. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  64. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  65. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/log +0 -0
  66. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  67. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/py.typed +0 -0
  68. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/release_file.py +0 -0
  69. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/scripts/repair-metadata.py +0 -0
  70. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/MANIFEST.in +0 -0
  71. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/README +0 -0
  72. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/conftest_template +0 -0
  73. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  74. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/pre-push +0 -0
  75. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  76. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
  77. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/module_template_3 +0 -0
  78. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/pyproject.toml +0 -0
  79. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/relation_test +0 -0
  80. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/sql_adapter +0 -0
  81. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/warning +0 -0
  82. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/utils.py +0 -0
  83. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  84. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/entry_points.txt +0 -0
  85. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/top_level.txt +0 -0
  86. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/pyproject.toml +0 -0
  87. {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a32
3
+ Version: 1.0.0a33
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.0a1
28
+ Requires-Dist: half_orm<1.1.0,>=1.0.0rc16
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='localhost', help='PostgreSQL host (default: localhost)')
18
- @click.option('--port', default=5432, type=int, help='PostgreSQL port (default: 5432)')
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='localhost', help='PostgreSQL host (default: localhost)')
29
- @click.option('--port', default=5432, type=int, help='PostgreSQL port (default: 5432)')
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
- def init(project_name, host, port, user, password, git_origin, production, force_sync_only, create_db, docker):
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
  # ============================================================
@@ -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
@@ -61,7 +61,7 @@ class Hop:
61
61
 
62
62
  # DEVELOPMENT ENVIRONMENT - Patch development
63
63
  return ['patch', 'release', 'check', 'set-git-origin',
64
- 'revert-migration', 'recover']
64
+ 'revert-migration', 'recover', 'restore']
65
65
 
66
66
  @property
67
67
  def repo_checked(self):
@@ -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(db_name)
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/metadata-{version}.sql
210
- with half_orm_meta data using pg_dump --data-only.
211
- Updates model/schema.sql symlink to point to the new version.
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/metadata-0.0.0.sql
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/metadata-1.3.4.sql
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 # Symlink to current version
255
- ├── schema-0.0.0.sql # Initial version (structure)
256
- ├── metadata-0.0.0.sql # Initial version (half_orm_meta data)
257
- ├── schema-1.0.0.sql # Production version (structure)
258
- ├── metadata-1.0.0.sql # Production version (half_orm_meta data)
259
- ├── schema-1.3.4.sql # Latest production version (current)
260
- ├── metadata-1.3.4.sql # Latest production version (current)
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 metadata (only half_orm_meta tables)
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 metadata (version deduced from schema.sql)
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,70 @@ class Database:
329
335
  if temp_schema_file.exists():
330
336
  temp_schema_file.unlink()
331
337
 
332
- # Generate metadata dump (half_orm_meta data only)
333
- # Keep only COPY statements to avoid version-specific SET commands
334
- metadata_file = model_dir / f"metadata-{version}.sql"
335
- temp_file = model_dir / f".metadata-{version}.sql.tmp"
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
343
+
344
+ try:
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"
336
386
 
337
387
  try:
338
- # Dump to temporary file
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
+ # to avoid version-specific SET commands
351
402
  content = temp_file.read_text()
352
403
  filtered_lines = []
353
404
  in_copy_block = False
@@ -360,36 +411,14 @@ class Database:
360
411
  in_copy_block = False
361
412
  filtered_lines.append('') # Empty line between blocks
362
413
 
363
- metadata_file.write_text('\n'.join(filtered_lines))
414
+ data_file.write_text('\n'.join(filtered_lines))
364
415
  except Exception as e:
365
- raise Exception(f"Failed to generate metadata SQL: {e}") from e
416
+ raise Exception(f"Failed to generate data SQL: {e}") from e
366
417
  finally:
367
- # Clean up temporary file
368
418
  if temp_file.exists():
369
419
  temp_file.unlink()
370
420
 
371
- # Create or update symlink
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
421
+ return data_file
393
422
 
394
423
  @classmethod
395
424
  def _save_configuration(cls, database_name, connection_params):
@@ -632,13 +661,12 @@ class Database:
632
661
  """
633
662
  # Prepare environment variables for PostgreSQL commands
634
663
  env = os.environ.copy()
635
- env['PGUSER'] = connection_params['user']
636
- env['PGHOST'] = connection_params['host']
637
- env['PGPORT'] = str(connection_params['port'])
638
-
639
- # Set password if provided (use PGPASSWORD environment variable)
640
- if connection_params.get('password'):
641
- env['PGPASSWORD'] = connection_params['password']
664
+ pg_env_keys = {'user': 'PGUSER', 'host': 'PGHOST', 'port': 'PGPORT', 'password': 'PGPASSWORD'}
665
+ env.update({
666
+ pg_env_keys[k]: str(v)
667
+ for k, v in connection_params.items()
668
+ if k in pg_env_keys and v
669
+ })
642
670
 
643
671
  # Execute PostgreSQL command
644
672
  result = subprocess.run(
@@ -1101,9 +1129,11 @@ class Database:
1101
1129
  """
1102
1130
  Collect missing connection parameters interactively.
1103
1131
 
1104
- Takes partial connection parameters from CLI options and prompts
1105
- interactively for any missing or None values. Applies halfORM
1106
- standard defaults where appropriate.
1132
+ Takes partial connection parameters from CLI options. Missing
1133
+ user/password are prompted for interactively. Missing host/port
1134
+ are resolved like any standard PostgreSQL client tool - from the
1135
+ PGHOST/PGPORT environment variables, then localhost/5432 - never
1136
+ prompted for.
1107
1137
 
1108
1138
  Args:
1109
1139
  database_name (str): PostgreSQL database name for context
@@ -1127,11 +1157,12 @@ class Database:
1127
1157
  EOFError: If input stream is closed during prompts
1128
1158
 
1129
1159
  Interactive Behavior:
1130
- - Only prompts for missing/None parameters
1131
- - Shows current defaults in prompts: "Host (localhost): "
1160
+ - Only prompts for missing/None user/password
1132
1161
  - Uses getpass for secure password input
1133
1162
  - Allows empty input to accept defaults
1134
1163
  - Confirms production flag if True
1164
+ - host/port are never prompted for (PGHOST/PGPORT env vars,
1165
+ then localhost/5432)
1135
1166
 
1136
1167
  Examples:
1137
1168
  # Complete parameters provided - no prompts
@@ -1149,13 +1180,14 @@ class Database:
1149
1180
  # Prompts: "User (current_user): " and "Password: [hidden]"
1150
1181
  # Returns: {'host': 'localhost', 'port': 5432, 'user': 'prompted_user', 'password': 'prompted_pass', 'production': False}
1151
1182
 
1152
- # Only host provided - prompts for missing with defaults
1183
+ # Host/port omitted - resolved from PGHOST/PGPORT (or
1184
+ # localhost/5432), no prompt
1153
1185
  complete = Database._collect_connection_params(
1154
1186
  "my_db",
1155
- {'host': 'prod.db.com'}
1187
+ {'host': None, 'port': None, 'user': 'dev', 'password': 'secret'}
1156
1188
  )
1157
- # Prompts: "Port (5432): ", "User (current_user): ", "Password: "
1158
- # Returns: complete dict with provided host and prompted/default values
1189
+ # Prompts: nothing for host/port
1190
+ # Returns: complete dict with PGHOST/PGPORT-resolved host/port
1159
1191
 
1160
1192
  # Production flag confirmation
1161
1193
  complete = Database._collect_connection_params(
@@ -1189,29 +1221,23 @@ class Database:
1189
1221
  else:
1190
1222
  complete_params['password'] = password_input
1191
1223
 
1192
- # Prompt for host if None
1224
+ # Host/port are never prompted for interactively - like psql,
1225
+ # pg_dump, createdb and every other standard PostgreSQL client
1226
+ # tool, they fall back to the PGHOST/PGPORT environment variables
1227
+ # (and finally to localhost/5432) rather than asking the user.
1193
1228
  if complete_params.get('host') is None:
1194
- host_input = input("Host (localhost): ").strip()
1195
- complete_params['host'] = host_input if host_input else 'localhost'
1229
+ complete_params['host'] = os.environ.get('PGHOST', 'localhost')
1196
1230
 
1197
- # Prompt for port if None
1198
1231
  if complete_params.get('port') is None:
1199
- port_input = input("Port (5432): ").strip()
1200
- if port_input:
1232
+ port_env = os.environ.get('PGPORT')
1233
+ if port_env:
1201
1234
  try:
1202
- complete_params['port'] = int(port_input)
1235
+ complete_params['port'] = int(port_env)
1203
1236
  except ValueError:
1204
- raise ValueError(f"Invalid port number: {port_input}")
1237
+ raise ValueError(f"Invalid port number in PGPORT: {port_env}")
1205
1238
  else:
1206
1239
  complete_params['port'] = 5432
1207
1240
 
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
1241
  if complete_params.get('user') is None:
1216
1242
  complete_params['user'] = os.environ.get('USER', 'postgres')
1217
1243
 
@@ -1277,11 +1303,10 @@ class Database:
1277
1303
 
1278
1304
  def has_createdb_privilege(self) -> bool:
1279
1305
  """Check if the current PostgreSQL user has the CREATEDB privilege."""
1280
- params = self._get_connection_params()
1281
- result = self._execute_pg_command(
1282
- 'postgres', params,
1306
+ result = self.execute_pg_command(
1283
1307
  'psql', '-d', 'postgres', '-t', '-c',
1284
- "SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user"
1308
+ "SELECT rolcreatedb FROM pg_roles WHERE rolname = current_user",
1309
+ database_name='postgres'
1285
1310
  )
1286
1311
  return result.stdout.strip().lower() == 't'
1287
1312
 
@@ -1294,14 +1319,13 @@ class Database:
1294
1319
  Returns:
1295
1320
  Number of connections terminated.
1296
1321
  """
1297
- params = self._get_connection_params()
1298
1322
  sql = (
1299
1323
  f"SELECT count(pg_terminate_backend(pid)) FROM pg_stat_activity "
1300
1324
  f"WHERE datname = '{self.__name}'"
1301
1325
  )
1302
- result = self._execute_pg_command(
1303
- 'postgres', params,
1304
- 'psql', '-d', 'postgres', '-t', '-c', sql
1326
+ result = self.execute_pg_command(
1327
+ 'psql', '-d', 'postgres', '-t', '-c', sql,
1328
+ database_name='postgres'
1305
1329
  )
1306
1330
  try:
1307
1331
  return int(result.stdout.strip())
@@ -1317,10 +1341,9 @@ class Database:
1317
1341
  Args:
1318
1342
  snapshot_name: Name for the new snapshot database.
1319
1343
  """
1320
- params = self._get_connection_params()
1321
- self._execute_pg_command(
1322
- 'postgres', params,
1323
- 'createdb', '-T', self.__name, snapshot_name
1344
+ self.execute_pg_command(
1345
+ 'createdb', '-T', self.__name, snapshot_name,
1346
+ database_name='postgres'
1324
1347
  )
1325
1348
 
1326
1349
  def drop_snapshot(self, snapshot_name: str) -> None:
@@ -1329,10 +1352,9 @@ class Database:
1329
1352
  Args:
1330
1353
  snapshot_name: Name of the snapshot database to drop.
1331
1354
  """
1332
- params = self._get_connection_params()
1333
- self._execute_pg_command(
1334
- 'postgres', params,
1335
- 'dropdb', '--if-exists', snapshot_name
1355
+ self.execute_pg_command(
1356
+ 'dropdb', '--if-exists', snapshot_name,
1357
+ database_name='postgres'
1336
1358
  )
1337
1359
 
1338
1360
  def restore_from_snapshot(self, snapshot_name: str) -> None:
@@ -1344,17 +1366,15 @@ class Database:
1344
1366
  Args:
1345
1367
  snapshot_name: Name of the snapshot database to restore from.
1346
1368
  """
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)
1369
+ self.execute_pg_command('dropdb', self.__name, database_name='postgres')
1370
+ self.execute_pg_command('createdb', '-T', snapshot_name, self.__name, database_name='postgres')
1350
1371
 
1351
1372
  def list_snapshots(self) -> list:
1352
1373
  """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,
1374
+ result = self.execute_pg_command(
1356
1375
  'psql', '-d', 'postgres', '-t', '-c',
1357
- f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname"
1376
+ f"SELECT datname FROM pg_database WHERE datname LIKE '{self.__name}_hop_snap_%' ORDER BY datname",
1377
+ database_name='postgres'
1358
1378
  )
1359
1379
  return [line.strip() for line in result.stdout.splitlines() if line.strip()]
1360
1380