half-orm-dev 1.0.0a2__tar.gz → 1.0.0a5__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 (79) hide show
  1. {half_orm_dev-1.0.0a2/half_orm_dev.egg-info → half_orm_dev-1.0.0a5}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/__init__.py +2 -0
  3. half_orm_dev-1.0.0a5/half_orm_dev/cli/commands/revert_migration.py +49 -0
  4. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/main.py +2 -1
  5. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/hgit.py +13 -0
  6. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migration_manager.py +137 -8
  7. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/modules.py +152 -1
  8. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/release_manager.py +45 -0
  9. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/repo.py +19 -3
  10. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/module_template_1 +3 -0
  11. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/module_template_3 +4 -1
  12. half_orm_dev-1.0.0a5/half_orm_dev/version.txt +1 -0
  13. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5/half_orm_dev.egg-info}/PKG-INFO +1 -1
  14. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev.egg-info/SOURCES.txt +2 -1
  15. half_orm_dev-1.0.0a5/half_orm_dev.egg-info/entry_points.txt +2 -0
  16. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/pyproject.toml +4 -2
  17. half_orm_dev-1.0.0a2/half_orm_dev/migrations/half_orm/BREAKING_CHANGES-1.0.0.md +0 -50
  18. half_orm_dev-1.0.0a2/half_orm_dev/version.txt +0 -1
  19. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/AUTHORS +0 -0
  20. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/LICENSE +0 -0
  21. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/README.md +0 -0
  22. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/__init__.py +0 -0
  23. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/bootstrap_manager.py +0 -0
  24. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/__init__.py +0 -0
  25. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/apply.py +0 -0
  26. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  27. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/check.py +0 -0
  28. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/clone.py +0 -0
  29. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/init.py +0 -0
  30. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/migrate.py +0 -0
  31. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/patch.py +0 -0
  32. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/release.py +0 -0
  33. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/restore.py +0 -0
  34. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  35. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/sync.py +0 -0
  36. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/todo.py +0 -0
  37. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/undo.py +0 -0
  38. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/update.py +0 -0
  39. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli/commands/upgrade.py +0 -0
  40. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/cli_extension.py +0 -0
  41. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/database.py +0 -0
  42. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/decorators.py +0 -0
  43. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/file_executor.py +0 -0
  44. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  45. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  46. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  47. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  48. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  49. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  50. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  51. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  52. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  53. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patch_manager.py +0 -0
  54. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patch_validator.py +0 -0
  55. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  56. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  57. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  58. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patches/log +0 -0
  59. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  60. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/release_file.py +0 -0
  61. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/scripts/repair-metadata.py +0 -0
  62. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/.gitignore +0 -0
  63. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/MANIFEST.in +0 -0
  64. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/README +0 -0
  65. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/conftest_template +0 -0
  66. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  67. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  68. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/init_module_template +0 -0
  69. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/module_template_2 +0 -0
  70. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/pyproject.toml +0 -0
  71. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/relation_test +0 -0
  72. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/sql_adapter +0 -0
  73. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/templates/warning +0 -0
  74. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev/utils.py +0 -0
  75. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  76. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev.egg-info/requires.txt +0 -0
  77. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/half_orm_dev.egg-info/top_level.txt +0 -0
  78. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/setup.cfg +0 -0
  79. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a5}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a2
3
+ Version: 1.0.0a5
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
@@ -15,6 +15,7 @@ from .upgrade import upgrade
15
15
  from .check import check
16
16
  from .set_git_origin import set_git_origin
17
17
  from .migrate import migrate
18
+ from .revert_migration import revert_migration
18
19
  from .bootstrap import bootstrap
19
20
  from .todo import apply_release
20
21
  from .todo import rollback
