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.
- half_orm_dev/__init__.py +1 -0
- half_orm_dev/cli/__init__.py +9 -0
- half_orm_dev/cli/commands/__init__.py +56 -0
- half_orm_dev/cli/commands/apply.py +13 -0
- half_orm_dev/cli/commands/clone.py +102 -0
- half_orm_dev/cli/commands/init.py +331 -0
- half_orm_dev/cli/commands/new.py +15 -0
- half_orm_dev/cli/commands/patch.py +317 -0
- half_orm_dev/cli/commands/prepare.py +21 -0
- half_orm_dev/cli/commands/prepare_release.py +119 -0
- half_orm_dev/cli/commands/promote_to.py +127 -0
- half_orm_dev/cli/commands/release.py +344 -0
- half_orm_dev/cli/commands/restore.py +14 -0
- half_orm_dev/cli/commands/sync.py +13 -0
- half_orm_dev/cli/commands/todo.py +73 -0
- half_orm_dev/cli/commands/undo.py +17 -0
- half_orm_dev/cli/commands/update.py +73 -0
- half_orm_dev/cli/commands/upgrade.py +191 -0
- half_orm_dev/cli/main.py +103 -0
- half_orm_dev/cli_extension.py +38 -0
- half_orm_dev/database.py +1389 -0
- half_orm_dev/hgit.py +1025 -0
- half_orm_dev/hop.py +167 -0
- half_orm_dev/manifest.py +43 -0
- half_orm_dev/modules.py +456 -0
- half_orm_dev/patch.py +281 -0
- half_orm_dev/patch_manager.py +1694 -0
- half_orm_dev/patch_validator.py +335 -0
- half_orm_dev/patches/0/1/0/00_half_orm_meta.database.sql +34 -0
- half_orm_dev/patches/0/1/0/01_alter_half_orm_meta.hop_release.sql +2 -0
- half_orm_dev/patches/0/1/0/02_half_orm_meta.view.hop_penultimate_release.sql +3 -0
- half_orm_dev/patches/log +2 -0
- half_orm_dev/patches/sql/half_orm_meta.sql +208 -0
- half_orm_dev/release_manager.py +2841 -0
- half_orm_dev/repo.py +1562 -0
- half_orm_dev/templates/.gitignore +15 -0
- half_orm_dev/templates/MANIFEST.in +1 -0
- half_orm_dev/templates/Pipfile +13 -0
- half_orm_dev/templates/README +25 -0
- half_orm_dev/templates/conftest_template +42 -0
- half_orm_dev/templates/init_module_template +10 -0
- half_orm_dev/templates/module_template_1 +12 -0
- half_orm_dev/templates/module_template_2 +6 -0
- half_orm_dev/templates/module_template_3 +3 -0
- half_orm_dev/templates/relation_test +23 -0
- half_orm_dev/templates/setup.py +81 -0
- half_orm_dev/templates/sql_adapter +9 -0
- half_orm_dev/templates/warning +12 -0
- half_orm_dev/utils.py +49 -0
- half_orm_dev/version.txt +1 -0
- half_orm_dev-0.16.0a9.dist-info/METADATA +935 -0
- half_orm_dev-0.16.0a9.dist-info/RECORD +58 -0
- half_orm_dev-0.16.0a9.dist-info/WHEEL +5 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/AUTHORS +3 -0
- half_orm_dev-0.16.0a9.dist-info/licenses/LICENSE +14 -0
- half_orm_dev-0.16.0a9.dist-info/top_level.txt +2 -0
- tests/__init__.py +0 -0
- 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
|