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.
- {half_orm_dev-1.0.0a32/half_orm_dev.egg-info → half_orm_dev-1.0.0a33}/PKG-INFO +2 -2
- half_orm_dev-1.0.0a33/half_orm_dev/_bootstrap_runner.py +39 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/__init__.py +3 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/clone.py +2 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/init.py +20 -4
- half_orm_dev-1.0.0a33/half_orm_dev/cli/commands/restore.py +62 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/todo.py +0 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/main.py +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/database.py +124 -104
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/file_executor.py +127 -12
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/hgit.py +29 -5
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/migration_manager.py +22 -13
- half_orm_dev-1.0.0a33/half_orm_dev/migrations/1/0/0/a33/00_metadata_to_data_sql.py +61 -0
- half_orm_dev-1.0.0a33/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +54 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/modules.py +16 -29
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patch_manager.py +10 -14
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/release_manager.py +4 -15
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/repo.py +170 -124
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/.gitignore +3 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/init_module_template +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/module_template_1 +0 -1
- half_orm_dev-1.0.0a33/half_orm_dev/templates/module_template_2 +7 -0
- half_orm_dev-1.0.0a33/half_orm_dev/venv_setup.py +71 -0
- half_orm_dev-1.0.0a33/half_orm_dev/version.txt +1 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33/half_orm_dev.egg-info}/PKG-INFO +2 -2
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/SOURCES.txt +5 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/requires.txt +1 -1
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/setup.py +2 -1
- half_orm_dev-1.0.0a33/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.0a33}/AUTHORS +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/LICENSE +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/README.md +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/__init__.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/__init__.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/apply.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/check.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/migrate.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/patch.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/recover.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/release.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/revert_migration.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/rollback.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/sync.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/undo.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli/commands/upgrade.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/cli_extension.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/decorators.py +0 -0
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {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
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patch_validator.py +0 -0
- {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
- {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
- {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
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/log +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/py.typed +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/release_file.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/scripts/repair-metadata.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/MANIFEST.in +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/README +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/conftest_template +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/pre-push +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/git-hooks/reference-transaction +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/module_template_3 +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/pyproject.toml +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/relation_test +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/sql_adapter +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/templates/warning +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev/utils.py +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/dependency_links.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/entry_points.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/half_orm_dev.egg-info/top_level.txt +0 -0
- {half_orm_dev-1.0.0a32 → half_orm_dev-1.0.0a33}/pyproject.toml +0 -0
- {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.
|
|
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.
|
|
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=
|
|
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
|
# ============================================================
|
|
@@ -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,70 @@ 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
|
|
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
|
-
|
|
414
|
+
data_file.write_text('\n'.join(filtered_lines))
|
|
364
415
|
except Exception as e:
|
|
365
|
-
raise Exception(f"Failed to generate
|
|
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
|
-
|
|
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
|
-
|
|
636
|
-
env
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
|
1105
|
-
|
|
1106
|
-
standard
|
|
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
|
|
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
|
-
#
|
|
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': '
|
|
1187
|
+
{'host': None, 'port': None, 'user': 'dev', 'password': 'secret'}
|
|
1156
1188
|
)
|
|
1157
|
-
# Prompts:
|
|
1158
|
-
# Returns: complete dict with
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
if
|
|
1232
|
+
port_env = os.environ.get('PGPORT')
|
|
1233
|
+
if port_env:
|
|
1201
1234
|
try:
|
|
1202
|
-
complete_params['port'] = int(
|
|
1235
|
+
complete_params['port'] = int(port_env)
|
|
1203
1236
|
except ValueError:
|
|
1204
|
-
raise ValueError(f"Invalid port number: {
|
|
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
|
-
|
|
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.
|
|
1303
|
-
'postgres',
|
|
1304
|
-
'
|
|
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
|
-
|
|
1321
|
-
|
|
1322
|
-
'postgres'
|
|
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
|
-
|
|
1333
|
-
|
|
1334
|
-
'postgres'
|
|
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
|
-
|
|
1348
|
-
self.
|
|
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
|
-
|
|
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
|
|