half-orm-dev 0.16.0a9__py3-none-any.whl

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 (58) hide show
  1. half_orm_dev/__init__.py +1 -0
  2. half_orm_dev/cli/__init__.py +9 -0
  3. half_orm_dev/cli/commands/__init__.py +56 -0
  4. half_orm_dev/cli/commands/apply.py +13 -0
  5. half_orm_dev/cli/commands/clone.py +102 -0
  6. half_orm_dev/cli/commands/init.py +331 -0
  7. half_orm_dev/cli/commands/new.py +15 -0
  8. half_orm_dev/cli/commands/patch.py +317 -0
  9. half_orm_dev/cli/commands/prepare.py +21 -0
  10. half_orm_dev/cli/commands/prepare_release.py +119 -0
  11. half_orm_dev/cli/commands/promote_to.py +127 -0
  12. half_orm_dev/cli/commands/release.py +344 -0
  13. half_orm_dev/cli/commands/restore.py +14 -0
  14. half_orm_dev/cli/commands/sync.py +13 -0
  15. half_orm_dev/cli/commands/todo.py +73 -0
  16. half_orm_dev/cli/commands/undo.py +17 -0
  17. half_orm_dev/cli/commands/update.py +73 -0
  18. half_orm_dev/cli/commands/upgrade.py +191 -0
  19. half_orm_dev/cli/main.py +103 -0
  20. half_orm_dev/cli_extension.py +38 -0
  21. half_orm_dev/database.py +1389 -0
  22. half_orm_dev/hgit.py +1025 -0
  23. half_orm_dev/hop.py +167 -0
  24. half_orm_dev/manifest.py +43 -0
  25. half_orm_dev/modules.py +456 -0
  26. half_orm_dev/patch.py +281 -0
  27. half_orm_dev/patch_manager.py +1694 -0
  28. half_orm_dev/patch_validator.py +335 -0
  29. half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
  30. half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
  31. half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
  32. half_orm_dev/patches/log +2 -0
  33. half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
  34. half_orm_dev/release_manager.py +2841 -0
  35. half_orm_dev/repo.py +1562 -0
  36. half_orm_dev/templates/.gitignore +15 -0
  37. half_orm_dev/templates/MANIFEST.in +1 -0
  38. half_orm_dev/templates/Pipfile +13 -0
  39. half_orm_dev/templates/README +25 -0
  40. half_orm_dev/templates/conftest_template +42 -0
  41. half_orm_dev/templates/init_module_template +10 -0
  42. half_orm_dev/templates/module_template_1 +12 -0
  43. half_orm_dev/templates/module_template_2 +6 -0
  44. half_orm_dev/templates/module_template_3 +3 -0
  45. half_orm_dev/templates/relation_test +23 -0
  46. half_orm_dev/templates/setup.py +81 -0
  47. half_orm_dev/templates/sql_adapter +9 -0
  48. half_orm_dev/templates/warning +12 -0
  49. half_orm_dev/utils.py +49 -0
  50. half_orm_dev/version.txt +1 -0
  51. half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
  52. half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
  53. half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
  54. half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
  55. half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
  56. half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
  57. tests/__init__.py +0 -0
  58. tests/conftest.py +329 -0