@@ -35,6 +36,7 @@ ALL_COMMANDS = {
35
36
  'check': check, # Project health check and updates
36
37
  'set-git-origin': set_git_origin, # Update git remote origin URL
37
38
  'migrate': migrate, # Repository migration after upgrade
39
+ 'revert-migration': revert_migration, # Revert last migration
38
40
  'bootstrap': bootstrap, # Execute data initialization scripts
39
41
  # 🚧 (stubs)
40
42
  'apply_release': apply_release,
@@ -0,0 +1,49 @@
1
+ """
2
+ revert-migration command - Revert the last migration.
3
+
4
+ Uses the annotated git tag ho-migration/<version> created during migration
5
+ to identify the exact commits to revert on each branch.
6
+
7
+ Not available after a production promotion (the tag is deleted at that point).
8
+ """
9
+
10
+ import click
11
+ from half_orm_dev.repo import Repo, RepoError
12
+ from half_orm_dev.migration_manager import MigrationManagerError
13
+ from half_orm import utils
14
+
15
+
16
+ @click.command('revert-migration')
17
+ def revert_migration() -> None:
18
+ """
19
+ Revert the last migration applied by 'half_orm dev migrate'.
20
+
21
+ Uses the annotated tag ho-migration/<version> to locate the exact
22
+ commits and runs 'git revert --no-edit' on each affected branch.
23
+
24
+ \b
25
+ Constraints:
26
+ • Must be on ho-prod branch
27
+ • Not possible after a production promotion
28
+
29
+ \b
30
+ Multiple migrations:
31
+ Call repeatedly to roll back a chain of migrations (LIFO order).
32
+ Each call reverts the migration with the highest version number.
33
+ """
34
+ try:
35
+ repo = Repo()
36
+
37
+ if not repo.checked:
38
+ click.echo(utils.Color.red("❌ Not in a hop repository"), err=True)
39
+ raise click.Abort()
40
+
41
+ repo.revert_migration()
42
+ click.echo(f"✓ {utils.Color.green('Migration reverted successfully.')}")
43
+
44
+ except (RepoError, MigrationManagerError) as e:
45
+ click.echo(utils.Color.red(f"❌ {e}"), err=True)
46
+ raise click.Abort()
47
+ except Exception as e:
48
+ click.echo(utils.Color.red(f"❌ Unexpected error: {e}"), err=True)
49
+ raise click.Abort()
@@ -62,7 +62,8 @@ class Hop:
62
62
  return ['update', 'upgrade', 'bootstrap']
63
63
  else:
64
64
  # DEVELOPMENT ENVIRONMENT - Patch development
65
- return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin']
65
+ return ['patch', 'release', 'check', 'bootstrap', 'set-git-origin',
66
+ 'revert-migration']
66
67
 
67
68
  @property
68
69
  def repo_checked(self):
@@ -411,6 +411,19 @@ class HGit:
411
411
  origin = self.__git_repo.remote('origin')
412
412
  origin.push(tag_name)
413
413
 
414
+ def delete_remote_tag(self, tag_name: str) -> None:
415
+ """
416
+ Delete tag from remote.
417
+
418
+ Args:
419
+ tag_name: Tag name to delete (e.g., "ho-migration/1.0.0")
420
+
421
+ Examples:
422
+ hgit.delete_remote_tag("ho-migration/1.0.0")
423
+ """
424
+ origin = self.__git_repo.remote('origin')
425
+ origin.push(refspec=f':refs/tags/{tag_name}')
426
+
414
427
  def fetch_from_origin(self) -> None:
415
428
  """
416
429
  Fetch all references from origin remote with pruning.
@@ -31,6 +31,11 @@ from typing import List, Dict, Optional, Tuple
31
31
  from half_orm import utils
32
32
  from half_orm_dev.decorators import with_dynamic_branch_lock
33
33
 
34
+ try:
35
+ from half_orm.migrations import get_breaking_changes_dir
36
+ except (ImportError, AttributeError):
37
+ get_breaking_changes_dir = None # type: ignore[assignment]
38
+
34
39
 
35
40
  class MigrationManagerError(Exception):
36
41
  """Base exception for MigrationManager operations."""
@@ -195,22 +200,38 @@ class MigrationManager:
195
200
  f"Migration {migration_file.name} missing migrate() function"
196
201
  )
197
202
 
198
- # Execute migration and collect sync_files if returned
203
+ # Execute migration.
204
+ # The repo is guaranteed clean before migration starts, so the
205
+ # git index is empty here. Everything staged by this script is
206
+ # exclusively a migration-induced change.
199
207
  migration_result = module.migrate(self._repo)
200
208
 
201
209
  result['applied_files'].append(migration_file.name)
202
210
 
203
- # Collect sync_files from migration result
204
- if isinstance(migration_result, dict):
205
- sync_files = migration_result.get('sync_files', [])
206
- if sync_files:
207
- result['sync_files'].extend(sync_files)
211
+ # Auto-detect files staged by the script (via hgit.add()).
212
+ # Since the index was empty before migration, git diff --cached
213
+ # returns exactly the files this script modified.
214
+ git_repo = self._repo.hgit._HGit__git_repo
215
+ auto_staged = set(
216
+ git_repo.git.diff('--cached', '--name-only').splitlines()
217
+ )
218
+
219
+ # Merge with sync_files explicitly declared by the script.
220
+ declared = (
221
+ list(migration_result.get('sync_files', []))
222
+ if isinstance(migration_result, dict) else []
223
+ )
224
+ all_sync = list(dict.fromkeys(declared + list(auto_staged)))
225
+ if all_sync:
226
+ result['sync_files'].extend(all_sync)
208
227
 
209
228
  except Exception as e:
210
229
  error_msg = f"Error in {migration_file.name}: {e}"
211
230
  result['errors'].append(error_msg)
212
231
  raise MigrationManagerError(error_msg) from e
213
232
 
233
+ # Deduplicate across all scripts (git diff --cached is cumulative)
234
+ result['sync_files'] = list(dict.fromkeys(result['sync_files']))
214
235
  return result
215
236
 
216
237
  @with_dynamic_branch_lock(lambda self, *args, **kwargs: 'ho-prod')
@@ -344,6 +365,11 @@ class MigrationManager:
344
365
  result['commit_pushed'] = True
345
366
  result['sync_result'] = sync_result
346
367
 
368
+ # Create annotated tag encoding all commit SHAs for potential revert
369
+ self._create_migration_tag(
370
+ current_version, target_version, sync_result
371
+ )
372
+
347
373
  except Exception as e:
348
374
  # Don't fail migration if commit fails
349
375
  result['errors'].append(f"Failed to create commit: {e}")
@@ -355,6 +381,67 @@ class MigrationManager:
355
381
 
356
382
  return result
357
383
 
384
+ @with_dynamic_branch_lock(lambda self, *args, **kwargs: 'ho-prod')
385
+ def revert_migration(self) -> None:
386
+ """
387
+ Revert the most recently tagged migration.
388
+
389
+ Acquires a lock on ho-prod (via decorator), finds the ho-migration/*
390
+ tag with the highest version, and runs `git revert --no-edit` on each
391
+ affected branch (active branches first, then ho-prod). The tag is
392
+ deleted (local + remote) after a successful revert.
393
+
394
+ Raises:
395
+ MigrationManagerError: if no migration tag exists (never migrated,
396
+ or already locked by a production promotion).
397
+ """
398
+ git_repo = self._repo.hgit._HGit__git_repo
399
+
400
+ migration_tags = sorted(
401
+ [t for t in git_repo.tags if t.name.startswith('ho-migration/')],
402
+ key=lambda t: version.parse(t.name[len('ho-migration/'):]),
403
+ reverse=True,
404
+ )
405
+ if not migration_tags:
406
+ raise MigrationManagerError(
407
+ "No migration tag found — revert is not possible "
408
+ "(migration was never run, or already locked by a "
409
+ "production promotion)."
410
+ )
411
+ tag = migration_tags[0]
412
+
413
+ # Parse annotation: "Migration from X to Y\nho-prod:<sha>\nbranch:<sha>…"
414
+ shas: Dict = {}
415
+ for line in tag.tag.message.splitlines()[1:]:
416
+ if ':' in line:
417
+ branch, sha = line.split(':', 1)
418
+ shas[branch.strip()] = sha.strip()
419
+
420
+ if 'ho-prod' not in shas:
421
+ raise MigrationManagerError(
422
+ f"Migration tag {tag.name} is malformed (missing ho-prod SHA)."
423
+ )
424
+
425
+ # Revert sync commits on active branches first
426
+ for branch, sha in shas.items():
427
+ if branch == 'ho-prod':
428
+ continue
429
+ git_repo.git.checkout(branch)
430
+ git_repo.git.revert(sha, '--no-edit')
431
+ self._repo.hgit.push_branch(branch)
432
+
433
+ # Revert migration commit on ho-prod last
434
+ git_repo.git.checkout('ho-prod')
435
+ git_repo.git.revert(shas['ho-prod'], '--no-edit')
436
+ self._repo.hgit.push_branch('ho-prod')
437
+
438
+ # Remove tag (local + remote)
439
+ self._repo.hgit.delete_local_tag(tag.name)
440
+ try:
441
+ self._repo.hgit.delete_remote_tag(tag.name)
442
+ except Exception:
443
+ pass # remote tag may already be gone
444
+
358
445
  def _create_migration_commit_message(
359
446
  self,
360
447
  from_version: str,
@@ -389,6 +476,42 @@ class MigrationManager:
389
476
 
390
477
  return '\n'.join(lines)
391
478
 
479
+ def _create_migration_tag(
480
+ self, from_version: str, to_version: str, sync_result: Dict
481
+ ) -> None:
482
+ """
483
+ Create annotated tag ho-migration/{to_version} encoding commit SHAs.
484
+
485
+ The annotation stores the ho-prod commit SHA and the SHA of each sync
486
+ commit on active branches, enabling revert_migration() to undo the
487
+ migration precisely.
488
+
489
+ If the tag already exists (e.g. a previous failed migration left it),
490
+ it is deleted first.
491
+ """
492
+ tag_name = f"ho-migration/{to_version}"
493
+
494
+ # Remove stale tag if present
495
+ if self._repo.hgit.tag_exists(tag_name):
496
+ self._repo.hgit.delete_local_tag(tag_name)
497
+ try:
498
+ self._repo.hgit.delete_remote_tag(tag_name)
499
+ except Exception:
500
+ pass # remote tag may not exist
501
+
502
+ ho_prod_sha = self._repo.hgit._HGit__git_repo.head.commit.hexsha
503
+ branch_commits = (
504
+ sync_result.get('sync_result', {}).get('branch_commits', {})
505
+ )
506
+
507
+ lines = [f"Migration from {from_version} to {to_version}"]
508
+ lines.append(f"ho-prod:{ho_prod_sha}")
509
+ for branch, sha in branch_commits.items():
510
+ lines.append(f"{branch}:{sha}")
511
+
512
+ self._repo.hgit.create_tag(tag_name, message='\n'.join(lines))
513
+ self._repo.hgit.push_tag(tag_name)
514
+
392
515
  def _update_pyproject_dependency_version(self, target_version: str) -> None:
393
516
  """
394
517
  Update half_orm_dev version in pyproject.toml.
@@ -506,8 +629,14 @@ class MigrationManager:
506
629
  except Exception:
507
630
  return results
508
631
 
509
- for component in ('hop', 'half_orm'):
510
- bc_dir = self._migrations_root / component
632
+ component_dirs = {'hop': self._migrations_root / 'hop'}
633
+ if get_breaking_changes_dir is not None:
634
+ try:
635
+ component_dirs['half_orm'] = get_breaking_changes_dir()
636
+ except Exception:
637
+ pass # older half-orm — ignore silently
638
+
639
+ for component, bc_dir in component_dirs.items():
511
640
  if not bc_dir.is_dir():
512
641
  continue
513
642
  for bc_file in sorted(bc_dir.glob('BREAKING_CHANGES-*.md')):
@@ -48,6 +48,8 @@ HO_DATACLASSES = [
48
48
  from half_orm.relation import DC_Relation
49
49
  from half_orm.field import Field''']
50
50
  HO_DATACLASSES_IMPORTS = set()
51
+ HO_TYPEDICTS: list = []
52
+ HO_TYPEDICTS_IMPORTS: set = set()
51
53
  INIT_MODULE_TEMPLATE = read_template('init_module_template')
52
54
  MODULE_TEMPLATE_1 = read_template('module_template_1')
53
55
  MODULE_TEMPLATE_2 = read_template('module_template_2')
@@ -194,7 +196,12 @@ def __gen_dataclass(relation, fkeys):
194
196
  # Invert user-defined aliases: constraint_name → alias
195
197
  aliases = {constraint: alias for alias, constraint in fkeys.items() if alias != ''}
196
198
  for constraint_name, fkey in rel._ho_fkeys.items():
197
- attr_name = aliases.get(constraint_name, constraint_name)
199
+ if constraint_name in aliases:
200
+ attr_name = aliases[constraint_name]
201
+ elif constraint_name.startswith('_reverse_fkey_'):
202
+ attr_name = 'rfk_' + constraint_name[len('_reverse_fkey_'):]
203
+ else:
204
+ attr_name = 'fk_' + constraint_name
198
205
  try:
199
206
  fk_fqrn = list(fkey()._t_fqrn)
200
207
  fdc_name = f'DC_{__get_full_class_name(fk_fqrn[1], fk_fqrn[2])}'
@@ -204,6 +211,144 @@ def __gen_dataclass(relation, fkeys):
204
211
  return '\n'.join([f'@dataclasses.dataclass\nclass {dc_name}(DC_Relation):'] + fields + post_init)
205
212
 
206
213
 
214
+ def __get_type_annotation(field) -> tuple:
215
+ """Return (type_str, extra_imports) for a TypedDict field annotation.
216
+
217
+ Array types (SQL prefix '_') map to List[T].
218
+ """
219
+ sql_type = field._metadata['fieldtype']
220
+ is_array = sql_type.startswith('_')
221
+ base_sql_type = sql_type[1:] if is_array else sql_type
222
+
223
+ py_type = SQL_ADAPTER.get(base_sql_type)
224
+ imports: set = set()
225
+
226
+ if py_type is None or py_type is Any:
227
+ type_str = 'Any'
228
+ elif py_type.__module__ != 'builtins':
229
+ imports.add(py_type.__module__)
230
+ name = py_type.__name__ if hasattr(py_type, '__name__') else 'Any'
231
+ type_str = f'{py_type.__module__}.{name}'
232
+ else:
233
+ type_str = py_type.__name__
234
+
235
+ if is_array:
236
+ return f'List[{type_str}]', imports
237
+ return type_str, imports
238
+
239
+
240
+ def __json_scalar_type(sql_type_name: str) -> str:
241
+ """Map a JSON schema scalar type name to a Python type string.
242
+
243
+ Updates HO_TYPEDICTS_IMPORTS as needed.
244
+ """
245
+ py_type = SQL_ADAPTER.get(sql_type_name.lower())
246
+ if py_type is None or py_type is Any:
247
+ return 'Any'
248
+ if py_type.__module__ != 'builtins':
249
+ HO_TYPEDICTS_IMPORTS.add(py_type.__module__)
250
+ name = py_type.__name__ if hasattr(py_type, '__name__') else 'Any'
251
+ return f'{py_type.__module__}.{name}'
252
+ return py_type.__name__
253
+
254
+
255
+ def __gen_json_typedicts(name_prefix: str, schema) -> tuple:
256
+ """Recursively generate TypedDict classes from a Field.json_schema structure.
257
+
258
+ Returns (class_strings, top_class_name).
259
+ class_strings are in dependency order (nested classes before the class using them).
260
+
261
+ YAML value conventions:
262
+ scalar string → SQL type name (e.g. 'text', 'integer', 'uuid')
263
+ [scalar] → List[T]
264
+ [dict] → List[NestedDict]
265
+ dict → NestedDict
266
+ """
267
+ if not isinstance(schema, dict):
268
+ return [], 'Any'
269
+
270
+ classes = []
271
+ fields = []
272
+
273
+ for key, val in schema.items():
274
+ if isinstance(val, str):
275
+ type_str = __json_scalar_type(val)
276
+ fields.append(f" {key}: Optional[{type_str}]")
277
+ elif isinstance(val, list) and len(val) == 1:
278
+ item = val[0]
279
+ if isinstance(item, str):
280
+ inner = __json_scalar_type(item)
281
+ fields.append(f" {key}: Optional[List[{inner}]]")
282
+ elif isinstance(item, dict):
283
+ child_prefix = name_prefix + ''.join(w.capitalize() for w in key.split('_'))
284
+ nested, child_name = __gen_json_typedicts(child_prefix, item)
285
+ classes.extend(nested)
286
+ fields.append(f" {key}: Optional[List['{child_name}']]")
287
+ else:
288
+ fields.append(f" {key}: Optional[Any]")
289
+ elif isinstance(val, dict):
290
+ child_prefix = name_prefix + ''.join(w.capitalize() for w in key.split('_'))
291
+ nested, child_name = __gen_json_typedicts(child_prefix, val)
292
+ classes.extend(nested)
293
+ fields.append(f" {key}: Optional['{child_name}']")
294
+ else:
295
+ fields.append(f" {key}: Optional[Any]")
296
+
297
+ class_name = f'{name_prefix}Dict'
298
+ body = '\n'.join(fields) if fields else ' pass'
299
+ classes.append(f'class {class_name}(TypedDict, total=False):\n{body}')
300
+ return classes, class_name
301
+
302
+
303
+ def __gen_typedict(relation, fkeys) -> list:
304
+ """Generate TypedDict class(es) for a relation.
305
+
306
+ Returns a list of class strings: nested JSON TypedDicts first, then the main class.
307
+ Only database columns are included — FK accessor attributes are not part of a row dict.
308
+ json/jsonb fields with a json_schema generate nested TypedDict classes.
309
+ """
310
+ rel = relation()
311
+ t_qrn = list(rel._t_fqrn)[1:]
312
+ dict_class_name = f'{__get_full_class_name(*t_qrn)}Dict'
313
+
314
+ extra_classes = []
315
+ fields = []
316
+ for field_name, field in rel._ho_fields.items():
317
+ json_schema = getattr(field, 'json_schema', None)
318
+ if json_schema is not None and isinstance(json_schema, dict):
319
+ field_cc = ''.join(w.capitalize() for w in field_name.split('_'))
320
+ json_classes, top_name = __gen_json_typedicts(
321
+ f'{dict_class_name[:-4]}{field_cc}', json_schema
322
+ )
323
+ extra_classes.extend(json_classes)
324
+ type_str = top_name
325
+ else:
326
+ type_str, imports = __get_type_annotation(field)
327
+ HO_TYPEDICTS_IMPORTS.update(imports)
328
+ line = f" {field_name}: Optional[{type_str}]"
329
+ error = utils.check_attribute_name(field_name)
330
+ if error:
331
+ line = f"# {line} # FIX ME! {error}"
332
+ fields.append(line)
333
+
334
+ body = '\n'.join(fields) if fields else ' pass'
335
+ main_class = f'class {dict_class_name}(TypedDict, total=False):\n{body}'
336
+ return extra_classes + [main_class]
337
+
338
+
339
+ def __gen_typedicts(package_dir: str, package_name: str) -> None:
340
+ with open(os.path.join(package_dir, "ho_typeddicts.py"), "w", encoding='utf-8') as file_:
341
+ file_.write(f"# TypedDicts for {package_name}\n\n")
342
+ file_.write("from __future__ import annotations\n")
343
+ file_.write("from typing import TypedDict, Optional, List, Any\n")
344
+ td_imports = sorted(HO_TYPEDICTS_IMPORTS)
345
+ for mod in td_imports:
346
+ file_.write(f"import {mod}\n")
347
+ file_.write("\n")
348
+ for td in HO_TYPEDICTS:
349
+ file_.write(f"\n{td}\n")
350
+
351
+
207
352
  def __get_modules_list(dir, files_list, files):
208
353
  all_ = []
209
354
  for file_ in files:
@@ -357,6 +502,9 @@ def __update_this_module(
357
502
  inheritance_import, inherited_classes = __get_inheritance_info(
358
503
  rel, package_name)
359
504
 
505
+ t_qrn = list(rel._t_fqrn)[1:]
506
+ dict_class_name = f'{__get_full_class_name(*t_qrn)}Dict'
507
+
360
508
  # Generate Python module
361
509
  with open(module_path, 'w', encoding='utf-8') as file_:
362
510
  documentation = "\n".join([line and f" {line}" or "" for line in str(rel).split("\n")[1:]])
@@ -370,6 +518,7 @@ def __update_this_module(
370
518
  inherited_classes=inherited_classes,
371
519
  class_name=class_name,
372
520
  dc_name=rel._ho_dataclass_name(),
521
+ dict_class_name=dict_class_name,
373
522
  fqtn=fqtn,
374
523
  kwargs=kwargs,
375
524
  arg_names=arg_names,
@@ -394,6 +543,7 @@ def __update_this_module(
394
543
  class_name=class_name))
395
544
 
396
545
  HO_DATACLASSES.append(__gen_dataclass(rel, existing_fkeys))
546
+ HO_TYPEDICTS.extend(__gen_typedict(rel, existing_fkeys))
397
547
 
398
548
  return module_path
399
549
 
@@ -469,6 +619,7 @@ def generate(repo):
469
619
  # Tests are no longer added to files_list (they live in tests/ directory)
470
620
 
471
621
  __gen_dataclasses(str(package_dir), package_name)
622
+ __gen_typedicts(str(package_dir), package_name)
472
623
 
473
624
  if len(NO_APAPTER):
474
625
  print("MISSING ADAPTER FOR SQL TYPE")
@@ -2497,6 +2497,12 @@ class ReleaseManager:
2497
2497
 
2498
2498
  # Save original branch for rollback
2499
2499
  original_branch = self._repo.hgit.branch
2500
+ # Save schema.sql symlink target so rollback can restore it if
2501
+ # _generate_schema_sql updates it before a failure.
2502
+ _schema_sql = Path(self._repo.model_dir) / 'schema.sql'
2503
+ original_schema_link = (
2504
+ os.readlink(str(_schema_sql)) if _schema_sql.is_symlink() else None
2505
+ )
2500
2506
 
2501
2507
  try:
2502
2508
  # 5. Create temporary branch from release branch (validation only)
@@ -2547,6 +2553,15 @@ class ReleaseManager:
2547
2553
  staged_patches = release_file.get_patches(status="staged")
2548
2554
  self._create_prod_snapshot(version, staged_patches, release_file, model_dir)
2549
2555
 
2556
+ # 10b. Passe 2: validate all bootstrap scripts against final schema.
2557
+ # schema.sql now points to schema-X.Y.Z.sql (just generated).
2558
+ # restore_database_from_schema() resets the DB and runs ALL pending
2559
+ # bootstrap scripts in order — exactly what a fresh clone does.
2560
+ # Any failure here aborts promotion before the commit.
2561
+ print(f"\n🔍 Validating bootstrap scripts against schema {version}...")
2562
+ self._repo.restore_database_from_schema()
2563
+ print(f"✓ Bootstrap validation passed")
2564
+
2550
2565
  # 11. Commit the merge with all promote changes included
2551
2566
  self._repo.hgit.add(".")
2552
2567
  self._repo.hgit.commit("-m", commit_msg)
@@ -2600,6 +2615,16 @@ class ReleaseManager:
2600
2615
  result['deleted_branches'] = deleted_branches
2601
2616
  result['migrated_to'] = next_patch_version if migrate_candidates else None
2602
2617
  result['migrated_patches'] = candidates if migrate_candidates else []
2618
+
2619
+ # Lock migration revert: delete all ho-migration/* tags so that
2620
+ # revert_migration() is no longer possible after going to production.
2621
+ for tag in list(self._repo.hgit._HGit__git_repo.tags):
2622
+ if tag.name.startswith('ho-migration/'):
2623
+ self._repo.hgit.delete_local_tag(tag.name)
2624
+ try:
2625
+ self._repo.hgit.delete_remote_tag(tag.name)
2626
+ except Exception:
2627
+ pass # remote tag may already be gone
2603
2628
  else:
2604
2629
  result['branch'] = release_branch
2605
2630
 
@@ -2615,6 +2640,15 @@ class ReleaseManager:
2615
2640
  except GitCommandError:
2616
2641
  pass
2617
2642
 
2643
+ # Restore tracked files deleted/modified outside of git control
2644
+ # during the merge window (by _generate_schema_sql and
2645
+ # _create_prod_snapshot). merge_abort resets the index but
2646
+ # not working-tree files that were deleted directly on disk.
2647
+ try:
2648
+ self._repo.hgit.checkout('HEAD', '--', '.')
2649
+ except GitCommandError:
2650
+ pass
2651
+
2618
2652
  # Return to original branch
2619
2653
  try:
2620
2654
  self._repo.hgit.checkout(original_branch)
@@ -2631,6 +2665,17 @@ class ReleaseManager:
2631
2665
  except GitCommandError:
2632
2666
  pass # Already deleted (prod validation complete) or never created
2633
2667
 
2668
+ # Restore schema.sql symlink if _generate_schema_sql changed it
2669
+ # before the failure (merge_abort does not undo symlink changes
2670
+ # made outside of the merge commit itself).
2671
+ if original_schema_link is not None:
2672
+ try:
2673
+ if _schema_sql.is_symlink() and os.readlink(str(_schema_sql)) != original_schema_link:
2674
+ _schema_sql.unlink()
2675
+ _schema_sql.symlink_to(original_schema_link)
2676
+ except OSError:
2677
+ pass
2678
+
2634
2679
  except (GitCommandError, TypeError) as cleanup_error:
2635
2680
  print(f" Warning: Cleanup failed: {cleanup_error}", file=sys.stderr)
2636
2681
 
@@ -621,7 +621,8 @@ class Repo:
621
621
  result = {
622
622
  'synced_branches': [],
623
623
  'skipped_branches': [],
624
- 'errors': []
624
+ 'errors': [],
625
+ 'branch_commits': {}, # branch → commit SHA of the sync commit created
625
626
  }
626
627
 
627
628
  if not self.hgit:
@@ -798,6 +799,11 @@ class Repo:
798
799
  commit_msg = f"[HOP] Sync .hop/ from {source_branch} ({reason})"
799
800
  self.hgit.commit('-m', commit_msg)
800
801
 
802
+ # Record the SHA of the sync commit for potential revert
803
+ result['branch_commits'][branch] = (
804
+ self.hgit._HGit__git_repo.head.commit.hexsha
805
+ )
806
+
801
807
  # Push to remote
802
808
  self.hgit.push_branch(branch)
803
809
 
@@ -862,12 +868,14 @@ class Repo:
862
868
 
863
869
  # Check if working directory is clean
864
870
  if git_repo.is_dirty(untracked_files=False):
871
+ status = git_repo.git.status('--short')
865
872
  raise RepoError(
866
873
  f"Working directory has uncommitted changes.\n"
867
874
  f"Please commit or stash your changes before running this command:\n"
868
875
  f" git stash\n"
869
876
  f" OR\n"
870
- f" git add . && git commit -m \"your message\""
877
+ f" git add . && git commit -m \"your message\"\n"
878
+ f"Dirty files:\n{status}"
871
879
  )
872
880
 
873
881
  # Switch to ho-prod temporarily
@@ -988,6 +996,11 @@ class Repo:
988
996
 
989
997
  return result
990
998
 
999
+ def revert_migration(self) -> None:
1000
+ """Revert the most recently tagged migration. Delegates to MigrationManager."""
1001
+ mgr = MigrationManager(self)
1002
+ mgr.revert_migration()
1003
+
991
1004
  @property
992
1005
  def base_dir(self):
993
1006
  "Returns the base dir of the repository"
@@ -2483,11 +2496,14 @@ Each script is executed only once unless `--force` is used.
2483
2496
  # up_to_version prevents bootstraps from future versions from running.
2484
2497
  from half_orm_dev.bootstrap_manager import BootstrapManager
2485
2498
  bootstrap_mgr = BootstrapManager(self)
2486
- bootstrap_mgr.run_bootstrap(
2499
+ result = bootstrap_mgr.run_bootstrap(
2487
2500
  exclude_patch_id=exclude_bootstrap_patch_id,
2488
2501
  exclude_version=exclude_bootstrap_version,
2489
2502
  up_to_version=exclude_bootstrap_version
2490
2503
  )
2504
+ if result['errors']:
2505
+ error_msg = "\n".join([f" • {f}: {e}" for f, e in result['errors']])
2506
+ raise RepoError(f"Bootstrap execution failed:\n{error_msg}")
2491
2507
 
2492
2508
  except RepoError:
2493
2509
  # Re-raise RepoError as-is
@@ -6,7 +6,10 @@ WARNING!
6
6
 
7
7
  {warning}
8
8
  """
9
+ from typing import TYPE_CHECKING, Iterator
9
10
  from half_orm.model import register
10
11
  from {package_name} import MODEL, ho_dataclasses
12
+ if TYPE_CHECKING:
13
+ from {package_name}.ho_typeddicts import {dict_class_name}
11
14
  fields_aliases=None
12
15
 
@@ -1,3 +1,6 @@
1
1
  #pylint: disable=line-too-long, too-many-arguments, redefined-builtin, too-many-positional-arguments
2
2
  def __init__(self, {kwargs}):
3
- super().__init__({arg_names}, **kwargs)
3
+ super().__init__({arg_names}, **kwargs)
4
+
5
+ def __iter__(self) -> 'Iterator[{dict_class_name}]':
6
+ return super().__iter__()
@@ -0,0 +1 @@
1
+ 1.0.0-a5
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a2
3
+ Version: 1.0.0a5
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
@@ -22,6 +22,7 @@ half_orm_dev/version.txt
22
22
  half_orm_dev.egg-info/PKG-INFO
23
23
  half_orm_dev.egg-info/SOURCES.txt
24
24
  half_orm_dev.egg-info/dependency_links.txt
25
+ half_orm_dev.egg-info/entry_points.txt
25
26
  half_orm_dev.egg-info/requires.txt
26
27
  half_orm_dev.egg-info/top_level.txt
27
28
  half_orm_dev/cli/__init__.py
@@ -36,6 +37,7 @@ half_orm_dev/cli/commands/migrate.py
36
37
  half_orm_dev/cli/commands/patch.py
37
38
  half_orm_dev/cli/commands/release.py
38
39
  half_orm_dev/cli/commands/restore.py
40
+ half_orm_dev/cli/commands/revert_migration.py
39
41
  half_orm_dev/cli/commands/set_git_origin.py
40
42
  half_orm_dev/cli/commands/sync.py
41
43
  half_orm_dev/cli/commands/todo.py
@@ -50,7 +52,6 @@ half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py
50
52
  half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py
51
53
  half_orm_dev/migrations/0/18/0/00_add_async_support.py
52
54
  half_orm_dev/migrations/0/18/0/01_update_default_tests.py
53
- half_orm_dev/migrations/half_orm/BREAKING_CHANGES-1.0.0.md
54
55
  half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md
55
56
  half_orm_dev/patches/log
56
57
  half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ half_orm = half_orm.cli:main
@@ -33,6 +33,9 @@ classifiers = [
33
33
  ]
34
34
  requires-python = ">=3.9"
35
35
 
36
+ [project.scripts]
37
+ half_orm = "half_orm.cli:main"
38
+
36
39
  [project.urls]
37
40
  Homepage = "https://github.com/half-orm/half-orm-dev"
38
41
 
@@ -49,8 +52,7 @@ half_orm_dev = [
49
52
  "patches/**/*",
50
53
  "scripts/*",
51
54
  "version.txt",
52
- "migrations/hop/*.md",
53
- "migrations/half_orm/*.md"
55
+ "migrations/hop/*.md"
54
56
  ]
