half-orm-dev 1.0.0a2__tar.gz → 1.0.0a4__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.0a4}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/__init__.py +2 -0
  3. half_orm_dev-1.0.0a4/half_orm_dev/cli/commands/revert_migration.py +49 -0
  4. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/main.py +2 -1
  5. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/hgit.py +13 -0
  6. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migration_manager.py +137 -8
  7. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/modules.py +167 -1
  8. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/release_manager.py +10 -0
  9. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/repo.py +12 -1
  10. half_orm_dev-1.0.0a4/half_orm_dev/version.txt +1 -0
  11. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4/half_orm_dev.egg-info}/PKG-INFO +1 -1
  12. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev.egg-info/SOURCES.txt +2 -1
  13. half_orm_dev-1.0.0a4/half_orm_dev.egg-info/entry_points.txt +2 -0
  14. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/pyproject.toml +4 -2
  15. half_orm_dev-1.0.0a2/half_orm_dev/migrations/half_orm/BREAKING_CHANGES-1.0.0.md +0 -50
  16. half_orm_dev-1.0.0a2/half_orm_dev/version.txt +0 -1
  17. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/AUTHORS +0 -0
  18. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/LICENSE +0 -0
  19. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/README.md +0 -0
  20. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/__init__.py +0 -0
  21. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/bootstrap_manager.py +0 -0
  22. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/__init__.py +0 -0
  23. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/apply.py +0 -0
  24. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  25. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/check.py +0 -0
  26. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/clone.py +0 -0
  27. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/init.py +0 -0
  28. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/migrate.py +0 -0
  29. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/patch.py +0 -0
  30. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/release.py +0 -0
  31. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/restore.py +0 -0
  32. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  33. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/sync.py +0 -0
  34. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/todo.py +0 -0
  35. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/undo.py +0 -0
  36. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/update.py +0 -0
  37. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli/commands/upgrade.py +0 -0
  38. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/cli_extension.py +0 -0
  39. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/database.py +0 -0
  40. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/decorators.py +0 -0
  41. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/file_executor.py +0 -0
  42. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  43. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  44. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  45. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  46. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  47. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  48. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  49. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  50. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  51. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patch_manager.py +0 -0
  52. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patch_validator.py +0 -0
  53. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  54. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  55. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  56. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patches/log +0 -0
  57. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  58. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/release_file.py +0 -0
  59. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/scripts/repair-metadata.py +0 -0
  60. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/.gitignore +0 -0
  61. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/MANIFEST.in +0 -0
  62. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/README +0 -0
  63. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/conftest_template +0 -0
  64. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  65. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  66. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/init_module_template +0 -0
  67. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/module_template_1 +0 -0
  68. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/module_template_2 +0 -0
  69. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/module_template_3 +0 -0
  70. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/pyproject.toml +0 -0
  71. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/relation_test +0 -0
  72. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/sql_adapter +0 -0
  73. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/templates/warning +0 -0
  74. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev/utils.py +0 -0
  75. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  76. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev.egg-info/requires.txt +0 -0
  77. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/half_orm_dev.egg-info/top_level.txt +0 -0
  78. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/setup.cfg +0 -0
  79. {half_orm_dev-1.0.0a2 → half_orm_dev-1.0.0a4}/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.0a4
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,163 @@ 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
+ FK fields starting with 'rfk_' (reverse) are typed List['TargetDict'];
308
+ all other FK fields are typed 'TargetDict'.
309
+ json/jsonb fields with a json_schema generate nested TypedDict classes.
310
+ """
311
+ rel = relation()
312
+ t_qrn = list(rel._t_fqrn)[1:]
313
+ dict_class_name = f'{__get_full_class_name(*t_qrn)}Dict'
314
+
315
+ extra_classes = []
316
+ fields = []
317
+ for field_name, field in rel._ho_fields.items():
318
+ json_schema = getattr(field, 'json_schema', None)
319
+ if json_schema is not None and isinstance(json_schema, dict):
320
+ field_cc = ''.join(w.capitalize() for w in field_name.split('_'))
321
+ json_classes, top_name = __gen_json_typedicts(
322
+ f'{dict_class_name[:-4]}{field_cc}', json_schema
323
+ )
324
+ extra_classes.extend(json_classes)
325
+ type_str = top_name
326
+ else:
327
+ type_str, imports = __get_type_annotation(field)
328
+ HO_TYPEDICTS_IMPORTS.update(imports)
329
+ line = f" {field_name}: Optional[{type_str}]"
330
+ error = utils.check_attribute_name(field_name)
331
+ if error:
332
+ line = f"# {line} # FIX ME! {error}"
333
+ fields.append(line)
334
+
335
+ aliases = {constraint: alias for alias, constraint in fkeys.items() if alias != ''}
336
+ for constraint_name, fkey in rel._ho_fkeys.items():
337
+ if constraint_name in aliases:
338
+ attr_name = aliases[constraint_name]
339
+ elif constraint_name.startswith('_reverse_fkey_'):
340
+ attr_name = 'rfk_' + constraint_name[len('_reverse_fkey_'):]
341
+ else:
342
+ attr_name = 'fk_' + constraint_name
343
+ try:
344
+ fk_fqrn = list(fkey()._t_fqrn)
345
+ target_name = f'{__get_full_class_name(fk_fqrn[1], fk_fqrn[2])}Dict'
346
+ except Exception:
347
+ target_name = dict_class_name
348
+ if attr_name.startswith('rfk_'):
349
+ fields.append(f" {attr_name}: Optional[List['{target_name}']]")
350
+ else:
351
+ fields.append(f" {attr_name}: Optional['{target_name}']")
352
+
353
+ body = '\n'.join(fields) if fields else ' pass'
354
+ main_class = f'class {dict_class_name}(TypedDict, total=False):\n{body}'
355
+ return extra_classes + [main_class]
356
+
357
+
358
+ def __gen_typedicts(package_dir: str, package_name: str) -> None:
359
+ with open(os.path.join(package_dir, "ho_typeddicts.py"), "w", encoding='utf-8') as file_:
360
+ file_.write(f"# TypedDicts for {package_name}\n\n")
361
+ file_.write("from __future__ import annotations\n")
362
+ file_.write("from typing import TypedDict, Optional, List, Any\n")
363
+ td_imports = sorted(HO_TYPEDICTS_IMPORTS)
364
+ for mod in td_imports:
365
+ file_.write(f"import {mod}\n")
366
+ file_.write("\n")
367
+ for td in HO_TYPEDICTS:
368
+ file_.write(f"\n{td}\n")
369
+
370
+
207
371
  def __get_modules_list(dir, files_list, files):
208
372
  all_ = []
209
373
  for file_ in files:
@@ -394,6 +558,7 @@ def __update_this_module(
394
558
  class_name=class_name))
395
559
 
396
560
  HO_DATACLASSES.append(__gen_dataclass(rel, existing_fkeys))
561
+ HO_TYPEDICTS.extend(__gen_typedict(rel, existing_fkeys))
397
562
 
398
563
  return module_path
399
564
 
@@ -469,6 +634,7 @@ def generate(repo):
469
634
  # Tests are no longer added to files_list (they live in tests/ directory)
470
635
 
471
636
  __gen_dataclasses(str(package_dir), package_name)
637
+ __gen_typedicts(str(package_dir), package_name)
472
638
 
473
639
  if len(NO_APAPTER):
474
640
  print("MISSING ADAPTER FOR SQL TYPE")
@@ -2600,6 +2600,16 @@ class ReleaseManager:
2600
2600
  result['deleted_branches'] = deleted_branches
2601
2601
  result['migrated_to'] = next_patch_version if migrate_candidates else None
2602
2602
  result['migrated_patches'] = candidates if migrate_candidates else []
2603
+
2604
+ # Lock migration revert: delete all ho-migration/* tags so that
2605
+ # revert_migration() is no longer possible after going to production.
2606
+ for tag in list(self._repo.hgit._HGit__git_repo.tags):
2607
+ if tag.name.startswith('ho-migration/'):
2608
+ self._repo.hgit.delete_local_tag(tag.name)
2609
+ try:
2610
+ self._repo.hgit.delete_remote_tag(tag.name)
2611
+ except Exception:
2612
+ pass # remote tag may already be gone
2603
2613
  else:
2604
2614
  result['branch'] = release_branch
2605
2615
 
@@ -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
 
@@ -988,6 +994,11 @@ class Repo:
988
994
 
989
995
  return result
990
996
 
997
+ def revert_migration(self) -> None:
998
+ """Revert the most recently tagged migration. Delegates to MigrationManager."""
999
+ mgr = MigrationManager(self)
1000
+ mgr.revert_migration()
1001
+
991
1002
  @property
992
1003
  def base_dir(self):
993
1004
  "Returns the base dir of the repository"
@@ -0,0 +1 @@
1
+ 1.0.0-a4
@@ -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.0a4
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