half_orm_dev/repo.py ADDED
@@ -0,0 +1,1562 @@
1
+ """The pkg_conf module provides the Repo class.
2
+ """
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import sys
8
+ from configparser import ConfigParser
9
+ from pathlib import Path
10
+ import subprocess
11
+ from typing import Optional
12
+ from psycopg2 import OperationalError
13
+
14
+ import half_orm
15
+ from half_orm import utils
16
+ from half_orm_dev.database import Database
17
+ from half_orm_dev.hgit import HGit
18
+ from half_orm_dev import modules
19
+ from half_orm.model import Model
20
+ from half_orm_dev.patch import Patch
21
+ from half_orm_dev.patch_manager import PatchManager, PatchManagerError
22
+ from half_orm_dev.release_manager import ReleaseManager
23
+
24
+ from .utils import TEMPLATE_DIRS, hop_version, resolve_database_config_name
25
+
26
+ class RepoError(Exception):
27
+ pass
28
+
29
+ class Config:
30
+ """
31
+ """
32
+ __name: Optional[str] = None
33
+ __git_origin: str = ''
34
+ __devel: bool = False
35
+ __hop_version: Optional[str] = None
36
+ def __init__(self, base_dir, **kwargs):
37
+ Config.__file = os.path.join(base_dir, '.hop', 'config')
38
+ self.__name = kwargs.get('name') or resolve_database_config_name(base_dir)
39
+ self.__devel = kwargs.get('devel', False)
40
+ if os.path.exists(self.__file):
41
+ sys.path.insert(0, base_dir)
42
+ self.read()
43
+
44
+ def read(self):
45
+ "Sets __name and __hop_version"
46
+ config = ConfigParser()
47
+ config.read(self.__file)
48
+ self.__hop_version = config['halfORM'].get('hop_version', '')
49
+ self.__git_origin = config['halfORM'].get('git_origin', '')
50
+ self.__devel = config['halfORM'].getboolean('devel', False)
51
+ self.__allow_rc = config['halfORM'].getboolean('allow_rc', False)
52
+
53
+ def write(self):
54
+ "Helper: write file in utf8"
55
+ config = ConfigParser()
56
+ self.__hop_version = hop_version()
57
+ data = {
58
+ 'hop_version': self.__hop_version,
59
+ 'git_origin': self.__git_origin,
60
+ 'devel': self.__devel
61
+ }
62
+ config['halfORM'] = data
63
+ with open(Config.__file, 'w', encoding='utf-8') as configfile:
64
+ config.write(configfile)
65
+
66
+ @property
67
+ def name(self):
68
+ return self.__name
69
+ @name.setter
70
+ def name(self, name):
71
+ self.__name = name
72
+
73
+ @property
74
+ def git_origin(self):
75
+ return self.__git_origin
76
+ @git_origin.setter
77
+ def git_origin(self, origin):
78
+ "Sets the git origin and register it in .hop/config"
79
+ self.__git_origin = origin
80
+ self.write()
81
+
82
+ @property
83
+ def hop_version(self):
84
+ return self.__hop_version
85
+ @hop_version.setter
86
+ def hop_version(self, version):
87
+ self.__hop_version = version
88
+ self.write()
89
+
90
+ @property
91
+ def devel(self):
92
+ return self.__devel
93
+ @devel.setter
94
+ def devel(self, devel):
95
+ self.__devel = devel
96
+
97
+ @property
98
+ def allow_rc(self):
99
+ return self.__allow_rc
100
+
101
+ @allow_rc.setter
102
+ def allow_rc(self, value):
103
+ self.__allow_rc = value
104
+ self.write()
105
+
106
+ class Repo:
107
+ """Reads and writes the hop repo conf file.
108
+
109
+ Implements Singleton pattern to ensure only one instance per base directory.
110
+ """
111
+
112
+ # Singleton storage: base_dir -> instance
113
+ _instances = {}
114
+
115
+ # Instance variables
116
+ __new = False
117
+ __checked: bool = False
118
+ __base_dir: Optional[str] = None
119
+ __config: Optional[Config] = None
120
+ database: Optional[Database] = None
121
+ hgit: Optional[HGit] = None
122
+ _patch_directory: Optional[PatchManager] = None
123
+ _release_manager: Optional[ReleaseManager] = None
124
+
125
+ def __new__(cls):
126
+ """Singleton implementation based on current working directory"""
127
+ # Find the base directory for this context
128
+ base_dir = cls._find_base_dir()
129
+
130
+ # Return existing instance if it exists for this base_dir
131
+ if base_dir in cls._instances:
132
+ return cls._instances[base_dir]
133
+
134
+ # Create new instance
135
+ instance = super().__new__(cls)
136
+ cls._instances[base_dir] = instance
137
+ return instance
138
+
139
+ def __init__(self):
140
+ # Only initialize once per instance
141
+ if hasattr(self, '_initialized'):
142
+ return
143
+
144
+ self._initialized = True
145
+ self.__check()
146
+
147
+ @classmethod
148
+ def _find_base_dir(cls):
149
+ """Find the base directory for the current context (same logic as __check)"""
150
+ base_dir = os.path.abspath(os.path.curdir)
151
+ while base_dir:
152
+ conf_file = os.path.join(base_dir, '.hop', 'config')
153
+ if os.path.exists(conf_file):
154
+ return base_dir
155
+ par_dir = os.path.split(base_dir)[0]
156
+ if par_dir == base_dir:
157
+ break
158
+ base_dir = par_dir
159
+ return os.path.abspath(os.path.curdir) # fallback to current dir
160
+
161
+ @classmethod
162
+ def clear_instances(cls):
163
+ """Clear all singleton instances - useful for testing or cleanup"""
164
+ for instance in cls._instances.values():
165
+ if instance.database and instance.database.model:
166
+ try:
167
+ instance.database.model.disconnect()
168
+ except:
169
+ pass
170
+ cls._instances.clear()
171
+
172
+ @property
173
+ def new(self):
174
+ "Returns if the repo is being created or not."
175
+ return Repo.__new
176
+
177
+ @property
178
+ def checked(self):
179
+ "Returns if the Repo is OK."
180
+ return self.__checked
181
+
182
+ @property
183
+ def production(self):
184
+ "Returns the production status of the database"
185
+ return self.database.production
186
+
187
+ @property
188
+ def model(self):
189
+ "Returns the Model (halfORM) of the database"
190
+ return self.database.model
191
+
192
+ def __check(self):
193
+ """Searches the hop configuration file for the package.
194
+ This method is called when no hop config file is provided.
195
+ Returns True if we are in a repo, False otherwise.
196
+ """
197
+ base_dir = os.path.abspath(os.path.curdir)
198
+ while base_dir:
199
+ if self.__set_base_dir(base_dir):
200
+ self.database = Database(self)
201
+ if self.devel:
202
+ self.hgit = HGit(self)
203
+ self.__checked = True
204
+ par_dir = os.path.split(base_dir)[0]
205
+ if par_dir == base_dir:
206
+ break
207
+ base_dir = par_dir
208
+
209
+ def __set_base_dir(self, base_dir):
210
+ conf_file = os.path.join(base_dir, '.hop', 'config')
211
+ if os.path.exists(conf_file):
212
+ self.__base_dir = base_dir
213
+ self.__config = Config(base_dir)
214
+ return True
215
+ return False
216
+
217
+ @property
218
+ def base_dir(self):
219
+ "Returns the base dir of the repository"
220
+ return self.__base_dir
221
+
222
+ @property
223
+ def name(self):
224
+ "Returns the name of the package"
225
+ return self.__config and self.__config.name or None
226
+
227
+ @property
228
+ def git_origin(self):
229
+ "Returns the git origin registered in .hop/config"
230
+ return self.__config.git_origin
231
+ @git_origin.setter
232
+ def git_origin(self, origin):
233
+ self.__config.git_origin = origin
234
+
235
+ @property
236
+ def allow_rc(self):
237
+ """Returns whether RC releases are allowed in production."""
238
+ return self.__config.allow_rc
239
+
240
+ def __hop_version_mismatch(self):
241
+ """Returns a boolean indicating if current hop version is different from
242
+ the last hop version used with this repository.
243
+ """
244
+ return hop_version() != self.__config.hop_version
245
+
246
+ @property
247
+ def devel(self):
248
+ return self.__config.devel
249
+
250
+ @property
251
+ def state(self):
252
+ "Returns the state (str) of the repository."
253
+ res = [f'hop version: {utils.Color.bold(hop_version())}']
254
+ res += [f'half-orm version: {utils.Color.bold(half_orm.__version__)}\n']
255
+ if self.__config:
256
+ hop_version_display = utils.Color.red(self.__config.hop_version) if \
257
+ self.__hop_version_mismatch() else \
258
+ utils.Color.green(self.__config.hop_version)
259
+ res += [
260
+ '[Hop repository]',
261
+ f'- base directory: {self.__base_dir}',
262
+ f'- package name: {self.__config.name}',
263
+ f'- hop version: {hop_version_display}'
264
+ ]
265
+ res.append(self.database.state)
266
+ res.append(str(self.hgit))
267
+ res.append(Patch(self).state)
268
+ return '\n'.join(res)
269
+
270
+ def init(self, package_name, devel):
271
+ "Create a new hop repository"
272
+ raise Exception("Deprecated init")
273
+ Repo.__new = True
274
+ cur_dir = os.path.abspath(os.path.curdir)
275
+ self.__base_dir = os.path.join(cur_dir, package_name)
276
+ self.__config = Config(self.__base_dir, name=package_name, devel=devel)
277
+ self.database = Database(self, get_release=False).init(self.__config.name)
278
+ print(f"Installing new hop repo in {self.__base_dir}.")
279
+
280
+ if not os.path.exists(self.__base_dir):
281
+ os.makedirs(self.__base_dir)
282
+ else:
283
+ utils.error(f"ERROR! The path '{self.__base_dir}' already exists!\n", exit_code=1)
284
+ readme = utils.read(os.path.join(TEMPLATE_DIRS, 'README'))
285
+ setup_template = utils.read(os.path.join(TEMPLATE_DIRS, 'setup.py'))
286
+ git_ignore = utils.read(os.path.join(TEMPLATE_DIRS, '.gitignore'))
287
+ pipfile = utils.read(os.path.join(TEMPLATE_DIRS, 'Pipfile'))
288
+
289
+ setup = setup_template.format(
290
+ dbname=self.__config.name,
291
+ package_name=self.__config.name,
292
+ half_orm_version=half_orm.__version__)
293
+ utils.write(os.path.join(self.__base_dir, 'setup.py'), setup)
294
+
295
+ pipfile = pipfile.format(
296
+ half_orm_version=half_orm.__version__,
297
+ hop_version=hop_version())
298
+ utils.write(os.path.join(self.__base_dir, 'Pipfile'), pipfile)
299
+
300
+ os.mkdir(os.path.join(self.__base_dir, '.hop'))
301
+ self.__config.write()
302
+ modules.generate(self)
303
+
304
+ readme = readme.format(
305
+ hop_version=hop_version(), dbname=self.__config.name, package_name=self.__config.name)
306
+ utils.write(os.path.join(self.__base_dir, 'README.md'), readme)
307
+ utils.write(os.path.join(self.__base_dir, '.gitignore'), git_ignore)
308
+ self.hgit = HGit().init(self.__base_dir)
309
+
310
+ print(f"\nThe hop project '{self.__config.name}' has been created.")
311
+ print(self.state)
312
+
313
+ def sync_package(self):
314
+ Patch(self).sync_package()
315
+
316
+ def upgrade_prod(self):
317
+ "Upgrade (production)"
318
+ Patch(self).upgrade_prod()
319
+
320
+ def restore(self, release):
321
+ "Restore package and database to release (production/devel)"
322
+ Patch(self).restore(release)
323
+
324
+ def prepare_release(self, level, message=None):
325
+ "Prepare a new release (devel)"
326
+ Patch(self).prep_release(level, message)
327
+
328
+ def apply_release(self):
329
+ "Apply the current release (devel)"
330
+ Patch(self).apply(self.hgit.current_release, force=True)
331
+
332
+ def undo_release(self, database_only=False):
333
+ "Undo the current release (devel)"
334
+ Patch(self).undo(database_only=database_only)
335
+
336
+ def commit_release(self, push):
337
+ "Release a 'release' (devel)"
338
+ Patch(self).release(push)
339
+
340
+ @property
341
+ def patch_manager(self) -> PatchManager:
342
+ """
343
+ Get PatchManager instance for patch-centric operations.
344
+
345
+ Provides access to Patches/ directory management including:
346
+ - Creating patch directories with minimal README templates
347
+ - Validating patch structure following KISS principles
348
+ - Applying SQL and Python files in lexicographic order
349
+ - Listing and managing existing patches
350
+
351
+ Lazy initialization ensures PatchManager is only created when needed
352
+ and cached for subsequent accesses.
353
+
354
+ Returns:
355
+ PatchManager: Instance for managing Patches/ operations
356
+
357
+ Raises:
358
+ PatchManagerError: If repository not in development mode
359
+ RuntimeError: If repository not properly initialized
360
+
361
+ Examples:
362
+ # Create new patch directory
363
+ repo.patch_manager.create_patch_directory("456-user-auth")
364
+
365
+ # Apply patch files using repo's model
366
+ applied = repo.patch_manager.apply_patch_files("456-user-auth", repo.model)
367
+
368
+ # List all existing patches
369
+ patches = repo.patch_manager.list_all_patches()
370
+
371
+ # Get detailed patch structure analysis
372
+ structure = repo.patch_manager.get_patch_structure("456-user-auth")
373
+ if structure.is_valid:
374
+ print(f"Patch has {len(structure.files)} executable files")
375
+ """
376
+ # Validate repository is properly initialized
377
+ if not self.__checked:
378
+ raise RuntimeError(
379
+ "Repository not initialized. PatchManager requires valid repository context."
380
+ )
381
+
382
+ # Validate development mode requirement
383
+ if not self.devel:
384
+ raise PatchManagerError(
385
+ "PatchManager operations require development mode. "
386
+ "Enable development mode in repository configuration."
387
+ )
388
+
389
+ # Lazy initialization with caching
390
+ if self._patch_directory is None:
391
+ try:
392
+ self._patch_directory = PatchManager(self)
393
+ except Exception as e:
394
+ raise PatchManagerError(
395
+ f"Failed to initialize PatchManager: {e}"
396
+ ) from e
397
+
398
+ return self._patch_directory
399
+
400
+ def clear_patch_directory_cache(self) -> None:
401
+ """
402
+ Clear cached PatchManager instance.
403
+
404
+ Forces re-initialization of PatchManager on next access.
405
+ Useful for testing or when repository configuration changes.
406
+
407
+ Examples:
408
+ # Clear cache after configuration change
409
+ repo.clear_patch_directory_cache()
410
+
411
+ # Next access will create fresh instance
412
+ new_patch_dir = repo.patch_manager
413
+ """
414
+ self._patch_directory = None
415
+
416
+ def has_patch_directory_support(self) -> bool:
417
+ """
418
+ Check if repository supports PatchManager operations.
419
+
420
+ Validates that repository is in development mode and properly
421
+ initialized without actually creating PatchManager instance.
422
+
423
+ Returns:
424
+ bool: True if PatchManager operations are supported
425
+
426
+ Examples:
427
+ if repo.has_patch_directory_support():
428
+ patches = repo.patch_manager.list_all_patches()
429
+ else:
430
+ print("Repository not in development mode")
431
+ """
432
+ return self.__checked and self.devel
433
+
434
+ @property
435
+ def release_manager(self) -> ReleaseManager:
436
+ """
437
+ Get ReleaseManager instance for release lifecycle operations.
438
+
439
+ Provides access to releases/ directory management including:
440
+ - Preparing new releases (stage files creation)
441
+ - Version calculation and management
442
+ - Release lifecycle (stage → rc → production)
443
+ - Production version tracking
444
+
445
+ Lazy initialization ensures ReleaseManager is only created when needed
446
+ and cached for subsequent accesses.
447
+
448
+ Returns:
449
+ ReleaseManager: Instance for managing releases/ operations
450
+
451
+ Raises:
452
+ RuntimeError: If repository not properly initialized
453
+
454
+ Examples:
455
+ # Prepare new patch release
456
+ result = repo.release_manager.prepare_release('patch')
457
+ print(f"Created: {result['version']}")
458
+
459
+ # Find latest version
460
+ latest = repo.release_manager.find_latest_version()
461
+
462
+ # Calculate next version
463
+ next_ver = repo.release_manager.calculate_next_version(latest, 'minor')
464
+ """
465
+ # Validate repository is properly initialized
466
+ if not self.__checked:
467
+ raise RuntimeError(
468
+ "Repository not initialized. ReleaseManager requires valid repository context."
469
+ )
470
+
471
+ # Lazy initialization with caching
472
+ if self._release_manager is None:
473
+ self._release_manager = ReleaseManager(self)
474
+
475
+ return self._release_manager
476
+
477
+ def clear_release_manager_cache(self) -> None:
478
+ """
479
+ Clear cached ReleaseManager instance.
480
+
481
+ Forces re-initialization of ReleaseManager on next access.
482
+ Useful for testing or when repository configuration changes.
483
+
484
+ Examples:
485
+ # Clear cache after configuration change
486
+ repo.clear_release_manager_cache()
487
+
488
+ # Next access will create fresh instance
489
+ new_release_mgr = repo.release_manager
490
+ """
491
+ self._release_manager = None
492
+
493
+ def init_git_centric_project(self, package_name, git_origin):
494
+ """
495
+ Initialize new halfORM project with Git-centric architecture.
496
+
497
+ Creates a complete project structure with Git repository, configuration,
498
+ and generated Python package from database schema. This is the main entry
499
+ point for creating new projects, replacing the legacy init(package_name, devel)
500
+ workflow.
501
+
502
+ Args:
503
+ package_name: Name for the project directory and Python package
504
+ git_origin: Git remote origin URL (HTTPS, SSH, or Git protocol)
505
+
506
+ Raises:
507
+ ValueError: If package_name or git_origin are invalid
508
+ FileExistsError: If project directory already exists
509
+ OperationalError: If database connection fails
510
+
511
+ Process:
512
+ 1. Validate package name and git origin URL
513
+ 2. Verify database is configured
514
+ 3. Connect to database and detect mode (metadata → devel=True)
515
+ 4. Create project directory structure
516
+ 5. Generate configuration files (.hop/config with git_origin)
517
+ 6. Create Git-centric directories (Patches/, releases/)
518
+ 7. Initialize Database instance (self.database)
519
+ 8. Generate Python package structure
520
+ 9. Initialize Git repository with ho-prod branch
521
+ 10. Generate template files (README, .gitignore, setup.py, Pipfile)
522
+ 11. Save model/schema-0.0.0.sql
523
+
524
+ Git-centric Architecture:
525
+ - Main branch: ho-prod (replaces hop_main)
526
+ - Patch branches: ho-patch/<patch-name>
527
+ - Directory structure: Patches/<patch-name>/ for schema files
528
+ - Release management: releases/X.Y.Z-stage.txt workflow
529
+
530
+ Mode Detection:
531
+ - Full development mode: Database has half_orm_meta schemas
532
+ - Sync-only mode: Database lacks metadata (read-only package sync)
533
+
534
+ Examples:
535
+ # After database configuration with valid git origin
536
+ repo = Repo()
537
+ repo.init_git_centric_project(
538
+ "my_blog",
539
+ "https://github.com/user/my_blog.git"
540
+ )
541
+ # → Creates my_blog/ with full development mode if metadata present
542
+
543
+ # With SSH URL
544
+ repo.init_git_centric_project(
545
+ "my_app",
546
+ "git@github.com:user/my_app.git"
547
+ )
548
+
549
+ # Self-hosted Git server
550
+ repo.init_git_centric_project(
551
+ "company_project",
552
+ "https://git.company.com/team/project.git"
553
+ )
554
+
555
+ Migration Notes:
556
+ - Replaces Repo.init(package_name, devel) from legacy workflow
557
+ - Database creation moved to separate init-database command
558
+ - Mode detection replaces explicit --devel flag
559
+ - Git branch naming updated (hop_main → ho-prod)
560
+ - git_origin is now mandatory (was optional/auto-discovered)
561
+ """
562
+ # Step 1: Validate package name
563
+ self._validate_package_name(package_name)
564
+
565
+ # Step 1b: Validate git origin URL (EARLY validation)
566
+ self._validate_git_origin_url(git_origin)
567
+
568
+ # Step 2: Check database configuration exists
569
+ self._verify_database_configured(package_name)
570
+
571
+ # Step 3: Connect to database and detect mode
572
+ devel_mode = self._detect_development_mode(package_name)
573
+
574
+ # Step 4: Setup project directory
575
+ self._create_project_directory(package_name)
576
+
577
+ # Step 5: Initialize configuration (now includes git_origin)
578
+ self._initialize_configuration(package_name, devel_mode, git_origin.strip())
579
+
580
+ # Step 6: Create Git-centric directories
581
+ self._create_git_centric_structure()
582
+
583
+ # Step 7: Initialize Database instance (CRITICAL - must be before generate)
584
+ self.database = Database(self)
585
+
586
+ # Step 8: Generate Python package
587
+ self._generate_python_package()
588
+
589
+ # Step 9: Generate template files
590
+ self._generate_template_files()
591
+
592
+ # Step 10: Save initial schema dump
593
+ self._dump_initial_schema()
594
+
595
+ # Step 11: Initialize Git repository with ho-prod branch
596
+ self._initialize_git_repository()
597
+
598
+
599
+ def _validate_package_name(self, package_name):
600
+ """
601
+ Validate package name follows Python package naming conventions.
602
+
603
+ Args:
604
+ package_name (str): Package name to validate
605
+
606
+ Raises:
607
+ ValueError: If package name is invalid
608
+
609
+ Rules:
610
+ - Not None or empty
611
+ - Valid Python identifier (letters, numbers, underscore)
612
+ - Cannot start with digit
613
+ - Recommended: lowercase with underscores
614
+
615
+ Examples:
616
+ _validate_package_name("my_blog") # Valid
617
+ _validate_package_name("my-blog") # Valid (converted to my_blog)
618
+ _validate_package_name("9invalid") # Raises ValueError
619
+ _validate_package_name("my blog") # Raises ValueError
620
+ """
621
+ import keyword
622
+
623
+ # Check for None
624
+ if package_name is None:
625
+ raise ValueError("Package name cannot be None")
626
+
627
+ # Check type
628
+ if not isinstance(package_name, str):
629
+ raise ValueError(f"Package name must be a string, got {type(package_name).__name__}")
630
+
631
+ # Check for empty string
632
+ if not package_name or not package_name.strip():
633
+ raise ValueError("Package name cannot be empty")
634
+
635
+ # Clean the name
636
+ package_name = package_name.strip()
637
+
638
+ # Convert hyphens to underscores (common convention)
639
+ normalized_name = package_name.replace('-', '_')
640
+
641
+ # Check if starts with digit
642
+ if normalized_name[0].isdigit():
643
+ raise ValueError(f"Package name '{package_name}' cannot start with a digit")
644
+
645
+ # Check for valid Python identifier characters
646
+ # Allow only letters, numbers, and underscores
647
+ if not normalized_name.replace('_', '').isalnum():
648
+ raise ValueError(
649
+ f"Package name '{package_name}' contains invalid characters. "
650
+ "Use only letters, numbers, underscore, and hyphen."
651
+ )
652
+
653
+ # Check for Python reserved keywords
654
+ if keyword.iskeyword(normalized_name):
655
+ raise ValueError(
656
+ f"Package name '{package_name}' is a Python reserved keyword"
657
+ )
658
+
659
+ # Store normalized name for later use
660
+ return normalized_name
661
+
662
+
663
+ def _verify_database_configured(self, package_name):
664
+ """
665
+ Verify database is configured via init-database command.
666
+
667
+ Checks that database configuration file exists and is accessible.
668
+ Does NOT create the database - assumes init-database was run first.
669
+
670
+ Args:
671
+ package_name (str): Database name to verify
672
+
673
+ Raises:
674
+ DatabaseNotConfiguredError: If configuration file doesn't exist
675
+ DatabaseConnectionError: If cannot connect to configured database
676
+
677
+ Process:
678
+ 1. Check ~/.half_orm/<package_name> exists
679
+ 2. Attempt connection to verify database is accessible
680
+ 3. Store connection for later use
681
+
682
+ Examples:
683
+ # Database configured
684
+ _verify_database_configured("my_blog") # Success
685
+
686
+ # Database not configured
687
+ _verify_database_configured("unconfigured_db")
688
+ # Raises: DatabaseNotConfiguredError with helpful message
689
+ """
690
+ # Try to load database configuration
691
+ config = Database._load_configuration(package_name)
692
+
693
+ if config is None:
694
+ raise ValueError(
695
+ f"Database '{package_name}' is not configured.\n"
696
+ f"Please run: half_orm dev init-database {package_name} [OPTIONS]\n"
697
+ f"See 'half_orm dev init-database --help' for more information."
698
+ )
699
+
700
+ # Try to connect to verify database is accessible
701
+ try:
702
+ model = Model(package_name)
703
+ # Store model for later use
704
+ return model
705
+ except OperationalError as e:
706
+ raise OperationalError(
707
+ f"Cannot connect to database '{package_name}'.\n"
708
+ f"Database may not exist or connection parameters may be incorrect.\n"
709
+ f"Original error: {e}"
710
+ )
711
+
712
+ def _detect_development_mode(self, package_name):
713
+ """
714
+ Detect development mode based on metadata presence in database.
715
+
716
+ Automatically determines if full development mode (with patch management)
717
+ or sync-only mode based on half_orm_meta schemas presence.
718
+
719
+ Args:
720
+ package_name (str): Database name to check
721
+
722
+ Returns:
723
+ bool: True if metadata present (full mode), False if sync-only
724
+
725
+ Detection Logic:
726
+ - Query database for half_orm_meta.hop_release table
727
+ - Present → devel=True (full development mode)
728
+ - Absent → devel=False (sync-only mode)
729
+
730
+ Examples:
731
+ # Database with metadata
732
+ mode = _detect_development_mode("my_blog")
733
+ assert mode is True # Full development mode
734
+
735
+ # Database without metadata
736
+ mode = _detect_development_mode("legacy_db")
737
+ assert mode is False # Sync-only mode
738
+ """
739
+ from half_orm.model import Model
740
+
741
+ # Check if we already have a Model instance (from _verify_database_configured)
742
+ if hasattr(self, 'database') and self.database and hasattr(self.database, 'model'):
743
+ model = self.database.model
744
+ else:
745
+ # Create new Model instance
746
+ model = Model(package_name)
747
+
748
+ # Check for metadata table presence
749
+ return model.has_relation('half_orm_meta.hop_release')
750
+
751
+ def _create_project_directory(self, package_name):
752
+ """
753
+ Create project root directory with validation.
754
+
755
+ Args:
756
+ package_name (str): Name for project directory
757
+
758
+ Raises:
759
+ FileExistsError: If directory already exists
760
+ OSError: If directory creation fails
761
+
762
+ Process:
763
+ 1. Build absolute path from current directory
764
+ 2. Check directory doesn't already exist
765
+ 3. Create directory
766
+ 4. Store path in self.__base_dir
767
+
768
+ Examples:
769
+ # Success case
770
+ _create_project_directory("my_blog")
771
+ # Creates: /current/path/my_blog/
772
+
773
+ # Error case
774
+ _create_project_directory("existing_dir")
775
+ # Raises: FileExistsError
776
+ """
777
+ import os
778
+
779
+ # Build absolute path
780
+ cur_dir = os.path.abspath(os.path.curdir)
781
+ project_path = os.path.join(cur_dir, package_name)
782
+
783
+ # Check if directory already exists
784
+ if os.path.exists(project_path):
785
+ raise FileExistsError(
786
+ f"Directory '{package_name}' already exists at {project_path}.\n"
787
+ "Choose a different project name or remove the existing directory."
788
+ )
789
+
790
+ # Create directory
791
+ try:
792
+ os.makedirs(project_path)
793
+ except PermissionError as e:
794
+ raise PermissionError(
795
+ f"Permission denied: Cannot create directory '{project_path}'.\n"
796
+ f"Check your write permissions for the current directory."
797
+ ) from e
798
+ except OSError as e:
799
+ raise OSError(
800
+ f"Failed to create directory '{project_path}': {e}"
801
+ ) from e
802
+
803
+ # Store base directory path
804
+ self._Repo__base_dir = project_path
805
+
806
+ return project_path
807
+
808
+
809
+ def _initialize_configuration(self, package_name, devel_mode, git_origin):
810
+ """
811
+ Initialize .hop/config file with project settings.
812
+
813
+ Creates .hop directory and config file with project metadata including
814
+ package name, hop version, development mode, and git origin URL.
815
+
816
+ Args:
817
+ package_name: Name of the Python package
818
+ devel_mode: Boolean indicating full development vs sync-only mode
819
+ git_origin: Git remote origin URL
820
+
821
+ Creates:
822
+ .hop/config file with INI format containing:
823
+ - package_name: Project/package name
824
+ - hop_version: Current half_orm_dev version
825
+ - devel: Development mode flag
826
+ - git_origin: Git remote URL
827
+
828
+ Examples:
829
+ _initialize_configuration("my_blog", True, "https://github.com/user/my_blog.git")
830
+ # Creates .hop/config:
831
+ # [halfORM]
832
+ # package_name = my_blog
833
+ # hop_version = 0.16.0
834
+ # devel = True
835
+ # git_origin = https://github.com/user/my_blog.git
836
+ """
837
+ import os
838
+ from half_orm_dev.utils import hop_version
839
+
840
+ # Create .hop directory
841
+ hop_dir = os.path.join(self.__base_dir, '.hop')
842
+ os.makedirs(hop_dir, exist_ok=True)
843
+
844
+ # Initialize Config object (stores git_origin)
845
+ self.__config = Config(self.__base_dir, name=package_name, devel=devel_mode)
846
+
847
+ # Set git_origin in config
848
+ self.__config.git_origin = git_origin
849
+
850
+ # Write config file (Config.write() handles the actual file writing)
851
+ self.__config.write()
852
+
853
+ def _create_git_centric_structure(self):
854
+ """
855
+ Create Git-centric directory structure for patch management.
856
+
857
+ Creates directories required for Git-centric workflow:
858
+ - Patches/ for patch development
859
+ - releases/ for release management
860
+ - model/ for schema snapshots
861
+ - backups/ for database backups
862
+
863
+ Only created in development mode (devel=True).
864
+
865
+ Directory Structure:
866
+ Patches/
867
+ ├── README.md # Patch development guide
868
+ releases/
869
+ ├── README.md # Release workflow guide
870
+ model/
871
+ backups/
872
+
873
+ Examples:
874
+ # Development mode
875
+ _create_git_centric_structure()
876
+ # Creates: Patches/, releases/, model/, backups/
877
+
878
+ # Sync-only mode
879
+ _create_git_centric_structure()
880
+ # Skips creation (not needed for sync-only)
881
+ """
882
+ import os
883
+
884
+ # Only create structure in development mode
885
+ if not self.__config.devel:
886
+ return
887
+
888
+ # Create directories
889
+ patches_dir = os.path.join(self.__base_dir, 'Patches')
890
+ releases_dir = os.path.join(self.__base_dir, 'releases')
891
+ model_dir = os.path.join(self.__base_dir, 'model')
892
+ backups_dir = os.path.join(self.__base_dir, 'backups')
893
+
894
+ os.makedirs(patches_dir, exist_ok=True)
895
+ os.makedirs(releases_dir, exist_ok=True)
896
+ os.makedirs(model_dir, exist_ok=True)
897
+ os.makedirs(backups_dir, exist_ok=True)
898
+
899
+ # Create README files for guidance
900
+ patches_readme = os.path.join(patches_dir, 'README.md')
901
+ with open(patches_readme, 'w', encoding='utf-8') as f:
902
+ f.write("""# Patches Directory
903
+
904
+ This directory contains schema patch files for database evolution.
905
+
906
+ ## Structure
907
+
908
+ Each patch is stored in its own directory:
909
+ ```
910
+ Patches/
911
+ ├── 001-initial-schema/
912
+ │ ├── 01_create_users.sql
913
+ │ ├── 02_add_indexes.sql
914
+ │ └── 03_seed_data.py
915
+ ├── 002-add-authentication/
916
+ │ └── 01_auth_tables.sql
917
+ ```
918
+
919
+ ## Workflow
920
+
921
+ 1. Create patch branch: `half_orm dev create-patch <patch-id>`
922
+ 2. Add SQL/Python files to Patches/<patch-id>/
923
+ 3. Apply patch: `half_orm dev apply-patch`
924
+ 4. Test your changes
925
+ 5. Add to release: `half_orm dev add-to-release <patch-id>`
926
+
927
+ ## File Naming
928
+
929
+ - Use numeric prefixes for ordering: `01_`, `02_`, etc.
930
+ - SQL files: `*.sql`
931
+ - Python scripts: `*.py`
932
+ - Files executed in lexicographic order
933
+
934
+ See docs/half_orm_dev.md for complete documentation.
935
+ """)
936
+
937
+ releases_readme = os.path.join(releases_dir, 'README.md')
938
+ with open(releases_readme, 'w', encoding='utf-8') as f:
939
+ f.write("""# Releases Directory
940
+
941
+ This directory manages release workflows through text files.
942
+
943
+ ## Structure
944
+
945
+ ```
946
+ releases/
947
+ ├── 1.0.0-stage.txt # Development release (stage)
948
+ ├── 1.0.0-rc.txt # Release candidate
949
+ └── 1.0.0-production.txt # Production release
950
+ ```
951
+
952
+ ## Release Files
953
+
954
+ Each file contains patch IDs, one per line:
955
+ ```
956
+ 001-initial-schema
957
+ 002-add-authentication
958
+ 003-user-profiles
959
+ ```
960
+
961
+ ## Workflow
962
+
963
+ 1. **Stage**: Development work
964
+ - `half_orm dev add-to-release <patch-id>`
965
+ - Patches added to X.Y.Z-stage.txt
966
+
967
+ 2. **RC**: Release candidate
968
+ - `half_orm dev promote-to rc`
969
+ - Creates X.Y.Z-rc.txt
970
+ - Deletes patch branches
971
+
972
+ 3. **Production**: Final release
973
+ - `half_orm dev promote-to prod`
974
+ - Creates X.Y.Z-production.txt
975
+ - Apply to production: `half_orm dev deploy-to-prod`
976
+
977
+ See docs/half_orm_dev.md for complete documentation.
978
+ """)
979
+
980
+ def _generate_python_package(self):
981
+ """
982
+ Generate Python package structure from database schema.
983
+
984
+ Uses modules.generate() to create Python classes for database tables.
985
+ Creates hierarchical package structure matching database schemas.
986
+
987
+ Process:
988
+ 1. Call modules.generate(self)
989
+ 2. Generates: <package>/<package>/<schema>/<table>.py
990
+ 3. Creates __init__.py files for each level
991
+ 4. Generates base_test.py and sql_adapter.py
992
+
993
+ Generated Structure:
994
+ my_blog/
995
+ └── my_blog/
996
+ ├── __init__.py
997
+ ├── base_test.py
998
+ ├── sql_adapter.py
999
+ └── public/
1000
+ ├── __init__.py
1001
+ ├── user.py
1002
+ └── post.py
1003
+
1004
+ Examples:
1005
+ _generate_python_package()
1006
+ # Generates complete package structure from database
1007
+ """
1008
+ from half_orm_dev import modules
1009
+
1010
+ # Delegate to existing modules.generate()
1011
+ modules.generate(self)
1012
+
1013
+ def _initialize_git_repository(self):
1014
+ """
1015
+ Initialize Git repository with ho-prod main branch.
1016
+
1017
+ Replaces hop_main branch naming with ho-prod for Git-centric workflow.
1018
+
1019
+ Process:
1020
+ 1. Initialize Git repository via HGit
1021
+ 2. Create initial commit
1022
+ 3. Set main branch to ho-prod
1023
+ 4. Configure remote origin (if available)
1024
+
1025
+ Branch Naming:
1026
+ - Main branch: ho-prod (replaces hop_main)
1027
+ - Patch branches: ho-patch/<patch-name>
1028
+
1029
+ Examples:
1030
+ _initialize_git_repository()
1031
+ # Creates: .git/ with ho-prod branch
1032
+ """
1033
+ # Delegate to existing hgit.HGit.init
1034
+ self.hgit = HGit().init(self.__base_dir, self.__config.git_origin)
1035
+
1036
+ def _generate_template_files(self):
1037
+ """
1038
+ Generate template files for project configuration.
1039
+
1040
+ Creates standard project files:
1041
+ - README.md: Project documentation
1042
+ - .gitignore: Git exclusions
1043
+ - setup.py: Python packaging (current template)
1044
+ - Pipfile: Dependencies (current template)
1045
+
1046
+ Templates read from TEMPLATE_DIRS and formatted with project variables.
1047
+
1048
+ Note: Future enhancement will migrate to pyproject.toml,
1049
+ but keeping current templates for initial implementation.
1050
+
1051
+ Examples:
1052
+ _generate_template_files()
1053
+ # Creates: README.md, .gitignore, setup.py, Pipfile
1054
+ """
1055
+ import half_orm
1056
+ from half_orm_dev.utils import TEMPLATE_DIRS, hop_version
1057
+
1058
+ # Read templates
1059
+ readme_template = utils.read(os.path.join(TEMPLATE_DIRS, 'README'))
1060
+ setup_template = utils.read(os.path.join(TEMPLATE_DIRS, 'setup.py'))
1061
+ git_ignore = utils.read(os.path.join(TEMPLATE_DIRS, '.gitignore'))
1062
+ pipfile_template = utils.read(os.path.join(TEMPLATE_DIRS, 'Pipfile'))
1063
+
1064
+ # Format templates with project variables
1065
+ package_name = self.__config.name
1066
+
1067
+ setup = setup_template.format(
1068
+ dbname=package_name,
1069
+ package_name=package_name,
1070
+ half_orm_version=half_orm.__version__
1071
+ )
1072
+
1073
+ pipfile = pipfile_template.format(
1074
+ half_orm_version=half_orm.__version__,
1075
+ hop_version=hop_version()
1076
+ )
1077
+
1078
+ readme = readme_template.format(
1079
+ hop_version=hop_version(),
1080
+ dbname=package_name,
1081
+ package_name=package_name
1082
+ )
1083
+
1084
+ # Write files
1085
+ utils.write(os.path.join(self.__base_dir, 'setup.py'), setup)
1086
+ utils.write(os.path.join(self.__base_dir, 'Pipfile'), pipfile)
1087
+ utils.write(os.path.join(self.__base_dir, 'README.md'), readme)
1088
+ utils.write(os.path.join(self.__base_dir, '.gitignore'), git_ignore)
1089
+
1090
+ def _dump_initial_schema(self):
1091
+ self.database._generate_schema_sql("0.0.0", Path(f"{self.__base_dir}/model"))
1092
+
1093
+ def _validate_git_origin_url(self, git_origin_url):
1094
+ """
1095
+ Validate Git origin URL format.
1096
+
1097
+ Validates that the provided URL follows valid Git remote URL formats.
1098
+ Supports HTTPS, SSH (git@), and git:// protocols for common Git hosting
1099
+ services (GitHub, GitLab, Bitbucket) and self-hosted Git servers.
1100
+
1101
+ Args:
1102
+ git_origin_url: Git remote origin URL to validate
1103
+
1104
+ Raises:
1105
+ ValueError: If URL is None, empty, or has invalid format
1106
+ UserWarning: If URL contains embedded credentials (discouraged)
1107
+
1108
+ Valid URL formats:
1109
+ - HTTPS: https://git.example.com/user/repo.git
1110
+ - SSH: git@git.example.com:user/repo.git
1111
+ - SSH with port: ssh://git@host:port/path/repo.git
1112
+ - Git protocol: git://git.example.com/user/repo.git
1113
+
1114
+ Examples:
1115
+ # Valid URLs
1116
+ _validate_git_origin_url("https://git.example.com/user/repo.git")
1117
+ _validate_git_origin_url("git@git.example.com:user/repo.git")
1118
+ _validate_git_origin_url("https://git.company.com/team/project.git")
1119
+
1120
+ # Invalid URLs raise ValueError
1121
+ _validate_git_origin_url("not-a-url") # → ValueError
1122
+ _validate_git_origin_url("http://git.example.com/user/repo.git") # → ValueError (HTTP not allowed)
1123
+ _validate_git_origin_url("") # → ValueError
1124
+
1125
+ Notes:
1126
+ - URLs with embedded credentials trigger a warning but are accepted
1127
+ - Leading/trailing whitespace is automatically stripped
1128
+ - .git extension is optional
1129
+ """
1130
+ import re
1131
+ import warnings
1132
+
1133
+ # Type validation
1134
+ if git_origin_url is None:
1135
+ raise ValueError("Git origin URL cannot be None")
1136
+
1137
+ if not isinstance(git_origin_url, str):
1138
+ raise ValueError(
1139
+ f"Git origin URL must be a string, got {type(git_origin_url).__name__}"
1140
+ )
1141
+
1142
+ # Strip whitespace
1143
+ git_origin_url = git_origin_url.strip()
1144
+
1145
+ # Empty check
1146
+ if not git_origin_url:
1147
+ raise ValueError("Git origin URL cannot be empty")
1148
+
1149
+ # Warn about embedded credentials (security issue)
1150
+ if re.search(r'://[^@/]+:[^@/]+@', git_origin_url):
1151
+ warnings.warn(
1152
+ "Git origin URL contains embedded credentials. "
1153
+ "Consider using SSH keys or credential helpers instead.",
1154
+ UserWarning
1155
+ )
1156
+
1157
+ # Define valid URL patterns
1158
+ patterns = [
1159
+ # HTTPS: https://github.com/user/repo.git or https://user:pass@github.com/user/repo.git
1160
+ r'^https://(?:[^@/]+@)?[a-zA-Z0-9._-]+(?:\.[a-zA-Z]{2,})+(?::[0-9]+)?/.+$',
1161
+
1162
+ # SSH (git@): git@git.example.com:user/repo.git or git@git.example.com:user/repo
1163
+ r'^git@[a-zA-Z0-9._-]+(?:\.[a-zA-Z]{2,})+:.+$',
1164
+
1165
+ # SSH with explicit protocol and port: ssh://git@host:port/path/repo.git
1166
+ r'^ssh://git@[a-zA-Z0-9._-]+(?:\.[a-zA-Z]{2,})+(?::[0-9]+)?/.+$',
1167
+
1168
+ # Git protocol: git://git.example.com/user/repo.git
1169
+ r'^git://[a-zA-Z0-9._-]+(?:\.[a-zA-Z]{2,})+(?::[0-9]+)?/.+$',
1170
+
1171
+ # File protocol: file:///path/to/repo
1172
+ r'^file:///[a-zA-Z0-9._/-]+|^/[a-zA-Z0-9._/-]+'
1173
+ ]
1174
+
1175
+ # Check if URL matches any valid pattern
1176
+ is_valid = any(re.match(pattern, git_origin_url) for pattern in patterns)
1177
+
1178
+ if not is_valid:
1179
+ raise ValueError(
1180
+ f"Invalid Git origin URL format: '{git_origin_url}'\n"
1181
+ "Valid formats:\n"
1182
+ " - HTTPS: https://git.example.com/user/repo.git\n"
1183
+ " - SSH: git@git.example.com:user/repo.git\n"
1184
+ " - Git protocol: git://git.example.com/user/repo.git"
1185
+ )
1186
+
1187
+ # Additional validation: ensure URL has a repository path
1188
+ # Extract path component based on URL type
1189
+ if git_origin_url.startswith('git@'):
1190
+ # SSH format: git@host:path
1191
+ parts = git_origin_url.split(':', 1)
1192
+ if len(parts) < 2 or not parts[1].strip():
1193
+ raise ValueError(
1194
+ "Git origin URL must include repository path. "
1195
+ f"Got: '{git_origin_url}'"
1196
+ )
1197
+ elif git_origin_url.startswith(('https://', 'git://', 'ssh://')):
1198
+ # Protocol-based format: check for path after host
1199
+ # Split on first / after protocol://host
1200
+ protocol_end = git_origin_url.index('://') + 3
1201
+ remaining = git_origin_url[protocol_end:]
1202
+
1203
+ # Find first / (path separator)
1204
+ if '/' not in remaining:
1205
+ raise ValueError(
1206
+ "Git origin URL must include repository path. "
1207
+ f"Got: '{git_origin_url}'"
1208
+ )
1209
+
1210
+ path = remaining.split('/', 1)[1]
1211
+ if not path.strip():
1212
+ raise ValueError(
1213
+ "Git origin URL must include repository path. "
1214
+ f"Got: '{git_origin_url}'"
1215
+ )
1216
+
1217
+ # Validation passed
1218
+ return True
1219
+
1220
+ def restore_database_from_schema(self) -> None:
1221
+ """
1222
+ Restore database from model/schema.sql and model/metadata-X.Y.Z.sql.
1223
+
1224
+ Restores database to clean production state by dropping and recreating
1225
+ database, then loading schema structure and half_orm_meta data. This provides
1226
+ a clean baseline before applying patch files during patch development.
1227
+
1228
+ Process:
1229
+ 1. Verify model/schema.sql exists (file or symlink)
1230
+ 2. Disconnect halfORM Model from database
1231
+ 3. Drop existing database using dropdb command
1232
+ 4. Create fresh empty database using createdb command
1233
+ 5. Load schema structure from model/schema.sql using psql -f
1234
+ 5b. Load half_orm_meta data from model/metadata-X.Y.Z.sql using psql -f (if exists)
1235
+ 6. Reconnect halfORM Model to restored database
1236
+
1237
+ The method uses Database.execute_pg_command() for all PostgreSQL
1238
+ operations (dropdb, createdb, psql) with connection parameters from
1239
+ repository configuration.
1240
+
1241
+ File Resolution:
1242
+ - Accepts model/schema.sql as regular file or symlink
1243
+ - Symlink typically points to versioned schema-X.Y.Z.sql file
1244
+ - Follows symlink automatically during psql execution
1245
+ - Deduces metadata file version from schema.sql symlink target
1246
+ - If metadata-X.Y.Z.sql doesn't exist, continues without error (backward compatibility)
1247
+
1248
+ Error Handling:
1249
+ - Raises RepoError if model/schema.sql not found
1250
+ - Raises RepoError if dropdb fails
1251
+ - Raises RepoError if createdb fails
1252
+ - Raises RepoError if psql schema load fails
1253
+ - Raises RepoError if psql metadata load fails (when file exists)
1254
+ - Database state rolled back on any failure
1255
+
1256
+ Usage Context:
1257
+ - Called by apply-patch workflow (Step 1: Database Restoration)
1258
+ - Ensures clean state before applying patch SQL files
1259
+ - Part of isolated patch testing strategy
1260
+
1261
+ Returns:
1262
+ None
1263
+
1264
+ Raises:
1265
+ RepoError: If schema file not found
1266
+ RepoError: If database restoration fails at any step
1267
+
1268
+ Examples:
1269
+ # Restore database from model/schema.sql before applying patch
1270
+ repo.restore_database_from_schema()
1271
+ # Database now contains clean production schema + half_orm_meta data
1272
+
1273
+ # Typical apply-patch workflow
1274
+ repo.restore_database_from_schema() # Step 1: Clean state + metadata
1275
+ patch_mgr.apply_patch_files("456-user-auth", repo.model) # Step 2: Apply patch
1276
+
1277
+ # With versioned schema and metadata
1278
+ # If schema.sql → schema-1.2.3.sql exists
1279
+ # Then metadata-1.2.3.sql is loaded automatically (if it exists)
1280
+
1281
+ # Error handling
1282
+ try:
1283
+ repo.restore_database_from_schema()
1284
+ except RepoError as e:
1285
+ print(f"Database restoration failed: {e}")
1286
+ # Handle error: check schema.sql exists, verify permissions
1287
+
1288
+ Notes:
1289
+ - Silences psql output using stdout=subprocess.DEVNULL
1290
+ - Uses Model.ping() for reconnection after restoration
1291
+ - Supports both schema.sql file and schema.sql -> schema-X.Y.Z.sql symlink
1292
+ - Metadata file is optional (backward compatibility with older schemas)
1293
+ - All PostgreSQL commands use repository connection configuration
1294
+ - Version deduction: schema.sql → schema-1.2.3.sql ⇒ metadata-1.2.3.sql
1295
+ """
1296
+ # 1. Verify model/schema.sql exists
1297
+ schema_path = Path(self.base_dir) / "model" / "schema.sql"
1298
+
1299
+ if not schema_path.exists():
1300
+ raise RepoError(
1301
+ f"Schema file not found: {schema_path}. "
1302
+ "Cannot restore database without model/schema.sql."
1303
+ )
1304
+
1305
+ try:
1306
+ # 2. Disconnect Model from database
1307
+ self.model.disconnect()
1308
+ pg_version = self.database.get_postgres_version()
1309
+ drop_cmd = ['dropdb', self.name]
1310
+ if pg_version > (13, 0):
1311
+ drop_cmd.append('--force')
1312
+
1313
+ # 3. Drop existing database
1314
+ try:
1315
+ self.database.execute_pg_command(*drop_cmd)
1316
+ except Exception as e:
1317
+ raise RepoError(f"Failed to drop database: {e}") from e
1318
+
1319
+ # 4. Create fresh empty database
1320
+ try:
1321
+ self.database.execute_pg_command('createdb', self.name)
1322
+ except Exception as e:
1323
+ raise RepoError(f"Failed to create database: {e}") from e
1324
+
1325
+ # 5. Load schema from model/schema.sql
1326
+ try:
1327
+ self.database.execute_pg_command(
1328
+ 'psql', '-d', self.name, '-f', str(schema_path)
1329
+ )
1330
+ except Exception as e:
1331
+ raise RepoError(f"Failed to load schema from {schema_path.name}: {e}") from e
1332
+
1333
+ # 5b. Load metadata from model/metadata-X.Y.Z.sql (if exists)
1334
+ metadata_path = self._deduce_metadata_path(schema_path)
1335
+
1336
+ if metadata_path and metadata_path.exists():
1337
+ try:
1338
+ self.database.execute_pg_command(
1339
+ 'psql', '-d', self.name, '-f', str(metadata_path)
1340
+ )
1341
+ # Optional: Log success (can be removed if too verbose)
1342
+ # print(f"✓ Loaded metadata from {metadata_path.name}")
1343
+ except Exception as e:
1344
+ raise RepoError(
1345
+ f"Failed to load metadata from {metadata_path.name}: {e}"
1346
+ ) from e
1347
+ # else: metadata file doesn't exist, continue without error (backward compatibility)
1348
+
1349
+ # 6. Reconnect Model to restored database
1350
+ self.model.ping()
1351
+
1352
+ except RepoError:
1353
+ # Re-raise RepoError as-is
1354
+ raise
1355
+ except Exception as e:
1356
+ # Catch any unexpected errors
1357
+ raise RepoError(f"Database restoration failed: {e}") from e
1358
+
1359
+ def _deduce_metadata_path(self, schema_path: Path) -> Path | None:
1360
+ """
1361
+ Deduce metadata file path from schema.sql symlink target.
1362
+
1363
+ If schema.sql is a symlink pointing to schema-X.Y.Z.sql,
1364
+ returns Path to metadata-X.Y.Z.sql in the same directory.
1365
+
1366
+ Args:
1367
+ schema_path: Path to model/schema.sql (may be file or symlink)
1368
+
1369
+ Returns:
1370
+ Path to metadata-X.Y.Z.sql if version can be deduced, None otherwise
1371
+
1372
+ Examples:
1373
+ # schema.sql → schema-1.2.3.sql
1374
+ metadata_path = _deduce_metadata_path(Path("model/schema.sql"))
1375
+ # Returns: Path("model/metadata-1.2.3.sql")
1376
+
1377
+ # schema.sql is regular file (not symlink)
1378
+ metadata_path = _deduce_metadata_path(Path("model/schema.sql"))
1379
+ # Returns: None
1380
+ """
1381
+ import re
1382
+
1383
+ # Check if schema.sql is a symlink
1384
+ if not schema_path.is_symlink():
1385
+ return None
1386
+
1387
+ # Read symlink target (e.g., "schema-1.2.3.sql")
1388
+ try:
1389
+ target = Path(os.readlink(schema_path))
1390
+ except OSError:
1391
+ return None
1392
+
1393
+ # Extract version from target filename
1394
+ match = re.match(r'schema-(\d+\.\d+\.\d+)\.sql$', target.name)
1395
+ if not match:
1396
+ return None
1397
+
1398
+ version = match.group(1)
1399
+
1400
+ # Construct metadata file path
1401
+ metadata_path = schema_path.parent / f"metadata-{version}.sql"
1402
+
1403
+ return metadata_path
1404
+
1405
+ @classmethod
1406
+ def clone_repo(cls,
1407
+ git_origin: str,
1408
+ database_name: Optional[str] = None,
1409
+ dest_dir: Optional[str] = None,
1410
+ production: bool = False,
1411
+ create_db: bool = True) -> None:
1412
+ """
1413
+ Clone existing half_orm_dev project and setup local database.
1414
+
1415
+ This method clones a Git repository, checks out the ho-prod branch,
1416
+ creates/configures the local database, and restores the schema to
1417
+ the production version.
1418
+
1419
+ Args:
1420
+ git_origin: Git repository URL (HTTPS, SSH, file://)
1421
+ database_name: Local database name (default: prompt or package_name)
1422
+ dest_dir: Clone destination (default: infer from git_origin)
1423
+ production: Production mode flag (passed to Database.setup_database)
1424
+ create_db: Create database if missing (default: True)
1425
+
1426
+ Raises:
1427
+ RepoError: If clone fails, checkout fails, or database setup fails
1428
+ FileExistsError: If destination directory already exists
1429
+
1430
+ Workflow:
1431
+ 1. Determine destination directory from git_origin or dest_dir
1432
+ 2. Verify destination directory doesn't exist
1433
+ 3. Clone repository using git clone
1434
+ 4. Checkout ho-prod branch
1435
+ 5. Create .hop/alt_config if custom database_name provided
1436
+ 6. Setup database (create + metadata if create_db=True)
1437
+ 7. Restore database from model/schema.sql to production version
1438
+
1439
+ Examples:
1440
+ # Interactive with prompts for connection params
1441
+ Repo.clone_repo("https://github.com/user/project.git")
1442
+
1443
+ # With custom database name (creates .hop/alt_config)
1444
+ Repo.clone_repo(
1445
+ "https://github.com/user/project.git",
1446
+ database_name="my_local_dev_db"
1447
+ )
1448
+
1449
+ # Production mode
1450
+ Repo.clone_repo(
1451
+ "https://github.com/user/project.git",
1452
+ production=True,
1453
+ create_db=False # DB must already exist
1454
+ )
1455
+
1456
+ Notes:
1457
+ - Changes current working directory to cloned project
1458
+ - Empty connection_options {} triggers interactive prompts
1459
+ - restore_database_from_schema() loads production schema version
1460
+ - Returns None (command completes, no return value needed)
1461
+ """
1462
+ # Step 1: Determine destination directory
1463
+ if dest_dir:
1464
+ dest_name = dest_dir
1465
+ else:
1466
+ # Extract project name from git_origin, remove .git extension
1467
+ dest_name = git_origin.rstrip('/').split('/')[-1]
1468
+ if dest_name.endswith('.git'):
1469
+ dest_name = dest_name[:-4]
1470
+
1471
+ dest_path = Path.cwd() / dest_name
1472
+
1473
+ # Step 2: Verify destination doesn't exist
1474
+ if dest_path.exists():
1475
+ raise FileExistsError(
1476
+ f"Directory '{dest_name}' already exists in current directory. "
1477
+ f"Choose a different destination or remove the existing directory."
1478
+ )
1479
+
1480
+ # Step 3: Clone repository
1481
+ try:
1482
+ result = subprocess.run(
1483
+ ["git", "clone", git_origin, str(dest_path)],
1484
+ capture_output=True,
1485
+ text=True,
1486
+ check=True,
1487
+ timeout=300 # 5 minutes timeout for clone
1488
+ )
1489
+ except subprocess.CalledProcessError as e:
1490
+ raise RepoError(
1491
+ f"Git clone failed: {e.stderr.strip()}"
1492
+ ) from e
1493
+ except subprocess.TimeoutExpired:
1494
+ raise RepoError(
1495
+ f"Git clone timed out after 5 minutes. "
1496
+ f"Check network connection or repository size."
1497
+ )
1498
+
1499
+ # Step 4: Change to cloned directory (required for Repo() singleton)
1500
+ os.chdir(dest_path)
1501
+
1502
+ # Step 5: Checkout ho-prod branch
1503
+ try:
1504
+ result = subprocess.run(
1505
+ ["git", "checkout", "ho-prod"],
1506
+ capture_output=True,
1507
+ text=True,
1508
+ check=True,
1509
+ cwd=dest_path
1510
+ )
1511
+ except subprocess.CalledProcessError as e:
1512
+ raise RepoError(
1513
+ f"Git checkout ho-prod failed: {e.stderr.strip()}. "
1514
+ f"Ensure 'ho-prod' branch exists in the repository."
1515
+ ) from e
1516
+
1517
+ # Step 6: Create .hop/alt_config if custom database name provided
1518
+ if database_name:
1519
+ alt_config_path = dest_path / '.hop' / 'alt_config'
1520
+ try:
1521
+ with open(alt_config_path, 'w', encoding='utf-8') as f:
1522
+ f.write(database_name)
1523
+ except (OSError, IOError) as e:
1524
+ raise RepoError(
1525
+ f"Failed to create .hop/alt_config: {e}"
1526
+ ) from e
1527
+
1528
+ # Step 7: Load config and setup database
1529
+ from half_orm_dev.repo import Config # Import here to avoid circular imports
1530
+ from half_orm_dev.database import Database
1531
+
1532
+ config = Config(dest_path)
1533
+
1534
+ connection_options = {
1535
+ 'host': None,
1536
+ 'port': None,
1537
+ 'user': None,
1538
+ 'password': None,
1539
+ 'production': production
1540
+ }
1541
+
1542
+ try:
1543
+ Database.setup_database(
1544
+ database_name=config.name,
1545
+ connection_options=connection_options,
1546
+ create_db=create_db,
1547
+ add_metadata=create_db # Auto-install metadata for new DB
1548
+ )
1549
+ except Exception as e:
1550
+ raise RepoError(
1551
+ f"Database setup failed: {e}"
1552
+ ) from e
1553
+
1554
+ # Step 8: Create Repo instance and restore production schema
1555
+ repo = cls()
1556
+
1557
+ try:
1558
+ repo.restore_database_from_schema()
1559
+ except RepoError as e:
1560
+ raise RepoError(
1561
+ f"Failed to restore database from schema: {e}"
1562
+ ) from e