half-orm-dev 1.0.0a12__tar.gz → 1.0.0a15__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 (80) hide show
  1. {half_orm_dev-1.0.0a12/half_orm_dev.egg-info → half_orm_dev-1.0.0a15}/PKG-INFO +1 -1
  2. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/modules.py +85 -36
  3. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patch_manager.py +19 -0
  4. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/repo.py +76 -61
  5. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/module_template_1 +1 -2
  6. half_orm_dev-1.0.0a15/half_orm_dev/templates/module_template_2 +7 -0
  7. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/module_template_3 +1 -1
  8. half_orm_dev-1.0.0a15/half_orm_dev/version.txt +1 -0
  9. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15/half_orm_dev.egg-info}/PKG-INFO +1 -1
  10. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev.egg-info/SOURCES.txt +0 -1
  11. half_orm_dev-1.0.0a12/half_orm_dev/templates/module_stub_template +0 -8
  12. half_orm_dev-1.0.0a12/half_orm_dev/templates/module_template_2 +0 -7
  13. half_orm_dev-1.0.0a12/half_orm_dev/version.txt +0 -1
  14. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/AUTHORS +0 -0
  15. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/LICENSE +0 -0
  16. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/README.md +0 -0
  17. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/__init__.py +0 -0
  18. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/bootstrap_manager.py +0 -0
  19. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/__init__.py +0 -0
  20. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/__init__.py +0 -0
  21. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/apply.py +0 -0
  22. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/bootstrap.py +0 -0
  23. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/check.py +0 -0
  24. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/clone.py +0 -0
  25. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/init.py +0 -0
  26. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/migrate.py +0 -0
  27. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/patch.py +0 -0
  28. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/release.py +0 -0
  29. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/restore.py +0 -0
  30. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/revert_migration.py +0 -0
  31. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/set_git_origin.py +0 -0
  32. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/sync.py +0 -0
  33. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/todo.py +0 -0
  34. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/undo.py +0 -0
  35. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/update.py +0 -0
  36. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/commands/upgrade.py +0 -0
  37. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli/main.py +0 -0
  38. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/cli_extension.py +0 -0
  39. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/database.py +0 -0
  40. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/decorators.py +0 -0
  41. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/file_executor.py +0 -0
  42. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/hgit.py +0 -0
  43. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migration_manager.py +0 -0
  44. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/1/00_move_to_hop.py +0 -0
  45. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/1/01_txt_to_toml.py +0 -0
  46. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/4/00_toml_dict_format.py +0 -0
  47. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/4/01_add_bootstrap_table.py +0 -0
  48. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/4/02_move_patches_to_subdirs.py +0 -0
  49. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/17/5/01_update_pyproject_dependency.py +0 -0
  50. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/18/0/00_add_async_support.py +0 -0
  51. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/0/18/0/01_update_default_tests.py +0 -0
  52. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/migrations/hop/BREAKING_CHANGES-1.0.0.md +0 -0
  53. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patch_validator.py +0 -0
  54. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +0 -0
  55. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +0 -0
  56. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +0 -0
  57. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patches/log +0 -0
  58. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/patches/sql/half_orm_meta.sql +0 -0
  59. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/release_file.py +0 -0
  60. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/release_manager.py +0 -0
  61. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/scripts/repair-metadata.py +0 -0
  62. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/.gitignore +0 -0
  63. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/MANIFEST.in +0 -0
  64. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/README +0 -0
  65. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/conftest_template +0 -0
  66. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/git-hooks/pre-commit +0 -0
  67. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/git-hooks/prepare-commit-msg +0 -0
  68. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/init_module_template +0 -0
  69. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/pyproject.toml +0 -0
  70. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/relation_test +0 -0
  71. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/sql_adapter +0 -0
  72. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/templates/warning +0 -0
  73. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev/utils.py +0 -0
  74. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev.egg-info/dependency_links.txt +0 -0
  75. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev.egg-info/entry_points.txt +0 -0
  76. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev.egg-info/requires.txt +0 -0
  77. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/half_orm_dev.egg-info/top_level.txt +0 -0
  78. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/pyproject.toml +0 -0
  79. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/setup.cfg +0 -0
  80. {half_orm_dev-1.0.0a12 → half_orm_dev-1.0.0a15}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a12