55
57
 
56
58
  [tool.setuptools.dynamic]
@@ -1,50 +0,0 @@
1
- # half-orm 1.0.0 — Breaking Changes
2
-
3
- ## `ho_get()` returns a `dict` and raises on 0 or >1 rows
4
-
5
- `ho_get()` now returns a plain `dict` directly (no longer a Relation
6
- object). It raises:
7
- - `NotFoundError` if no row matches
8
- - `MultipleRowsError` if more than one row matches
9
-
10
- **Before:**
11
- ```python
12
- obj = MyTable(id=1).ho_get() # returned a Relation
13
- ```
14
-
15
- **After:**
16
- ```python
17
- row = MyTable(id=1).ho_get() # returns dict, or raises
18
- ```
19
-
20
- The async counterpart `ho_aget()` has been added with the same semantics.
21
-
22
- ## Deprecated query-builder setters removed
23
-
24
- `ho_limit`, `ho_offset`, `ho_order_by`, `ho_distinct` no longer exist as
25
- property setters. Pass them as keyword arguments to `ho_select()`.
26
-
27
- **Before:**
28
- ```python
29
- rel.ho_limit = 10
30
- rel.ho_order_by = "name"
31
- for row in rel.ho_select():
32
- ...
33
- ```
34
-
35
- **After:**
36
- ```python
37
- for row in rel.ho_select(limit=10, order_by="name"):
38
- ...
39
- ```
40
-
41
- ## `FKEYS_PROPERTIES` / `FKEYS` class attributes removed
42
-
43
- Use `Fkeys` only. Any subclass that still defines `FKEYS_PROPERTIES` or
44
- `FKEYS` will raise an error at class definition time.
45
-
46
- ## `ho_cast()` raises `CastError` for invalid inheritance targets
47
-
48
- `ho_cast(TargetClass)` now raises `half_orm.relation.CastError` if
49
- `TargetClass` is not in the PostgreSQL inheritance hierarchy of the
50
- source table.
@@ -1 +0,0 @@
1
- 1.0.0-a2
File without changes
File without changes
File without changes
File without changes
File without changes