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
@@ -0,0 +1,1389 @@
1
+ """Provides the Database class
2
+ """
3
+
4
+ import os
5
+ import re
6
+ import subprocess
7
+ import sys
8
+
9
+ from pathlib import Path
10
+ from psycopg2 import OperationalError
11
+ from half_orm.model import Model
12
+ from half_orm.model_errors import UnknownRelation
13
+ from half_orm import utils
14
+ from .utils import HOP_PATH
15
+
16
+ class DatabaseError(Exception):
17
+ pass
18
+
19
+ class DockerNotAvailableError(Exception):
20
+ """
21
+ Raised when Docker is not installed or not running.
22
+
23
+ This exception is raised when attempting to use Docker for PostgreSQL
24
+ operations but Docker is not available on the system.
25
+
26
+ Examples:
27
+ Docker not installed:
28
+ DockerNotAvailableError("Docker is not installed on this system")
29
+
30
+ Docker daemon not running:
31
+ DockerNotAvailableError("Docker daemon is not running")
32
+ """
33
+ pass
34
+
35
+
36
+ class DockerContainerNotFoundError(Exception):
37
+ """
38
+ Raised when specified Docker container does not exist.
39
+
40
+ This exception is raised when a database configuration references a
41
+ Docker container that does not exist on the system.
42
+
43
+ Examples:
44
+ Container not found:
45
+ DockerContainerNotFoundError(
46
+ "Docker container 'my_postgres' not found. "
47
+ "Run: docker ps -a to list containers"
48
+ )
49
+ """
50
+ pass
51
+
52
+
53
+ class DockerContainerNotRunningError(Exception):
54
+ """
55
+ Raised when Docker container exists but is not running.
56
+
57
+ This exception is raised when a Docker container exists but is in a
58
+ stopped state and cannot execute PostgreSQL commands.
59
+
60
+ Examples:
61
+ Container stopped:
62
+ DockerContainerNotRunningError(
63
+ "Docker container 'my_postgres' exists but is not running. "
64
+ "Run: docker start my_postgres"
65
+ )
66
+ """
67
+ pass
68
+
69
+
70
+ class Database:
71
+ """Reads and writes the halfORM connection file
72
+ """
73
+
74
+ def __init__(self, repo, get_release=True):
75
+ self.__repo = repo
76
+ self.__model = None
77
+ self.__last_release = None
78
+ if self.__repo.name:
79
+ try:
80
+ self.__model = Model(self.__repo.name)
81
+ self.__init(self.__repo.name, get_release)
82
+ except OperationalError as err:
83
+ if not self.__repo.new:
84
+ utils.error(err, 1)
85
+
86
+ def __call__(self, name):
87
+ return self.__class__(self.__repo)
88
+
89
+ def __init(self, name, get_release=True):
90
+ self.__name = name
91
+ if get_release and self.__repo.devel:
92
+ self.__last_release = self.last_release
93
+
94
+ @property
95
+ def last_release(self):
96
+ "Returns the last release"
97
+ self.__last_release = next(
98
+ self.__model.get_relation_class('half_orm_meta.view.hop_last_release')().ho_select())
99
+ return self.__last_release
100
+
101
+ @property
102
+ def last_release_s(self):
103
+ "Returns the string representation of the last release X.Y.Z"
104
+ return '{major}.{minor}.{patch}'.format(**self.last_release)
105
+
106
+ @property
107
+ def model(self):
108
+ "The model (halfORM) of the database"
109
+ return self.__model
110
+
111
+ @property
112
+ def state(self):
113
+ "The state (str) of the database"
114
+ res = ['[Database]']
115
+ res.append(f'- name: {self.__name}')
116
+ res.append(f"- user: {self._get_connection_params()['user']}")
117
+ res.append(f"- host: {self._get_connection_params()['host']}")
118
+ res.append(f"- port: {self._get_connection_params()['port']}")
119
+ prod = utils.Color.blue(
120
+ True) if self._get_connection_params()['production'] else False
121
+ res.append(f'- production: {prod}')
122
+ if self.__repo.devel:
123
+ res.append(f'- last release: {self.last_release_s}')
124
+ return '\n'.join(res)
125
+
126
+ @property
127
+ def production(self):
128
+ "Returns whether the database is tagged in production or not."
129
+ return self._get_connection_params()['production']
130
+
131
+ def init(self, name):
132
+ """Called when creating a new repo.
133
+ Tries to read the connection parameters and then connect to
134
+ the database.
135
+ """
136
+ try:
137
+ self.__init(name, get_release=False)
138
+ except FileNotFoundError:
139
+ pass
140
+ return self.__init_db()
141
+
142
+ def __init_db(self):
143
+ """Tries to connect to the database. If unsuccessful, creates the
144
+ database end initializes it with half_orm_meta.
145
+ """
146
+ try:
147
+ self.__model = Model(self.__name)
148
+ except OperationalError:
149
+ sys.stderr.write(f"The database '{self.__name}' does not exist.\n")
150
+ create = input('Do you want to create it (Y/n): ') or "y"
151
+ if create.upper() == 'Y':
152
+ self.execute_pg_command('createdb')
153
+ else:
154
+ utils.error(
155
+ f'Aborting! Please remove {self.__name} directory.\n', exit_code=1)
156
+ self.__model = Model(self.__name)
157
+ if self.__repo.devel:
158
+ try:
159
+ self.__model.get_relation_class('half_orm_meta.hop_release')
160
+ except UnknownRelation:
161
+ hop_init_sql_file = os.path.join(
162
+ HOP_PATH, 'patches', 'sql', 'half_orm_meta.sql')
163
+ self.execute_pg_command(
164
+ 'psql', '-f', hop_init_sql_file, stdout=subprocess.DEVNULL)
165
+ self.__model.reconnect(reload=True)
166
+ self.__last_release = self.register_release(
167
+ major=0, minor=0, patch=0, changelog='Initial release')
168
+ return self(self.__name)
169
+
170
+ def execute_pg_command(self, *command_args):
171
+ """Execute PostgreSQL command with instance's connection parameters."""
172
+ return self._execute_pg_command(
173
+ self.__name,
174
+ self._get_connection_params(),
175
+ *command_args
176
+ )
177
+
178
+ def register_release(self, major, minor, patch, changelog=None):
179
+ "Register the release into half_orm_meta.hop_release"
180
+ return self.__model.get_relation_class('half_orm_meta.hop_release')(
181
+ major=major, minor=minor, patch=patch, changelog=changelog
182
+ ).ho_insert()
183
+
184
+ def _generate_schema_sql(self, version: str, model_dir: Path) -> Path:
185
+ """
186
+ Generate versioned schema SQL dump.
187
+
188
+ Creates model/schema-{version}.sql with current database structure
189
+ using pg_dump --schema-only. Creates model/metadata-{version}.sql
190
+ with half_orm_meta data using pg_dump --data-only.
191
+ Updates model/schema.sql symlink to point to the new version.
192
+
193
+ This method is used by:
194
+ - init-project: Generate initial schema-0.0.0.sql after database setup
195
+ - deploy-to-prod: Generate schema-X.Y.Z.sql after production deployment
196
+
197
+ Version History Strategy:
198
+ - Only production versions are saved (X.Y.Z)
199
+ - Stage and RC versions are NOT saved
200
+ - Hotfixes overwrite the base version (1.3.4-hotfix1 overwrites 1.3.4)
201
+ - Git history preserves old versions if needed
202
+
203
+ Args:
204
+ version: Version string (e.g., "0.0.0", "1.3.4", "2.0.0")
205
+ model_dir: Path to model/ directory where schema files are stored
206
+
207
+ Returns:
208
+ Path to generated schema file (model/schema-{version}.sql)
209
+
210
+ Raises:
211
+ DatabaseError: If pg_dump command fails
212
+ FileNotFoundError: If model_dir does not exist
213
+ PermissionError: If cannot write to model_dir or create symlink
214
+ ValueError: If version format is invalid
215
+
216
+ Examples:
217
+ # During init-project - create initial schema
218
+ from pathlib import Path
219
+ model_dir = Path("/project/model")
220
+ schema_path = database._generate_schema_sql("0.0.0", model_dir)
221
+ # → Creates model/schema-0.0.0.sql
222
+ # → Creates model/metadata-0.0.0.sql
223
+ # → Creates symlink model/schema.sql → schema-0.0.0.sql
224
+ # → Returns Path("/project/model/schema-0.0.0.sql")
225
+
226
+ # During deploy-to-prod - save production schema
227
+ schema_path = database._generate_schema_sql("1.3.4", model_dir)
228
+ # → Creates model/schema-1.3.4.sql
229
+ # → Creates model/metadata-1.3.4.sql
230
+ # → Updates symlink model/schema.sql → schema-1.3.4.sql
231
+
232
+ File Structure Created:
233
+ model/
234
+ ├── schema.sql # Symlink to current version
235
+ ├── schema-0.0.0.sql # Initial version (structure)
236
+ ├── metadata-0.0.0.sql # Initial version (half_orm_meta data)
237
+ ├── schema-1.0.0.sql # Production version (structure)
238
+ ├── metadata-1.0.0.sql # Production version (half_orm_meta data)
239
+ ├── schema-1.3.4.sql # Latest production version (current)
240
+ ├── metadata-1.3.4.sql # Latest production version (current)
241
+ └── ...
242
+
243
+ Notes:
244
+ - Uses pg_dump --schema-only for structure (no data)
245
+ - Uses pg_dump --data-only for metadata (only half_orm_meta tables)
246
+ - Symlink is relative (schema.sql → schema-X.Y.Z.sql)
247
+ - No symlink for metadata (version deduced from schema.sql)
248
+ - Existing symlink is replaced atomically
249
+ - Version format should be X.Y.Z (semantic versioning)
250
+ """
251
+ # Validate version format (X.Y.Z where X, Y, Z are integers)
252
+ version_pattern = r'^\d+\.\d+\.\d+$'
253
+ if not re.match(version_pattern, version):
254
+ raise ValueError(
255
+ f"Invalid version format: '{version}'. "
256
+ f"Expected semantic versioning (X.Y.Z, e.g., '1.3.4')"
257
+ )
258
+
259
+ # Validate model_dir exists
260
+ if not model_dir.exists():
261
+ raise FileNotFoundError(
262
+ f"Model directory does not exist: {model_dir}"
263
+ )
264
+
265
+ if not model_dir.is_dir():
266
+ raise FileNotFoundError(
267
+ f"Model path exists but is not a directory: {model_dir}"
268
+ )
269
+
270
+ # Construct versioned schema file path
271
+ schema_file = model_dir / f"schema-{version}.sql"
272
+
273
+ # Generate schema dump using pg_dump
274
+ try:
275
+ self.execute_pg_command(
276
+ 'pg_dump',
277
+ self.__name,
278
+ '--schema-only',
279
+ '-f',
280
+ str(schema_file)
281
+ )
282
+ except Exception as e:
283
+ raise Exception(f"Failed to generate schema SQL: {e}") from e
284
+
285
+ # Generate metadata dump (half_orm_meta data only)
286
+ metadata_file = model_dir / f"metadata-{version}.sql"
287
+
288
+ try:
289
+ self.execute_pg_command(
290
+ 'pg_dump',
291
+ self.__name,
292
+ '--data-only',
293
+ '--table=half_orm_meta.database',
294
+ '--table=half_orm_meta.hop_release',
295
+ '--table=half_orm_meta.hop_release_issue',
296
+ '-f',
297
+ str(metadata_file)
298
+ )
299
+ except Exception as e:
300
+ raise Exception(f"Failed to generate metadata SQL: {e}") from e
301
+
302
+ # Create or update symlink
303
+ symlink_path = model_dir / "schema.sql"
304
+ symlink_target = f"schema-{version}.sql" # Relative path
305
+
306
+ try:
307
+ # Remove existing symlink if it exists
308
+ if symlink_path.exists() or symlink_path.is_symlink():
309
+ symlink_path.unlink()
310
+
311
+ # Create new symlink (relative)
312
+ symlink_path.symlink_to(symlink_target)
313
+
314
+ except PermissionError as e:
315
+ raise PermissionError(
316
+ f"Permission denied: cannot create symlink in {model_dir}"
317
+ ) from e
318
+ except OSError as e:
319
+ raise OSError(
320
+ f"Failed to create symlink {symlink_path} → {symlink_target}: {e}"
321
+ ) from e
322
+
323
+ return schema_file
324
+
325
+ @classmethod
326
+ def _save_configuration(cls, database_name, connection_params):
327
+ """
328
+ Save connection parameters to configuration file.
329
+
330
+ Args:
331
+ database_name (str): PostgreSQL database name
332
+ connection_params (dict): Complete connection parameters
333
+
334
+ Returns:
335
+ str: Path to saved configuration file
336
+
337
+ Raises:
338
+ OSError: If configuration directory is not writable
339
+ """
340
+ from configparser import ConfigParser
341
+ from half_orm.model import CONF_DIR
342
+
343
+ # Ensure configuration directory exists and is writable
344
+ if not os.path.exists(CONF_DIR):
345
+ os.makedirs(CONF_DIR, exist_ok=True)
346
+
347
+ if not os.access(CONF_DIR, os.W_OK):
348
+ raise OSError(f"Configuration directory {CONF_DIR} is not writable")
349
+
350
+ # Create configuration file path
351
+ config_file = os.path.join(CONF_DIR, database_name)
352
+
353
+ # Create and populate configuration
354
+ config = ConfigParser()
355
+ config.add_section('database')
356
+ config.set('database', 'name', database_name)
357
+ config.set('database', 'user', connection_params['user'])
358
+ config.set('database', 'password', connection_params['password'] or '')
359
+ config.set('database', 'host', connection_params['host'])
360
+ config.set('database', 'port', str(connection_params['port']))
361
+ config.set('database', 'production', str(connection_params['production']))
362
+ config.set('database', 'docker_container', connection_params.get('docker_container', ''))
363
+
364
+ # Write configuration file
365
+ with open(config_file, 'w') as f:
366
+ config.write(f)
367
+
368
+ return config_file
369
+
370
+ @classmethod
371
+ def _check_docker_available(cls) -> bool:
372
+ """
373
+ Check if Docker is available on the system.
374
+
375
+ Verifies that Docker is installed and the Docker daemon is running
376
+ by executing 'docker --version'.
377
+
378
+ Returns:
379
+ bool: True if Docker is available, False otherwise
380
+
381
+ Examples:
382
+ >>> Database._check_docker_available()
383
+ True # Docker is installed and running
384
+
385
+ >>> Database._check_docker_available()
386
+ False # Docker not installed or daemon not running
387
+ """
388
+ try:
389
+ result = subprocess.run(
390
+ ['docker', '--version'],
391
+ capture_output=True,
392
+ text=True,
393
+ check=True,
394
+ timeout=5
395
+ )
396
+ return result.returncode == 0
397
+ except (subprocess.CalledProcessError, FileNotFoundError, subprocess.TimeoutExpired):
398
+ return False
399
+
400
+ @classmethod
401
+ def _check_docker_container_exists(cls, container_name: str) -> bool:
402
+ """
403
+ Check if a Docker container exists (running or stopped).
404
+
405
+ Uses 'docker inspect' to verify container existence. This checks
406
+ for containers in any state (running, stopped, paused, etc.).
407
+
408
+ Args:
409
+ container_name (str): Name or ID of the Docker container
410
+
411
+ Returns:
412
+ bool: True if container exists, False otherwise
413
+
414
+ Examples:
415
+ >>> Database._check_docker_container_exists('my_postgres')
416
+ True # Container exists
417
+
418
+ >>> Database._check_docker_container_exists('nonexistent')
419
+ False # Container does not exist
420
+ """
421
+ try:
422
+ result = subprocess.run(
423
+ ['docker', 'inspect', container_name],
424
+ capture_output=True,
425
+ text=True,
426
+ check=True,
427
+ timeout=5
428
+ )
429
+ return result.returncode == 0
430
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
431
+ return False
432
+
433
+ @classmethod
434
+ def _check_docker_container_running(cls, container_name: str) -> bool:
435
+ """
436
+ Check if a Docker container is currently running.
437
+
438
+ Uses 'docker inspect' to check the container's running state.
439
+ Returns False if container doesn't exist or is stopped.
440
+
441
+ Args:
442
+ container_name (str): Name or ID of the Docker container
443
+
444
+ Returns:
445
+ bool: True if container is running, False otherwise
446
+
447
+ Examples:
448
+ >>> Database._check_docker_container_running('my_postgres')
449
+ True # Container is running
450
+
451
+ >>> Database._check_docker_container_running('stopped_container')
452
+ False # Container exists but is stopped
453
+ """
454
+ try:
455
+ result = subprocess.run(
456
+ ['docker', 'inspect', '-f', '{{.State.Running}}', container_name],
457
+ capture_output=True,
458
+ text=True,
459
+ check=True,
460
+ timeout=5
461
+ )
462
+ # Docker inspect returns "true" or "false" as string
463
+ return result.stdout.strip().lower() == 'true'
464
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
465
+ return False
466
+
467
+ @classmethod
468
+ def _get_docker_container_info(cls, container_name: str) -> dict:
469
+ """
470
+ Get detailed information about a Docker container.
471
+
472
+ Retrieves container status, ID, and other relevant information
473
+ using 'docker inspect'. Useful for debugging and error messages.
474
+
475
+ Args:
476
+ container_name (str): Name or ID of the Docker container
477
+
478
+ Returns:
479
+ dict: Container information with keys:
480
+ - exists (bool): Whether container exists
481
+ - running (bool): Whether container is running
482
+ - status (str): Container status (running, exited, etc.)
483
+ - id (str): Container ID (first 12 chars)
484
+ - name (str): Container name
485
+
486
+ Examples:
487
+ >>> info = Database._get_docker_container_info('my_postgres')
488
+ >>> print(info)
489
+ {
490
+ 'exists': True,
491
+ 'running': True,
492
+ 'status': 'running',
493
+ 'id': '3f8d9a2b1c4e',
494
+ 'name': 'my_postgres'
495
+ }
496
+ """
497
+ info = {
498
+ 'exists': False,
499
+ 'running': False,
500
+ 'status': 'unknown',
501
+ 'id': '',
502
+ 'name': container_name
503
+ }
504
+
505
+ # Check if container exists
506
+ if not cls._check_docker_container_exists(container_name):
507
+ return info
508
+
509
+ info['exists'] = True
510
+
511
+ try:
512
+ # Get container status
513
+ result = subprocess.run(
514
+ ['docker', 'inspect', '-f', '{{.State.Status}}', container_name],
515
+ capture_output=True,
516
+ text=True,
517
+ check=True,
518
+ timeout=5
519
+ )
520
+ info['status'] = result.stdout.strip()
521
+ info['running'] = info['status'] == 'running'
522
+
523
+ # Get container ID
524
+ result = subprocess.run(
525
+ ['docker', 'inspect', '-f', '{{.Id}}', container_name],
526
+ capture_output=True,
527
+ text=True,
528
+ check=True,
529
+ timeout=5
530
+ )
531
+ info['id'] = result.stdout.strip()[:12] # First 12 chars
532
+
533
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
534
+ pass
535
+
536
+ return info
537
+
538
+ @classmethod
539
+ def _execute_native_pg_command(cls, database_name, connection_params, *command_args):
540
+ """
541
+ Execute PostgreSQL command on native PostgreSQL installation.
542
+
543
+ This is the original implementation extracted from _execute_pg_command().
544
+ Executes PostgreSQL commands (psql, createdb, pg_dump, etc.) on a
545
+ native PostgreSQL installation using environment variables.
546
+
547
+ Args:
548
+ database_name (str): PostgreSQL database name
549
+ connection_params (dict): Connection parameters (host, port, user, password)
550
+ *command_args: PostgreSQL command and arguments
551
+
552
+ Returns:
553
+ subprocess.CompletedProcess: Command execution result
554
+
555
+ Raises:
556
+ subprocess.CalledProcessError: If PostgreSQL command fails
557
+
558
+ Examples:
559
+ >>> Database._execute_native_pg_command(
560
+ ... 'my_db',
561
+ ... {'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret'},
562
+ ... 'createdb', 'my_db'
563
+ ... )
564
+ """
565
+ # Prepare environment variables for PostgreSQL commands
566
+ env = os.environ.copy()
567
+ env['PGUSER'] = connection_params['user']
568
+ env['PGHOST'] = connection_params['host']
569
+ env['PGPORT'] = str(connection_params['port'])
570
+
571
+ # Set password if provided (use PGPASSWORD environment variable)
572
+ if connection_params.get('password'):
573
+ env['PGPASSWORD'] = connection_params['password']
574
+
575
+ # Execute PostgreSQL command
576
+ result = subprocess.run(
577
+ command_args,
578
+ env=env,
579
+ capture_output=True,
580
+ text=True,
581
+ check=True
582
+ )
583
+
584
+ return result
585
+
586
+
587
+ @classmethod
588
+ def _execute_docker_pg_command(cls, container_name, database_name, connection_params, *command_args):
589
+ """
590
+ Execute PostgreSQL command inside a Docker container.
591
+
592
+ Handles Docker-specific challenges:
593
+ - Adds -U option to avoid "role 'root' does not exist" errors
594
+ - Manages psql -f by reading files on host and passing via stdin
595
+ - Manages pg_dump -f by capturing stdout and writing to host
596
+
597
+ Args:
598
+ container_name (str): Docker container name
599
+ database_name (str): PostgreSQL database name
600
+ connection_params (dict): Connection parameters (user, password)
601
+ *command_args: PostgreSQL command and arguments
602
+
603
+ Returns:
604
+ subprocess.CompletedProcess: Command execution result
605
+
606
+ Raises:
607
+ DockerNotAvailableError: If Docker is not installed or not running
608
+ DockerContainerNotFoundError: If container does not exist
609
+ DockerContainerNotRunningError: If container exists but is stopped
610
+ subprocess.CalledProcessError: If PostgreSQL command fails
611
+
612
+ Examples:
613
+ # psql -f (reads file on host, passes via stdin)
614
+ >>> Database._execute_docker_pg_command(
615
+ ... 'my_postgres', 'my_db',
616
+ ... {'user': 'postgres', 'password': 'secret'},
617
+ ... 'psql', '-d', 'my_db', '-f', '/path/to/schema.sql'
618
+ ... )
619
+
620
+ # pg_dump -f (captures stdout, writes to host)
621
+ >>> Database._execute_docker_pg_command(
622
+ ... 'my_postgres', 'my_db',
623
+ ... {'user': 'postgres', 'password': 'secret'},
624
+ ... 'pg_dump', 'my_db', '--schema-only', '-f', '/path/to/dump.sql'
625
+ ... )
626
+ """
627
+ # ═══════════════════════════════════════════════════════════════════
628
+ # STEP 1: Check Docker availability
629
+ # ═══════════════════════════════════════════════════════════════════
630
+ if not cls._check_docker_available():
631
+ raise DockerNotAvailableError(
632
+ "Docker is not installed or not running.\n"
633
+ "Install Docker: https://docs.docker.com/get-docker/"
634
+ )
635
+
636
+ # ═══════════════════════════════════════════════════════════════════
637
+ # STEP 2: Check container exists
638
+ # ═══════════════════════════════════════════════════════════════════
639
+ if not cls._check_docker_container_exists(container_name):
640
+ raise DockerContainerNotFoundError(
641
+ f"Docker container '{container_name}' not found.\n"
642
+ f"Run: docker ps -a # to list all containers\n"
643
+ f"Or create a new PostgreSQL container:\n"
644
+ f" docker run -d --name {container_name} -e POSTGRES_PASSWORD=postgres postgres:17"
645
+ )
646
+
647
+ # ═══════════════════════════════════════════════════════════════════
648
+ # STEP 3: Check container is running
649
+ # ═══════════════════════════════════════════════════════════════════
650
+ if not cls._check_docker_container_running(container_name):
651
+ container_info = cls._get_docker_container_info(container_name)
652
+ raise DockerContainerNotRunningError(
653
+ f"Docker container '{container_name}' exists but is not running.\n"
654
+ f"Status: {container_info['status']}\n"
655
+ f"Run: docker start {container_name}"
656
+ )
657
+
658
+ # ═══════════════════════════════════════════════════════════════════
659
+ # STEP 4: Handle file operations (psql -f, pg_dump -f)
660
+ # ═══════════════════════════════════════════════════════════════════
661
+ command_list = list(command_args)
662
+ modified_command = command_list.copy()
663
+ sql_input = None # For psql -f (stdin)
664
+ output_file = None # For pg_dump -f (stdout redirect)
665
+
666
+ command_name = command_list[0] if len(command_list) > 0 else ''
667
+
668
+ # ────────────────────────────────────────────────────────────────────
669
+ # Case 1: psql -f <file> → Read file and pass via stdin
670
+ # ────────────────────────────────────────────────────────────────────
671
+ if command_name == 'psql':
672
+ try:
673
+ f_index = command_list.index('-f')
674
+ if f_index + 1 < len(command_list):
675
+ host_file_path = command_list[f_index + 1]
676
+
677
+ # Read SQL file content on host
678
+ with open(host_file_path, 'r', encoding='utf-8') as f:
679
+ sql_input = f.read()
680
+
681
+ # Remove -f option from command (will use stdin)
682
+ modified_command = command_list[:f_index] + command_list[f_index+2:]
683
+ except (ValueError, FileNotFoundError, OSError):
684
+ # -f not found or file read failed, use original command
685
+ pass
686
+
687
+ # ────────────────────────────────────────────────────────────────────
688
+ # Case 2: pg_dump -f <file> → Remove -f and capture stdout
689
+ # ────────────────────────────────────────────────────────────────────
690
+ elif command_name == 'pg_dump':
691
+ try:
692
+ f_index = command_list.index('-f')
693
+ if f_index + 1 < len(command_list):
694
+ output_file = command_list[f_index + 1]
695
+
696
+ # Remove -f option (will capture stdout)
697
+ modified_command = command_list[:f_index] + command_list[f_index+2:]
698
+ except ValueError:
699
+ # -f not found, use original command
700
+ pass
701
+
702
+ # ═══════════════════════════════════════════════════════════════════
703
+ # STEP 5: Prepare PostgreSQL command with -U option
704
+ # ═══════════════════════════════════════════════════════════════════
705
+ # CRITICAL: Add -U <user> to avoid "role 'root' does not exist" error
706
+ # docker exec runs as root, but we need PostgreSQL user
707
+
708
+ pg_user = connection_params['user']
709
+
710
+ if len(modified_command) > 0:
711
+ pg_command = modified_command[0] # e.g., 'createdb', 'psql', 'pg_dump'
712
+ command_args_rest = modified_command[1:]
713
+
714
+ # Insert -U <user> after command name
715
+ final_command = [pg_command, '-U', pg_user] + command_args_rest
716
+ else:
717
+ final_command = modified_command
718
+
719
+ # ═══════════════════════════════════════════════════════════════════
720
+ # STEP 6: Prepare Docker exec command
721
+ # ═══════════════════════════════════════════════════════════════════
722
+ docker_cmd = ['docker', 'exec', '-i', container_name] + final_command
723
+
724
+ # ═══════════════════════════════════════════════════════════════════
725
+ # STEP 7: Prepare environment variables
726
+ # ═══════════════════════════════════════════════════════════════════
727
+ env = os.environ.copy()
728
+
729
+ # Set password if provided (PGPASSWORD for authentication)
730
+ if connection_params.get('password'):
731
+ env['PGPASSWORD'] = connection_params['password']
732
+
733
+ # ═══════════════════════════════════════════════════════════════════
734
+ # STEP 8: Execute command with appropriate I/O handling
735
+ # ═══════════════════════════════════════════════════════════════════
736
+
737
+ if sql_input:
738
+ # ──────────────────────────────────────────────────────────────
739
+ # psql -f: Pass SQL content via stdin
740
+ # ──────────────────────────────────────────────────────────────
741
+ result = subprocess.run(
742
+ docker_cmd,
743
+ env=env,
744
+ input=sql_input,
745
+ capture_output=True,
746
+ text=True,
747
+ check=True
748
+ )
749
+
750
+ elif output_file:
751
+ # ──────────────────────────────────────────────────────────────
752
+ # pg_dump -f: Capture stdout and write to host file
753
+ # ──────────────────────────────────────────────────────────────
754
+ result = subprocess.run(
755
+ docker_cmd,
756
+ env=env,
757
+ capture_output=True,
758
+ text=True,
759
+ check=True
760
+ )
761
+
762
+ # Write stdout to output file on host
763
+ with open(output_file, 'w', encoding='utf-8') as f:
764
+ f.write(result.stdout)
765
+
766
+ else:
767
+ # ──────────────────────────────────────────────────────────────
768
+ # Standard execution (no file operations)
769
+ # ──────────────────────────────────────────────────────────────
770
+ result = subprocess.run(
771
+ docker_cmd,
772
+ env=env,
773
+ capture_output=True,
774
+ text=True,
775
+ check=True
776
+ )
777
+
778
+ return result
779
+
780
+
781
+ @classmethod
782
+ def _execute_pg_command(cls, database_name, connection_params, *command_args):
783
+ """
784
+ Execute PostgreSQL command with connection parameters (native or Docker).
785
+
786
+ Routes command execution to either native PostgreSQL or Docker container
787
+ based on the presence of 'docker_container' in connection_params.
788
+
789
+ **Mode Detection**:
790
+ - If docker_container is present and non-empty → Docker mode
791
+ - Otherwise → Native PostgreSQL mode
792
+
793
+ Args:
794
+ database_name (str): PostgreSQL database name
795
+ connection_params (dict): Connection parameters including optional docker_container
796
+ *command_args: PostgreSQL command arguments
797
+
798
+ Returns:
799
+ subprocess.CompletedProcess: Command execution result
800
+
801
+ Raises:
802
+ DockerNotAvailableError: If Docker mode but Docker not available
803
+ DockerContainerNotFoundError: If Docker mode but container not found
804
+ DockerContainerNotRunningError: If Docker mode but container stopped
805
+ subprocess.CalledProcessError: If PostgreSQL command fails
806
+
807
+ Examples:
808
+ # Native PostgreSQL (existing behavior)
809
+ >>> Database._execute_pg_command(
810
+ ... 'my_db',
811
+ ... {'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret'},
812
+ ... 'createdb', 'my_db'
813
+ ... )
814
+
815
+ # Docker PostgreSQL (new behavior)
816
+ >>> Database._execute_pg_command(
817
+ ... 'my_db',
818
+ ... {'user': 'postgres', 'password': 'secret', 'docker_container': 'my_postgres'},
819
+ ... 'createdb', 'my_db'
820
+ ... )
821
+ """
822
+ # Detect execution mode based on docker_container presence
823
+ docker_container = connection_params.get('docker_container', '')
824
+
825
+ if docker_container:
826
+ # Docker mode: Execute command inside Docker container
827
+ return cls._execute_docker_pg_command(
828
+ docker_container,
829
+ database_name,
830
+ connection_params,
831
+ *command_args
832
+ )
833
+ else:
834
+ # Native mode: Execute command on native PostgreSQL
835
+ return cls._execute_native_pg_command(
836
+ database_name,
837
+ connection_params,
838
+ *command_args
839
+ )
840
+
841
+ @classmethod
842
+ def setup_database(cls, database_name, connection_options, create_db=True, add_metadata=False):
843
+ """
844
+ Configure database connection and install half-orm metadata schemas.
845
+
846
+ Replaces the interactive __init_db() method with a non-interactive version
847
+ that accepts connection parameters from CLI options or prompts for missing ones.
848
+
849
+ **AUTOMATIC METADATA INSTALLATION**: If create_db=True, metadata is automatically
850
+ installed for the newly created database (add_metadata becomes True automatically).
851
+
852
+ Args:
853
+ database_name (str): PostgreSQL database name
854
+ connection_options (dict): Connection parameters from CLI
855
+ - host (str): PostgreSQL host (default: localhost)
856
+ - port (int): PostgreSQL port (default: 5432)
857
+ - user (str): Database user (default: $USER)
858
+ - password (str): Database password (prompts if None)
859
+ - production (bool): Production environment flag
860
+ create_db (bool): Create database if it doesn't exist
861
+ add_metadata (bool): Add half_orm_meta schemas to existing database
862
+ (automatically True if create_db=True)
863
+
864
+ Returns:
865
+ str: Path to saved configuration file
866
+
867
+ Raises:
868
+ DatabaseConnectionError: If connection to PostgreSQL fails
869
+ DatabaseCreationError: If database creation fails
870
+ MetadataInstallationError: If metadata schema installation fails
871
+
872
+ Process Flow:
873
+ 1. Parameter Collection: Use provided options or prompt for missing ones
874
+ 2. Connection Test: Verify PostgreSQL connection with provided credentials
875
+ 3. Database Setup: Create database if create_db=True, or connect to existing
876
+ 4. Metadata Installation: Add half_orm_meta and half_orm_meta.view schemas
877
+ - Automatically installed for newly created databases (create_db=True)
878
+ - Manually requested for existing databases (add_metadata=True)
879
+ 5. Configuration Save: Store connection parameters in configuration file
880
+ 6. Initial Release: Register version 0.0.0 in metadata
881
+
882
+ Examples:
883
+ # Create new database - metadata automatically installed
884
+ Database.setup_database(
885
+ database_name="my_blog_db",
886
+ connection_options={'host': 'localhost', 'user': 'dev', 'password': 'secret'},
887
+ create_db=True # add_metadata becomes True automatically
888
+ )
889
+
890
+ # Add metadata to existing database manually
891
+ Database.setup_database(
892
+ database_name="legacy_db",
893
+ connection_options={'host': 'prod.db.com', 'user': 'admin'},
894
+ create_db=False,
895
+ add_metadata=True # Explicit metadata installation
896
+ )
897
+
898
+ # Connect to existing database without metadata (sync-only mode)
899
+ Database.setup_database(
900
+ database_name="readonly_db",
901
+ connection_options={'host': 'localhost'},
902
+ create_db=False,
903
+ add_metadata=False # No metadata - sync-only mode
904
+ )
905
+ """
906
+ # Step 1: Validate input parameters
907
+ cls._validate_parameters(database_name, connection_options)
908
+
909
+ # Step 2: Collect connection parameters
910
+ complete_params = cls._collect_connection_params(database_name, connection_options)
911
+
912
+ # Step 3: Save configuration to file
913
+ config_file = cls._save_configuration(database_name, complete_params)
914
+
915
+ # Step 4: Test database connection (create if needed)
916
+ database_created = False # Track if we created a new database
917
+
918
+ try:
919
+ model = Model(database_name)
920
+ except OperationalError:
921
+ if create_db:
922
+ # Create database using PostgreSQL createdb command
923
+ cls._execute_pg_command(database_name, complete_params, 'createdb', database_name)
924
+ database_created = True # Mark that we created the database
925
+ # Retry connection after creation
926
+ model = Model(database_name)
927
+ else:
928
+ raise OperationalError(f"Database '{database_name}' does not exist and create_db=False")
929
+
930
+ # Step 5: Install metadata if requested OR if database was newly created
931
+ # AUTOMATIC BEHAVIOR: newly created databases automatically get metadata
932
+ should_install_metadata = add_metadata or database_created
933
+
934
+ if should_install_metadata:
935
+ try:
936
+ model.get_relation_class('half_orm_meta.hop_release')
937
+ # Metadata already exists - skip installation
938
+ except UnknownRelation:
939
+ # Install metadata schemas
940
+ hop_init_sql_file = os.path.join(HOP_PATH, 'patches', 'sql', 'half_orm_meta.sql')
941
+ cls._execute_pg_command(
942
+ database_name,
943
+ complete_params,
944
+ 'psql',
945
+ '-d', database_name,
946
+ '-f', hop_init_sql_file
947
+ )
948
+ model.reconnect(reload=True)
949
+
950
+ # Register initial release 0.0.0
951
+ release_class = model.get_relation_class('half_orm_meta.hop_release')
952
+ release_class(
953
+ major=0, minor=0, patch=0, changelog='Initial release'
954
+ ).ho_insert()
955
+
956
+ return config_file
957
+
958
+ @classmethod
959
+ def _validate_parameters(cls, database_name, connection_options):
960
+ """
961
+ Validate input parameters for database setup.
962
+
963
+ Args:
964
+ database_name (str): PostgreSQL database name
965
+ connection_options (dict): Connection parameters from CLI
966
+
967
+ Raises:
968
+ ValueError: If database_name is invalid
969
+ TypeError: If connection_options is not a dict
970
+
971
+ Returns:
972
+ None: Parameters are valid
973
+
974
+ Examples:
975
+ # Valid parameters
976
+ Database._validate_parameters("my_db", {'host': 'localhost'})
977
+
978
+ # Invalid database name
979
+ Database._validate_parameters("", {}) # Raises ValueError
980
+ Database._validate_parameters(None, {}) # Raises ValueError
981
+
982
+ # Invalid connection options
983
+ Database._validate_parameters("my_db", None) # Raises TypeError
984
+ """
985
+ # Validate database_name
986
+ if database_name is None:
987
+ raise ValueError("Database name cannot be None")
988
+
989
+ if not isinstance(database_name, str):
990
+ raise ValueError(f"Database name must be a string, got {type(database_name).__name__}")
991
+
992
+ if database_name.strip() == "":
993
+ raise ValueError("Database name cannot be empty")
994
+
995
+ # Basic name format validation (PostgreSQL identifier rules)
996
+ database_name = database_name.strip()
997
+ if not database_name.replace('_', '').replace('-', '').isalnum():
998
+ raise ValueError(f"Database name '{database_name}' contains invalid characters. Use only letters, numbers, underscore, and hyphen.")
999
+
1000
+ if database_name[0].isdigit():
1001
+ raise ValueError(f"Database name '{database_name}' cannot start with a digit")
1002
+
1003
+ # Validate connection_options
1004
+ if connection_options is None:
1005
+ raise TypeError("Connection options cannot be None")
1006
+
1007
+ if not isinstance(connection_options, dict):
1008
+ raise TypeError(f"Connection options must be a dictionary, got {type(connection_options).__name__}")
1009
+
1010
+ # Expected option keys (some may be None/missing for interactive prompts)
1011
+ expected_keys = {'host', 'port', 'user', 'password', 'production', 'docker_container'}
1012
+ provided_keys = set(connection_options.keys())
1013
+
1014
+ # Check for unexpected keys
1015
+ unexpected_keys = provided_keys - expected_keys
1016
+ if unexpected_keys:
1017
+ raise ValueError(f"Unexpected connection options: {sorted(unexpected_keys)}. Expected: {sorted(expected_keys)}")
1018
+
1019
+ # Validate port if provided
1020
+ if 'port' in connection_options and connection_options['port'] is not None:
1021
+ port = connection_options['port']
1022
+ if not isinstance(port, int) or port <= 0 or port > 65535:
1023
+ raise ValueError(f"Port must be an integer between 1 and 65535, got {port}")
1024
+
1025
+ # Validate production flag if provided
1026
+ if 'production' in connection_options and connection_options['production'] is not None:
1027
+ production = connection_options['production']
1028
+ if not isinstance(production, bool):
1029
+ raise ValueError(f"Production flag must be boolean, got {type(production).__name__}")
1030
+
1031
+ @classmethod
1032
+ def _collect_connection_params(cls, database_name, connection_options):
1033
+ """
1034
+ Collect missing connection parameters interactively.
1035
+
1036
+ Takes partial connection parameters from CLI options and prompts
1037
+ interactively for any missing or None values. Applies halfORM
1038
+ standard defaults where appropriate.
1039
+
1040
+ Args:
1041
+ database_name (str): PostgreSQL database name for context
1042
+ connection_options (dict): Partial connection parameters from CLI
1043
+ - host (str|None): PostgreSQL host
1044
+ - port (int|None): PostgreSQL port
1045
+ - user (str|None): Database user
1046
+ - password (str|None): Database password
1047
+ - production (bool|None): Production environment flag
1048
+
1049
+ Returns:
1050
+ dict: Complete connection parameters ready for DbConn initialization
1051
+ - host (str): PostgreSQL host (default: 'localhost')
1052
+ - port (int): PostgreSQL port (default: 5432)
1053
+ - user (str): Database user (default: $USER env var)
1054
+ - password (str): Database password (prompted if None)
1055
+ - production (bool): Production flag (default: False)
1056
+
1057
+ Raises:
1058
+ KeyboardInterrupt: If user cancels interactive prompts
1059
+ EOFError: If input stream is closed during prompts
1060
+
1061
+ Interactive Behavior:
1062
+ - Only prompts for missing/None parameters
1063
+ - Shows current defaults in prompts: "Host (localhost): "
1064
+ - Uses getpass for secure password input
1065
+ - Allows empty input to accept defaults
1066
+ - Confirms production flag if True
1067
+
1068
+ Examples:
1069
+ # Complete parameters provided - no prompts
1070
+ complete = Database._collect_connection_params(
1071
+ "my_db",
1072
+ {'host': 'localhost', 'port': 5432, 'user': 'dev', 'password': 'secret', 'production': False}
1073
+ )
1074
+ # Returns: same dict (no interaction needed)
1075
+
1076
+ # Missing user and password - prompts interactively
1077
+ complete = Database._collect_connection_params(
1078
+ "my_db",
1079
+ {'host': 'localhost', 'port': 5432, 'user': None, 'password': None, 'production': False}
1080
+ )
1081
+ # Prompts: "User (current_user): " and "Password: [hidden]"
1082
+ # Returns: {'host': 'localhost', 'port': 5432, 'user': 'prompted_user', 'password': 'prompted_pass', 'production': False}
1083
+
1084
+ # Only host provided - prompts for missing with defaults
1085
+ complete = Database._collect_connection_params(
1086
+ "my_db",
1087
+ {'host': 'prod.db.com'}
1088
+ )
1089
+ # Prompts: "Port (5432): ", "User (current_user): ", "Password: "
1090
+ # Returns: complete dict with provided host and prompted/default values
1091
+
1092
+ # Production flag confirmation
1093
+ complete = Database._collect_connection_params(
1094
+ "prod_db",
1095
+ {'host': 'prod.db.com', 'production': True}
1096
+ )
1097
+ # Prompts: "Production environment (True): " for confirmation
1098
+ # Returns: dict with confirmed production setting
1099
+ """
1100
+ import getpass
1101
+ import os
1102
+
1103
+ # Create a copy to avoid modifying the original
1104
+ complete_params = connection_options.copy()
1105
+
1106
+ # Interactive prompts for None values BEFORE applying defaults
1107
+ print(f"Connection parameters for database '{database_name}':")
1108
+
1109
+ # Prompt for user if None
1110
+ if complete_params.get('user') is None:
1111
+ default_user = os.environ.get('USER', 'postgres')
1112
+ user_input = input(f"User ({default_user}): ").strip()
1113
+ complete_params['user'] = user_input if user_input else default_user
1114
+
1115
+ # Prompt for password if None (always prompt - security requirement)
1116
+ if complete_params.get('password') is None:
1117
+ password_input = getpass.getpass("Password: ")
1118
+ if password_input == '':
1119
+ # Empty password - assume trust/ident authentication
1120
+ complete_params['password'] = None # Explicitly None for trust mode
1121
+ complete_params['host'] = '' # Local socket connection
1122
+ complete_params['port'] = '' # No port for local socket
1123
+ else:
1124
+ complete_params['password'] = password_input
1125
+
1126
+ # Prompt for host if None
1127
+ if complete_params.get('host') is None:
1128
+ host_input = input("Host (localhost): ").strip()
1129
+ complete_params['host'] = host_input if host_input else 'localhost'
1130
+
1131
+ # Prompt for port if None
1132
+ if complete_params.get('port') is None:
1133
+ port_input = input("Port (5432): ").strip()
1134
+ if port_input:
1135
+ try:
1136
+ complete_params['port'] = int(port_input)
1137
+ except ValueError:
1138
+ raise ValueError(f"Invalid port number: {port_input}")
1139
+ else:
1140
+ complete_params['port'] = 5432
1141
+
1142
+ # Apply defaults for still missing parameters (no prompts needed)
1143
+ if complete_params.get('host') is None:
1144
+ complete_params['host'] = 'localhost'
1145
+
1146
+ if complete_params.get('port') is None:
1147
+ complete_params['port'] = 5432
1148
+
1149
+ if complete_params.get('user') is None:
1150
+ complete_params['user'] = os.environ.get('USER', 'postgres')
1151
+
1152
+ if complete_params.get('production') is None:
1153
+ complete_params['production'] = False
1154
+
1155
+ # Prompt for production confirmation if True (security measure)
1156
+ if complete_params.get('production') is True:
1157
+ prod_input = input(f"Production environment (True): ").strip().lower()
1158
+ if prod_input and prod_input not in ['true', 't', 'yes', 'y', '1']:
1159
+ complete_params['production'] = False
1160
+
1161
+ return complete_params
1162
+
1163
+ @classmethod
1164
+ def _load_configuration(cls, database_name):
1165
+ """
1166
+ Load existing database configuration file, replacing DbConn functionality.
1167
+
1168
+ Reads halfORM configuration file and returns connection parameters as a dictionary.
1169
+ This method completely replaces DbConn.__init() logic, supporting both minimal
1170
+ configurations (PostgreSQL trust mode) and complete parameter sets.
1171
+
1172
+ Args:
1173
+ database_name (str): Name of the database to load configuration for
1174
+
1175
+ Returns:
1176
+ dict | None: Connection parameters dictionary with standardized keys:
1177
+ - name (str): Database name (always present)
1178
+ - user (str): Database user (defaults to $USER environment variable)
1179
+ - password (str): Database password (empty string if not set)
1180
+ - host (str): Database host (empty string for Unix socket, 'localhost' otherwise)
1181
+ - port (int): Database port (5432 if not specified)
1182
+ - production (bool): Production environment flag (defaults to False)
1183
+ Returns None if configuration file doesn't exist.
1184
+
1185
+ Raises:
1186
+ FileNotFoundError: If CONF_DIR doesn't exist or isn't accessible
1187
+ PermissionError: If configuration file exists but isn't readable
1188
+ ValueError: If configuration file format is invalid or corrupted
1189
+
1190
+ Examples:
1191
+ # Complete configuration file
1192
+ config = Database._load_configuration("production_db")
1193
+ # Returns: {'name': 'production_db', 'user': 'app_user', 'password': 'secret',
1194
+ # 'host': 'db.company.com', 'port': 5432, 'production': True}
1195
+
1196
+ # Minimal trust mode configuration (only name=database_name)
1197
+ config = Database._load_configuration("local_dev")
1198
+ # Returns: {'name': 'local_dev', 'user': 'joel', 'password': '',
1199
+ # 'host': '', 'port': 5432, 'production': False}
1200
+
1201
+ # Non-existent configuration
1202
+ config = Database._load_configuration("unknown_db")
1203
+ # Returns: None
1204
+
1205
+ Migration Notes:
1206
+ - Completely replaces DbConn.__init() and DbConn.__init logic
1207
+ - Maintains backward compatibility with existing config files
1208
+ - Standardizes return format (int for port, bool for production)
1209
+ - Integrates PostgreSQL trust mode defaults directly into Database class
1210
+ - Eliminates external DbConn dependency while preserving all functionality
1211
+ """
1212
+ import os
1213
+ from configparser import ConfigParser
1214
+ from half_orm.model import CONF_DIR
1215
+
1216
+ # Check if configuration directory exists
1217
+ if not os.path.exists(CONF_DIR):
1218
+ raise FileNotFoundError(f"Configuration directory {CONF_DIR} doesn't exist")
1219
+
1220
+ # Build configuration file path
1221
+ config_file = os.path.join(CONF_DIR, database_name)
1222
+
1223
+ # Return None if configuration file doesn't exist
1224
+ if not os.path.exists(config_file):
1225
+ return None
1226
+
1227
+ # Check if file is readable before attempting to parse
1228
+ if not os.access(config_file, os.R_OK):
1229
+ raise PermissionError(f"Configuration file {config_file} is not readable")
1230
+
1231
+ # Read configuration file
1232
+ config = ConfigParser()
1233
+ try:
1234
+ config.read(config_file)
1235
+ except Exception as e:
1236
+ raise ValueError(f"Configuration file format is invalid: {e}")
1237
+
1238
+ # Check if [database] section exists
1239
+ if not config.has_section('database'):
1240
+ raise ValueError("Configuration file format is invalid: missing [database] section")
1241
+
1242
+ # Extract configuration values with PostgreSQL defaults
1243
+ try:
1244
+ name = config.get('database', 'name')
1245
+ user = config.get('database', 'user', fallback=os.environ.get('USER', ''))
1246
+ password = config.get('database', 'password', fallback='')
1247
+ host = config.get('database', 'host', fallback='')
1248
+ port_str = config.get('database', 'port', fallback='')
1249
+ production_str = config.get('database', 'production', fallback='False')
1250
+ docker_container = config.get('database', 'docker_container', fallback='')
1251
+
1252
+ # Convert port to int (default 5432 if empty)
1253
+ if port_str == '':
1254
+ port = 5432
1255
+ else:
1256
+ port = int(port_str)
1257
+
1258
+ # Convert production to bool
1259
+ production = config.getboolean('database', 'production', fallback=False)
1260
+
1261
+ return {
1262
+ 'name': name,
1263
+ 'user': user,
1264
+ 'password': password,
1265
+ 'host': host,
1266
+ 'port': port,
1267
+ 'production': production,
1268
+ 'docker_container': docker_container
1269
+ }
1270
+
1271
+ except (ValueError, TypeError) as e:
1272
+ raise ValueError(f"Configuration file format is invalid: {e}")
1273
+
1274
+ def _get_connection_params(self):
1275
+ """
1276
+ Get current connection parameters for this database instance.
1277
+
1278
+ Returns the connection parameters dictionary for this Database instance,
1279
+ replacing direct access to DbConn properties. This method serves as the
1280
+ unified interface for accessing connection parameters during the migration
1281
+ from DbConn to integrated Database functionality.
1282
+
1283
+ Uses instance-level caching to avoid repeated file reads within the same
1284
+ Database instance lifecycle.
1285
+
1286
+ Returns:
1287
+ dict: Connection parameters dictionary with standardized keys:
1288
+ - name (str): Database name
1289
+ - user (str): Database user
1290
+ - password (str): Database password (empty string if not set)
1291
+ - host (str): Database host (empty string for Unix socket)
1292
+ - port (int): Database port (5432 default)
1293
+ - production (bool): Production environment flag
1294
+ Returns dict with defaults if no configuration exists or errors occur.
1295
+
1296
+ Examples:
1297
+ # Get connection parameters for existing database instance
1298
+ db = Database(repo)
1299
+ params = db._get_connection_params()
1300
+ # Returns: {'name': 'my_db', 'user': 'dev', 'password': '',
1301
+ # 'host': 'localhost', 'port': 5432, 'production': False}
1302
+
1303
+ # Access specific parameters (replaces DbConn.property access)
1304
+ user = db._get_connection_params()['user'] # replaces self.__connection_params.user
1305
+ host = db._get_connection_params()['host'] # replaces self.__connection_params.host
1306
+ prod = db._get_connection_params()['production'] # replaces self.__connection_params.production
1307
+
1308
+ Implementation Notes:
1309
+ - Uses _load_configuration() internally but handles all exceptions
1310
+ - Provides stable interface - never raises exceptions
1311
+ - Returns sensible defaults if configuration is missing/invalid
1312
+ - Serves as protective wrapper around _load_configuration()
1313
+ - Exceptions from _load_configuration() are caught and handled gracefully
1314
+ - Uses instance-level cache to avoid repeated file reads
1315
+
1316
+ Migration Notes:
1317
+ - Replaces self.__connection_params.user, .host, .port, .production access
1318
+ - Serves as transition method during DbConn elimination
1319
+ - Maintains compatibility with existing Database instance usage patterns
1320
+ - Will be used by state, production, and execute_pg_command properties
1321
+ """
1322
+ # Return cached parameters if already loaded
1323
+ if hasattr(self, '_Database__connection_params_cache') and self.__connection_params_cache is not None:
1324
+ return self.__connection_params_cache
1325
+
1326
+ # Load configuration with defaults
1327
+ config = {
1328
+ 'name': self.__repo.name,
1329
+ 'user': os.environ.get('USER', ''),
1330
+ 'password': '',
1331
+ 'host': '',
1332
+ 'port': 5432,
1333
+ 'production': False
1334
+ }
1335
+
1336
+ try:
1337
+ # Try to load configuration for this database
1338
+ loaded_config = self._load_configuration(self.__repo.name)
1339
+ if loaded_config is not None:
1340
+ config = loaded_config
1341
+ except (FileNotFoundError, PermissionError, ValueError):
1342
+ # Handle all possible exceptions from _load_configuration gracefully
1343
+ # Return sensible defaults to maintain stable interface
1344
+ pass
1345
+
1346
+ # Cache the result for subsequent calls
1347
+ self.__connection_params_cache = config
1348
+ return config
1349
+
1350
+ def get_postgres_version(self) -> tuple:
1351
+ """
1352
+ Get PostgreSQL server version.
1353
+
1354
+ Returns:
1355
+ tuple: (major, minor) version numbers
1356
+ Examples: (13, 4), (16, 1), (17, 0)
1357
+
1358
+ Raises:
1359
+ DatabaseError: If version cannot be determined
1360
+
1361
+ Examples:
1362
+ version = db.get_postgres_version()
1363
+ if version >= (13, 0):
1364
+ # Use --force flag for dropdb
1365
+ pass
1366
+ """
1367
+ try:
1368
+ result = self._execute_pg_command(
1369
+ self.__name,
1370
+ self._get_connection_params(),
1371
+ *['psql', '-d', 'postgres', '-t', '-A', '-c', 'SHOW server_version;'],
1372
+ )
1373
+
1374
+ # Output format: "16.1 (Ubuntu 16.1-1.pgdg22.04+1)"
1375
+ # Extract version: "16.1"
1376
+ version_str = result.stdout.strip().split()[0]
1377
+
1378
+ # Parse major.minor
1379
+ parts = version_str.split('.')
1380
+ major = int(parts[0])
1381
+ minor = int(parts[1]) if len(parts) > 1 else 0
1382
+
1383
+ return (major, minor)
1384
+
1385
+ except Exception as e:
1386
+ raise DatabaseError(
1387
+ f"Failed to get PostgreSQL version: {e}\n"
1388
+ f"Ensure PostgreSQL is installed and accessible."
1389
+ )