3
+ Version: 1.0.0a15
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
@@ -48,11 +48,12 @@ HO_DATACLASSES = []
48
48
  HO_DATACLASSES_IMPORTS = set()
49
49
  HO_TYPEDICTS: list = []
50
50
  HO_TYPEDICTS_IMPORTS: set = set()
51
+ HO_BASECLASSES: list = []
52
+ HO_BASECLASSES_DICT_NAMES: 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')
54
56
  MODULE_TEMPLATE_3 = read_template('module_template_3')
55
- MODULE_STUB_TEMPLATE = read_template('module_stub_template')
56
57
  WARNING_TEMPLATE = read_template('warning')
57
58
  CONFTEST = read_template('conftest_template')
58
59
  TEST = read_template('relation_test')
@@ -335,18 +336,6 @@ def __gen_typedict(relation, fkeys) -> list:
335
336
  return extra_classes + [main_class]
336
337
 
337
338
 
338
- def __gen_stub_file(module_path: str, class_name: str, dict_class_name: str, package_name: str) -> str:
339
- """Generate a .pyi stub alongside the module for IDE type inference."""
340
- stub_path = module_path[:-3] + '.pyi'
341
- with open(stub_path, 'w', encoding='utf-8') as f:
342
- f.write(MODULE_STUB_TEMPLATE.format(
343
- package_name=package_name,
344
- dict_class_name=dict_class_name,
345
- class_name=class_name,
346
- ))
347
- return stub_path
348
-
349
-
350
339
  def __gen_typedicts(package_dir: str, package_name: str) -> None:
351
340
  with open(os.path.join(package_dir, "ho_typeddicts.py"), "w", encoding='utf-8') as file_:
352
341
  file_.write(f"# TypedDicts for {package_name}\n\n")
@@ -563,7 +552,9 @@ def __update_this_module(
563
552
  rel, package_name)
564
553
 
565
554
  t_qrn = list(rel._t_fqrn)[1:]
566
- dict_class_name = f'{__get_full_class_name(*t_qrn)}Dict'
555
+ full_name = __get_full_class_name(*t_qrn)
556
+ dict_class_name = f'{full_name}Dict'
557
+ bc_name = f'BC_{full_name}'
567
558
 
568
559
  # Generate Python module
569
560
  with open(module_path, 'w', encoding='utf-8') as file_:
@@ -580,6 +571,7 @@ def __update_this_module(
580
571
  class_name=class_name,
581
572
  dc_name=rel._ho_dataclass_name(),
582
573
  dict_class_name=dict_class_name,
574
+ bc_name=bc_name,
583
575
  fqtn=fqtn,
584
576
  kwargs=kwargs,
585
577
  arg_names=arg_names,
@@ -605,19 +597,9 @@ def __update_this_module(
605
597
 
606
598
  HO_DATACLASSES.append(__gen_dataclass(rel, existing_fkeys))
607
599
  HO_TYPEDICTS.extend(__gen_typedict(rel, existing_fkeys))
600
+ HO_BASECLASSES.append(__gen_baseclass(rel, existing_fkeys))
608
601
 
609
- stub_path = __gen_stub_file(module_path, class_name, dict_class_name, package_name)
610
-
611
- return [module_path, stub_path]
612
-
613
-
614
- def __reset_dataclasses(repo, package_dir):
615
- with open(os.path.join(package_dir, "ho_dataclasses.py"), "w", encoding='utf-8') as file_:
616
- for relation in repo.database.model._relations():
617
- t_qrn = relation[1][1:]
618
- if t_qrn[0].find('half_orm') == 0:
619
- continue
620
- file_.write(f'class DC_{__get_full_class_name(*t_qrn)}: ...\n')
602
+ return module_path
621
603
 
622
604
 
623
605
  _TYPING_NAMES = frozenset(('Any', 'Dict', 'Iterator', 'List', 'Optional', 'Tuple', 'Union'))
@@ -675,20 +657,80 @@ def __gen_dc_relation() -> tuple:
675
657
  return class_str, needed_typing
676
658
 
677
659
 
678
- def __gen_dataclasses(package_dir, package_name):
660
+ def __gen_baseclass(relation, fkeys) -> str:
661
+ """Generate a BC_* base class string with TypedDict-typed method overrides."""
662
+ rel = relation()
663
+ t_qrn = list(rel._t_fqrn)[1:]
664
+ full_name = __get_full_class_name(*t_qrn)
665
+ fqtn = '.'.join(t_qrn)
666
+ bc_name = f'BC_{full_name}'
667
+ dc_name = rel._ho_dataclass_name()
668
+ d = f'{full_name}Dict'
669
+ HO_BASECLASSES_DICT_NAMES.add(d)
670
+ lines = [
671
+ f"class {bc_name}(",
672
+ f" MODEL.get_relation_class('{fqtn}', fields_aliases=None),",
673
+ f" {dc_name}",
674
+ f"):",
675
+ f" def __iter__(self) -> Iterator[{d}]:",
676
+ f" return super().__iter__()",
677
+ f"",
678
+ f" def ho_select(self, *args, distinct: bool = False, order_by: str = None, limit: int = None, offset: int = None, json_agg=None) -> Iterator[{d}]:",
679
+ f" return super().ho_select(*args, distinct=distinct, order_by=order_by, limit=limit, offset=offset, json_agg=json_agg)",
680
+ f"",
681
+ f" def ho_get(self, *args) -> {d}:",
682
+ f" return super().ho_get(*args)",
683
+ f"",
684
+ f" def ho_insert(self, *args, upsert: Optional[bool] = False) -> {d}:",
685
+ f" return super().ho_insert(*args, upsert=upsert)",
686
+ f"",
687
+ f" async def ho_aselect(self, *args, distinct: bool = False, order_by: str = None, limit: int = None, offset: int = None) -> List[{d}]:",
688
+ f" return await super().ho_aselect(*args, distinct=distinct, order_by=order_by, limit=limit, offset=offset)",
689
+ f"",
690
+ f" async def ho_aget(self, *args) -> {d}:",
691
+ f" return await super().ho_aget(*args)",
692
+ f"",
693
+ f" async def ho_ainsert(self, *args, upsert: bool = False) -> {d}:",
694
+ f" return await super().ho_ainsert(*args, upsert=upsert)",
695
+ ]
696
+ return '\n'.join(lines)
697
+
698
+
699
+ def __reset_baseclasses(repo, package_dir):
700
+ with open(os.path.join(package_dir, "ho_baseclasses.py"), "w", encoding='utf-8') as file_:
701
+ for relation in repo.database.model._relations():
702
+ t_qrn = relation[1][1:]
703
+ if t_qrn[0].find('half_orm') == 0:
704
+ continue
705
+ full_name = __get_full_class_name(*t_qrn)
706
+ file_.write(f'class DC_{full_name}: ...\n')
707
+ file_.write(f'class BC_{full_name}: ...\n')
708
+
709
+
710
+ def __gen_baseclasses(package_dir, package_name):
679
711
  dc_relation_str, dc_typing = __gen_dc_relation()
680
- with open(os.path.join(package_dir, "ho_dataclasses.py"), "w", encoding='utf-8') as file_:
681
- file_.write(f"# DO NOT EDIT — auto-generated by half-orm-dev\n\n")
712
+ with open(os.path.join(package_dir, "ho_baseclasses.py"), "w", encoding='utf-8') as file_:
713
+ file_.write("# DO NOT EDIT — auto-generated by half-orm-dev\n\n")
714
+ file_.write("from __future__ import annotations\n")
715
+ typing_names = sorted(dc_typing | {'Iterator', 'List', 'Optional', 'TYPE_CHECKING'})
716
+ file_.write(f"from typing import {', '.join(typing_names)}\n")
682
717
  file_.write("import dataclasses\n")
683
718
  file_.write("from half_orm.field import Field\n")
684
- if dc_typing:
685
- file_.write(f"from typing import {', '.join(sorted(dc_typing))}\n")
686
719
  for mod in sorted(HO_DATACLASSES_IMPORTS):
687
720
  file_.write(f"import {mod}\n")
721
+ file_.write(f"from {package_name} import MODEL\n")
722
+ if HO_BASECLASSES_DICT_NAMES:
723
+ file_.write("if TYPE_CHECKING:\n")
724
+ file_.write(f" from {package_name}.ho_typeddicts import (\n")
725
+ for name in sorted(HO_BASECLASSES_DICT_NAMES):
726
+ file_.write(f" {name},\n")
727
+ file_.write(" )\n")
688
728
  file_.write("\n\n")
689
729
  file_.write(dc_relation_str)
690
730
  for dc in HO_DATACLASSES:
691
731
  file_.write(f"\n\n{dc}\n")
732
+ for bc in HO_BASECLASSES:
733
+ file_.write(f"\n\n{bc}\n")
692
734
 
693
735
 
694
736
  def generate(repo):
@@ -698,6 +740,8 @@ def generate(repo):
698
740
  HO_DATACLASSES_IMPORTS.clear()
699
741
  HO_TYPEDICTS.clear()
700
742
  HO_TYPEDICTS_IMPORTS.clear()
743
+ HO_BASECLASSES.clear()
744
+ HO_BASECLASSES_DICT_NAMES.clear()
701
745
  NO_APAPTER.clear()
702
746
 
703
747
  package_name = repo.name
@@ -721,7 +765,11 @@ def generate(repo):
721
765
  if not package_dir.exists():
722
766
  package_dir.mkdir(parents=True)
723
767
 
724
- __reset_dataclasses(repo, str(package_dir))
768
+ __reset_baseclasses(repo, str(package_dir))
769
+ # Remove legacy ho_dataclasses.py if present (merged into ho_baseclasses.py)
770
+ legacy = Path(package_dir) / "ho_dataclasses.py"
771
+ if legacy.exists():
772
+ legacy.unlink()
725
773
 
726
774
  # Generate package __init__.py
727
775
  with open(package_dir / INIT_PY, 'w', encoding='utf-8') as file_:
@@ -742,12 +790,13 @@ def generate(repo):
742
790
 
743
791
  # Generate modules for each relation
744
792
  for relation in repo.database.model._relations():
745
- paths = __update_this_module(repo, relation, str(package_dir), package_name)
746
- if paths:
747
- files_list.extend(paths)
793
+ module_path = __update_this_module(repo, relation, str(package_dir), package_name)
794
+ if module_path:
795
+ files_list.append(module_path)
796
+ # Tests are no longer added to files_list (they live in tests/ directory)
748
797
 
749
- __gen_dataclasses(str(package_dir), package_name)
750
798
  __gen_typedicts(str(package_dir), package_name)
799
+ __gen_baseclasses(str(package_dir), package_name)
751
800
 
752
801
  if len(NO_APAPTER):
753
802
  print("MISSING ADAPTER FOR SQL TYPE")
@@ -2002,6 +2002,25 @@ class PatchManager:
2002
2002
  result = patch_mgr.merge_patch()
2003
2003
  # Merges into ho-release/0.17.0, moves to stage
2004
2004
  """
2005
+ # 0. Remove stale local ho-* branches before any state change.
2006
+ # A stale branch (local but no longer on remote) causes
2007
+ # sync_hop_to_active_branches to leave staged-but-uncommitted
2008
+ # .hop/ changes that block subsequent checkouts, silently
2009
+ # skipping branches that should receive the TOML update.
2010
+ try:
2011
+ stale_result = self._repo.hgit.prune_local_branches(
2012
+ pattern="ho-*",
2013
+ dry_run=False,
2014
+ exclude_current=True,
2015
+ )
2016
+ if stale_result.get('deleted'):
2017
+ click.echo(
2018
+ f" ℹ Removed {len(stale_result['deleted'])} stale local "
2019
+ f"branch(es): {', '.join(stale_result['deleted'])}"
2020
+ )
2021
+ except Exception as e:
2022
+ raise PatchManagerError(f"Failed to clean up stale branches: {e}")
2023
+
2005
2024
  # 1. Extract patch_id from current branch
2006
2025
  current_branch = self._repo.hgit.branch
2007
2026
  if not current_branch.startswith('ho-patch/'):
@@ -643,10 +643,19 @@ class Repo:
643
643
  # Get all active branches (including ho-prod, release branches, and patch branches)
644
644
  try:
645
645
  branches_status = self.hgit.get_active_branches_status()
646
- patch_branches = [b['name'] for b in branches_status.get('patch_branches', [])]
647
- release_branches = [b['name'] for b in branches_status.get('release_branches', [])]
648
-
649
- # ho-staged/* branches are frozen after merge excluded from sync
646
+ # Only sync branches that still exist on remote — stale local
647
+ # branches (e.g. ho-patch/X renamed to ho-staged/X) leave
648
+ # staged-but-uncommitted .hop/ changes after a failed pre-commit
649
+ # hook, blocking checkout of subsequent branches in the loop.
650
+ patch_branches = [b['name'] for b in branches_status.get('patch_branches', [])
651
+ if b.get('exists_on_remote', True)]
652
+ release_branches = [b['name'] for b in branches_status.get('release_branches', [])
653
+ if b.get('exists_on_remote', True)]
654
+
655
+ # ho-staged/* branches are frozen after merge — excluded from sync.
656
+ # ho-prod is included but may have a protective hook (e.g. blocking
657
+ # direct commits); a commit failure on it is treated as a soft skip
658
+ # rather than aborting the whole sync (see Phase 1 loop below).
650
659
  all_branches = ['ho-prod'] + release_branches + patch_branches
651
660
 
652
661
  # Filter release branches to avoid syncing to future versions
@@ -685,140 +694,146 @@ class Repo:
685
694
  result['errors'].append(f"Failed to get active branches: {e}")
686
695
  return result
687
696
 
688
- # Sync each target branch
697
+ # Phase 1 local commits only (no push yet).
698
+ # All commits are made locally first so that a failure on any branch
699
+ # can be fully rolled back via git reset --hard, leaving a clean state.
700
+ original_shas = {} # branch → SHA recorded after checkout/fast-forward
701
+ committed_branches = [] # branches that received a new local commit
702
+ commit_msg = f"[HOP] Sync .hop/ from {source_branch} ({reason})"
703
+
704
+ def _rollback_phase1():
705
+ """Undo all local commits made during Phase 1."""
706
+ for rb, sha in original_shas.items():
707
+ try:
708
+ self.hgit.checkout(rb)
709
+ self.hgit._HGit__git_repo.git.reset('--hard', sha)
710
+ except Exception:
711
+ pass
712
+ try:
713
+ self.hgit.checkout(source_branch)
714
+ self.__config = Config(self.base_dir)
715
+ except Exception:
716
+ pass
717
+
689
718
  for branch in target_branches:
690
719
  try:
691
- # Checkout to target branch
692
720
  self.hgit.checkout(branch)
693
721
 
694
- # Reset to origin (source of truth) before syncing .hop/
695
- # This avoids non-fast-forward push failures when another
696
- # actor has already synced this branch.
722
+ # Fast-forward if behind remote (safe no local commits lost)
697
723
  remote_ref = f"origin/{branch}"
698
724
  try:
699
725
  synced, status = self.hgit.is_branch_synced(branch)
700
- # Only fast-forward when origin is strictly ahead (no local commits
701
- # at risk). Never reset on diverged branches — that would destroy
702
- # unmerged local work.
703
726
  if not synced and status == "behind":
704
727
  self.hgit._HGit__git_repo.git.reset('--hard', remote_ref)
705
728
  except GitCommandError:
706
- # Remote branch may not exist yet, continue without reset
707
729
  pass
708
730
 
709
- # Reload config for this branch
731
+ # Record SHA after fast-forward: this is the rollback target
732
+ original_shas[branch] = self.hgit._HGit__git_repo.head.commit.hexsha
733
+
710
734
  self.__config = Config(self.base_dir)
711
735
 
712
- # Use git checkout to copy .hop/ from source branch
713
- # This updates/adds files but doesn't remove deleted files
736
+ # Copy .hop/ from source branch
714
737
  self.hgit._HGit__git_repo.git.checkout(source_branch, '--', '.hop/')
715
738
 
716
- # Also checkout additional files if specified
717
739
  if additional_files:
718
740
  for file_path in additional_files:
719
741
  try:
720
742
  self.hgit._HGit__git_repo.git.checkout(source_branch, '--', file_path)
721
743
  except GitCommandError:
722
- # File might not exist in source branch, skip
723
744
  pass
724
745
 
725
- # Find files that exist in target but not in source and remove them
726
- # IMPORTANT: Only remove files for versions that exist in source
727
- # Don't remove release files (*-patches.toml, *.txt) for other versions
746
+ # Remove files that exist in target but no longer in source
728
747
  try:
729
- # Get list of files in .hop/ on current branch (target)
730
748
  target_files_output = self.hgit._HGit__git_repo.git.ls_files('.hop/')
731
749
  target_files = set(f for f in target_files_output.split('\n') if f.strip())
732
-
733
- # Get list of files in .hop/ on source branch
734
750
  source_files_output = self.hgit._HGit__git_repo.git.ls_tree('-r', '--name-only', source_branch, '.hop/')
735
751
  source_files = set(f for f in source_files_output.split('\n') if f.strip())
736
-
737
- # Get versions present in source (from .toml and .txt release files)
738
752
  source_versions = set()
739
753
  for file_path in source_files:
740
- # Extract version from release files
741
754
  if file_path.startswith('.hop/releases/'):
742
755
  filename = file_path.replace('.hop/releases/', '')
743
- # Match patterns: X.Y.Z-patches.toml, X.Y.Z.txt, X.Y.Z-rcN.txt, X.Y.Z-hotfixN.txt
744
756
  match = re.match(r'^(\d+\.\d+\.\d+)[-.]', filename)
745
757
  if match:
746
758
  source_versions.add(match.group(1))
747
-
748
- # Files to delete = in target but not in source
749
- files_to_delete = target_files - source_files
750
-
751
- # Filter: only delete files for versions present in source
752
- # This prevents deleting release files for unrelated versions
753
759
  safe_to_delete = []
754
- for file_path in files_to_delete:
760
+ for file_path in target_files - source_files:
755
761
  if not file_path:
756
762
  continue
757
- # Check if it's a release file
758
763
  if file_path.startswith('.hop/releases/'):
759
764
  filename = file_path.replace('.hop/releases/', '')
760
765
  match = re.match(r'^(\d+\.\d+\.\d+)[-.]', filename)
761
766
  if match:
762
- file_version = match.group(1)
763
- # Only delete if this version exists in source
764
- if file_version in source_versions:
767
+ if match.group(1) in source_versions:
765
768
  safe_to_delete.append(file_path)
766
769
  else:
767
- # Not a versioned file, safe to delete
768
770
  safe_to_delete.append(file_path)
769
771
  else:
770
- # Not in releases/, safe to delete
771
772
  safe_to_delete.append(file_path)
772
-
773
- # Remove files
774
773
  for file_path in safe_to_delete:
775
774
  self.hgit._HGit__git_repo.git.rm(file_path)
776
- except Exception as e:
777
- # If something fails in deletion detection, log but continue
778
- # The checkout already happened, so we have the updates
775
+ except Exception:
779
776
  pass
780
777
 
781
- # Stage all changes
782
778
  self.hgit.add('.hop/')
783
779
  if additional_files:
784
780
  for file_path in additional_files:
785
781
  try:
786
782
  self.hgit.add(file_path)
787
783
  except GitCommandError:
788
- pass # File might not exist
784
+ pass
789
785
 
790
- # Check if there are changes
791
786
  status = self.hgit._HGit__git_repo.git.status('--porcelain')
792
787
  if not status.strip():
793
- # No changes, skip
794
788
  result['skipped_branches'].append(branch)
789
+ del original_shas[branch] # nothing to roll back for this branch
795
790
  continue
796
791
 
797
- # Commit changes
798
- commit_msg = f"[HOP] Sync .hop/ from {source_branch} ({reason})"
799
792
  self.hgit.commit('-m', commit_msg)
800
793
 
801
- # Record the SHA of the sync commit for potential revert
802
794
  result['branch_commits'][branch] = (
803
795
  self.hgit._HGit__git_repo.head.commit.hexsha
804
796
  )
805
-
806
- # Push to remote
807
- self.hgit.push_branch(branch)
808
-
809
- result['synced_branches'].append(branch)
797
+ committed_branches.append(branch)
810
798
 
811
799
  except Exception as e:
812
- result['errors'].append(f"{branch}: {str(e)}")
800
+ if branch == 'ho-prod':
801
+ # ho-prod may have a protective hook that blocks direct
802
+ # commits. Clean up staged state so the next checkout
803
+ # succeeds, then skip gracefully.
804
+ try:
805
+ self.hgit._HGit__git_repo.git.reset('HEAD')
806
+ self.hgit._HGit__git_repo.git.checkout('--', '.hop/')
807
+ except Exception:
808
+ pass
809
+ original_shas.pop('ho-prod', None)
810
+ result['errors'].append(f"ho-prod: sync skipped (commit blocked): {e}")
811
+ else:
812
+ # Any other branch failure aborts and rolls back all local commits.
813
+ _rollback_phase1()
814
+ raise RepoError(
815
+ f"Sync commit failed on '{branch}': {e}\n"
816
+ f"All local sync commits have been rolled back.\n"
817
+ f"Run 'hop check' to diagnose."
818
+ ) from e
813
819
 
814
- # Return to source branch
820
+ # Return to source branch before pushing
815
821
  try:
816
822
  self.hgit.checkout(source_branch)
817
- # Reload config for source branch
818
823
  self.__config = Config(self.base_dir)
819
824
  except Exception as e:
820
825
  result['errors'].append(f"Failed to return to {source_branch}: {e}")
821
826
 
827
+ # Phase 2 — push all committed branches.
828
+ # The distributed lock ensures no conflicting pushes; errors here are
829
+ # unexpected but collected rather than raised.
830
+ for branch in committed_branches:
831
+ try:
832
+ self.hgit.push_branch(branch)
833
+ result['synced_branches'].append(branch)
834
+ except Exception as e:
835
+ result['errors'].append(f"{branch}: push failed: {e}")
836
+
822
837
  return result
823
838
 
824
839
  def sync_and_validate_ho_prod(self):
@@ -7,6 +7,5 @@ WARNING!
7
7
  {warning}
8
8
  """
9
9
  from half_orm.model import register
10
- from {package_name} import MODEL, ho_dataclasses
11
- fields_aliases=None
10
+ from {package_name} import MODEL, ho_baseclasses
12
11
 
@@ -0,0 +1,7 @@
1
+
2
+ @register
3
+ class {class_name}(ho_baseclasses.{bc_name}):
4
+ """
5
+ {documentation}
6
+ """
7
+
@@ -1,3 +1,3 @@
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)
@@ -0,0 +1 @@
1
+ 1.0.0-a15
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: half_orm_dev
3
- Version: 1.0.0a12
3
+ Version: 1.0.0a15
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
@@ -64,7 +64,6 @@ half_orm_dev/templates/MANIFEST.in
64
64
  half_orm_dev/templates/README
65
65
  half_orm_dev/templates/conftest_template
66
66
  half_orm_dev/templates/init_module_template
67
- half_orm_dev/templates/module_stub_template
68
67
  half_orm_dev/templates/module_template_1
69
68
  half_orm_dev/templates/module_template_2
70
69
  half_orm_dev/templates/module_template_3
@@ -1,8 +0,0 @@
1
- # DO NOT EDIT — auto-generated by half-orm-dev
2
- from typing import Iterator
3
- from {package_name}.ho_typeddicts import {dict_class_name}
4
-
5
- class {class_name}:
6
- def __iter__(self) -> Iterator[{dict_class_name}]: ...
7
- def ho_get(self, *args) -> {dict_class_name}: ...
8
- async def ho_aget(self, *args) -> {dict_class_name}: ...
@@ -1,7 +0,0 @@
1
-
2
- @register
3
- class {class_name}(MODEL.get_relation_class('{fqtn}', fields_aliases=fields_aliases), ho_dataclasses.{dc_name}):
4
- """
5
- {documentation}
6
- """
7
-
@@ -1 +0,0 @@
1
- 1.0.0-a12
File without changes
File without changes