nedo-vision-worker 1.0.0__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 (92) hide show
  1. nedo_vision_worker/__init__.py +10 -0
  2. nedo_vision_worker/cli.py +195 -0
  3. nedo_vision_worker/config/ConfigurationManager.py +196 -0
  4. nedo_vision_worker/config/__init__.py +1 -0
  5. nedo_vision_worker/database/DatabaseManager.py +219 -0
  6. nedo_vision_worker/database/__init__.py +1 -0
  7. nedo_vision_worker/doctor.py +453 -0
  8. nedo_vision_worker/initializer/AppInitializer.py +78 -0
  9. nedo_vision_worker/initializer/__init__.py +1 -0
  10. nedo_vision_worker/models/__init__.py +15 -0
  11. nedo_vision_worker/models/ai_model.py +29 -0
  12. nedo_vision_worker/models/auth.py +14 -0
  13. nedo_vision_worker/models/config.py +9 -0
  14. nedo_vision_worker/models/dataset_source.py +30 -0
  15. nedo_vision_worker/models/logs.py +9 -0
  16. nedo_vision_worker/models/ppe_detection.py +39 -0
  17. nedo_vision_worker/models/ppe_detection_label.py +20 -0
  18. nedo_vision_worker/models/restricted_area_violation.py +20 -0
  19. nedo_vision_worker/models/user.py +10 -0
  20. nedo_vision_worker/models/worker_source.py +19 -0
  21. nedo_vision_worker/models/worker_source_pipeline.py +21 -0
  22. nedo_vision_worker/models/worker_source_pipeline_config.py +24 -0
  23. nedo_vision_worker/models/worker_source_pipeline_debug.py +15 -0
  24. nedo_vision_worker/models/worker_source_pipeline_detection.py +14 -0
  25. nedo_vision_worker/protos/AIModelService_pb2.py +46 -0
  26. nedo_vision_worker/protos/AIModelService_pb2_grpc.py +140 -0
  27. nedo_vision_worker/protos/DatasetSourceService_pb2.py +46 -0
  28. nedo_vision_worker/protos/DatasetSourceService_pb2_grpc.py +140 -0
  29. nedo_vision_worker/protos/HumanDetectionService_pb2.py +44 -0
  30. nedo_vision_worker/protos/HumanDetectionService_pb2_grpc.py +140 -0
  31. nedo_vision_worker/protos/PPEDetectionService_pb2.py +46 -0
  32. nedo_vision_worker/protos/PPEDetectionService_pb2_grpc.py +140 -0
  33. nedo_vision_worker/protos/VisionWorkerService_pb2.py +72 -0
  34. nedo_vision_worker/protos/VisionWorkerService_pb2_grpc.py +471 -0
  35. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2.py +64 -0
  36. nedo_vision_worker/protos/WorkerSourcePipelineService_pb2_grpc.py +312 -0
  37. nedo_vision_worker/protos/WorkerSourceService_pb2.py +50 -0
  38. nedo_vision_worker/protos/WorkerSourceService_pb2_grpc.py +183 -0
  39. nedo_vision_worker/protos/__init__.py +1 -0
  40. nedo_vision_worker/repositories/AIModelRepository.py +44 -0
  41. nedo_vision_worker/repositories/DatasetSourceRepository.py +150 -0
  42. nedo_vision_worker/repositories/PPEDetectionRepository.py +112 -0
  43. nedo_vision_worker/repositories/RestrictedAreaRepository.py +88 -0
  44. nedo_vision_worker/repositories/WorkerSourcePipelineDebugRepository.py +90 -0
  45. nedo_vision_worker/repositories/WorkerSourcePipelineDetectionRepository.py +48 -0
  46. nedo_vision_worker/repositories/WorkerSourcePipelineRepository.py +174 -0
  47. nedo_vision_worker/repositories/WorkerSourceRepository.py +46 -0
  48. nedo_vision_worker/repositories/__init__.py +1 -0
  49. nedo_vision_worker/services/AIModelClient.py +362 -0
  50. nedo_vision_worker/services/ConnectionInfoClient.py +57 -0
  51. nedo_vision_worker/services/DatasetSourceClient.py +88 -0
  52. nedo_vision_worker/services/FileToRTMPServer.py +78 -0
  53. nedo_vision_worker/services/GrpcClientBase.py +155 -0
  54. nedo_vision_worker/services/GrpcClientManager.py +141 -0
  55. nedo_vision_worker/services/ImageUploadClient.py +82 -0
  56. nedo_vision_worker/services/PPEDetectionClient.py +108 -0
  57. nedo_vision_worker/services/RTSPtoRTMPStreamer.py +98 -0
  58. nedo_vision_worker/services/RestrictedAreaClient.py +100 -0
  59. nedo_vision_worker/services/SystemUsageClient.py +77 -0
  60. nedo_vision_worker/services/VideoStreamClient.py +161 -0
  61. nedo_vision_worker/services/WorkerSourceClient.py +215 -0
  62. nedo_vision_worker/services/WorkerSourcePipelineClient.py +393 -0
  63. nedo_vision_worker/services/WorkerSourceUpdater.py +134 -0
  64. nedo_vision_worker/services/WorkerStatusClient.py +65 -0
  65. nedo_vision_worker/services/__init__.py +1 -0
  66. nedo_vision_worker/util/HardwareID.py +104 -0
  67. nedo_vision_worker/util/ImageUploader.py +92 -0
  68. nedo_vision_worker/util/Networking.py +94 -0
  69. nedo_vision_worker/util/PlatformDetector.py +50 -0
  70. nedo_vision_worker/util/SystemMonitor.py +299 -0
  71. nedo_vision_worker/util/VideoProbeUtil.py +120 -0
  72. nedo_vision_worker/util/__init__.py +1 -0
  73. nedo_vision_worker/worker/CoreActionWorker.py +125 -0
  74. nedo_vision_worker/worker/DataSenderWorker.py +168 -0
  75. nedo_vision_worker/worker/DataSyncWorker.py +143 -0
  76. nedo_vision_worker/worker/DatasetFrameSender.py +208 -0
  77. nedo_vision_worker/worker/DatasetFrameWorker.py +412 -0
  78. nedo_vision_worker/worker/PPEDetectionManager.py +86 -0
  79. nedo_vision_worker/worker/PipelineActionWorker.py +129 -0
  80. nedo_vision_worker/worker/PipelineImageWorker.py +116 -0
  81. nedo_vision_worker/worker/RabbitMQListener.py +170 -0
  82. nedo_vision_worker/worker/RestrictedAreaManager.py +85 -0
  83. nedo_vision_worker/worker/SystemUsageManager.py +111 -0
  84. nedo_vision_worker/worker/VideoStreamWorker.py +139 -0
  85. nedo_vision_worker/worker/WorkerManager.py +155 -0
  86. nedo_vision_worker/worker/__init__.py +1 -0
  87. nedo_vision_worker/worker_service.py +264 -0
  88. nedo_vision_worker-1.0.0.dist-info/METADATA +563 -0
  89. nedo_vision_worker-1.0.0.dist-info/RECORD +92 -0
  90. nedo_vision_worker-1.0.0.dist-info/WHEEL +5 -0
  91. nedo_vision_worker-1.0.0.dist-info/entry_points.txt +2 -0
  92. nedo_vision_worker-1.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,10 @@
1
+ """
2
+ Nedo Vision Worker Service Library
3
+
4
+ A library for running worker agents in the Nedo Vision platform.
5
+ """
6
+
7
+ from .worker_service import WorkerService
8
+
9
+ __version__ = "1.0.0"
10
+ __all__ = ["WorkerService"]
@@ -0,0 +1,195 @@
1
+ import argparse
2
+ import signal
3
+ import sys
4
+ import traceback
5
+ import logging
6
+
7
+ from .worker_service import WorkerService
8
+
9
+
10
+ def signal_handler(signum, frame):
11
+ """Handle system signals for graceful shutdown"""
12
+ logging.info(f"Received signal {signum}, shutting down...")
13
+ sys.exit(0)
14
+
15
+
16
+ def main():
17
+ """Main CLI entry point."""
18
+ parser = argparse.ArgumentParser(
19
+ description="Nedo Vision Worker Service Library CLI",
20
+ formatter_class=argparse.RawDescriptionHelpFormatter,
21
+ epilog="""
22
+ Examples:
23
+ # Check system dependencies and requirements
24
+ nedo-worker doctor
25
+
26
+ # Start worker service with required parameters
27
+ nedo-worker run --token your-token-here --rtmp-server rtmp://server.com:1935/live
28
+
29
+ # Start with custom server host
30
+ nedo-worker run --token your-token-here --rtmp-server rtmp://server.com:1935/live --server-host custom.server.com
31
+
32
+ # Start with custom storage path
33
+ nedo-worker run --token your-token-here --rtmp-server rtmp://server.com:1935/live --storage-path /path/to/storage
34
+ """
35
+ )
36
+
37
+ # Add subcommands
38
+ subparsers = parser.add_subparsers(dest='command', help='Available commands')
39
+
40
+ # Doctor command
41
+ doctor_parser = subparsers.add_parser(
42
+ 'doctor',
43
+ help='Check system dependencies and requirements',
44
+ description='Run diagnostic checks for FFmpeg, OpenCV, gRPC and other dependencies'
45
+ )
46
+
47
+ # Run command
48
+ run_parser = subparsers.add_parser(
49
+ 'run',
50
+ help='Start the worker service',
51
+ description='Start the Nedo Vision Worker Service'
52
+ )
53
+
54
+ run_parser.add_argument(
55
+ "--server-host",
56
+ default="be.vision.sindika.co.id",
57
+ help="Server hostname for communication (default: be.vision.sindika.co.id)"
58
+ )
59
+
60
+ run_parser.add_argument(
61
+ "--token",
62
+ required=True,
63
+ help="Authentication token for the worker (obtained from frontend)"
64
+ )
65
+
66
+ run_parser.add_argument(
67
+ "--system-usage-interval",
68
+ type=int,
69
+ default=30,
70
+ help="System usage reporting interval in seconds (default: 30)"
71
+ )
72
+
73
+ run_parser.add_argument(
74
+ "--rtmp-server",
75
+ required=True,
76
+ help="RTMP server URL for video streaming (e.g., rtmp://server.com:1935/live)"
77
+ )
78
+
79
+ run_parser.add_argument(
80
+ "--storage-path",
81
+ default="data",
82
+ help="Storage path for databases and files (default: data)"
83
+ )
84
+
85
+ # Add legacy arguments for backward compatibility (when no subcommand is used)
86
+ parser.add_argument(
87
+ "--token",
88
+ help="(Legacy) Authentication token for the worker (obtained from frontend)"
89
+ )
90
+
91
+ parser.add_argument(
92
+ "--server-host",
93
+ default="be.vision.sindika.co.id",
94
+ help="(Legacy) Server hostname for communication (default: be.vision.sindika.co.id)"
95
+ )
96
+
97
+ parser.add_argument(
98
+ "--system-usage-interval",
99
+ type=int,
100
+ default=30,
101
+ help="(Legacy) System usage reporting interval in seconds (default: 30)"
102
+ )
103
+
104
+ parser.add_argument(
105
+ "--rtmp-server",
106
+ help="(Legacy) RTMP server URL for video streaming (e.g., rtmp://server.com:1935/live)"
107
+ )
108
+
109
+ parser.add_argument(
110
+ "--storage-path",
111
+ default="data",
112
+ help="(Legacy) Storage path for databases and files (default: data)"
113
+ )
114
+
115
+ parser.add_argument(
116
+ "--version",
117
+ action="version",
118
+ version="nedo-vision-worker 1.0.0"
119
+ )
120
+
121
+ parser.add_argument(
122
+ "--doctor",
123
+ action="store_true",
124
+ help="(Deprecated) Run system diagnostics - use 'nedo-worker doctor' instead"
125
+ )
126
+
127
+ args = parser.parse_args()
128
+
129
+ # Handle subcommands
130
+ if args.command == 'doctor':
131
+ from .doctor import main as doctor_main
132
+ sys.exit(doctor_main())
133
+ elif args.command == 'run':
134
+ run_worker_service(args)
135
+ elif args.doctor: # Legacy mode - deprecated --doctor flag
136
+ print("⚠️ Warning: Using deprecated --doctor flag. Use 'nedo-worker doctor' instead.")
137
+ from .doctor import main as doctor_main
138
+ sys.exit(doctor_main())
139
+ elif args.token and args.rtmp_server: # Legacy mode - if token and rtmp_server are provided without subcommand
140
+ print("⚠️ Warning: Using legacy command format. Consider using 'nedo-worker run --token ... --rtmp-server ...' instead.")
141
+ run_worker_service(args)
142
+ else:
143
+ # If no subcommand provided and no token, show help
144
+ parser.print_help()
145
+ sys.exit(1)
146
+
147
+
148
+ def run_worker_service(args):
149
+ """Run the worker service with the provided arguments."""
150
+ # Set up signal handlers for graceful shutdown
151
+ signal.signal(signal.SIGINT, signal_handler)
152
+ signal.signal(signal.SIGTERM, signal_handler)
153
+
154
+ logger = logging.getLogger(__name__)
155
+
156
+ try:
157
+ # Create and start the worker service
158
+ service = WorkerService(
159
+ server_host=args.server_host,
160
+ token=args.token,
161
+ system_usage_interval=args.system_usage_interval,
162
+ rtmp_server=args.rtmp_server,
163
+ storage_path=args.storage_path
164
+ )
165
+
166
+ logger.info("🚀 Starting Nedo Vision Worker Service...")
167
+ logger.info(f"🌐 Server: {args.server_host}")
168
+ logger.info(f"🔑 Token: {args.token[:8]}...")
169
+ logger.info(f"⏱️ System Usage Interval: {args.system_usage_interval}s")
170
+ logger.info(f"📡 RTMP Server: {args.rtmp_server}")
171
+ logger.info(f"💾 Storage Path: {args.storage_path}")
172
+ logger.info("Press Ctrl+C to stop the service")
173
+
174
+ # Start the service
175
+ service.run()
176
+
177
+ # Keep the service running
178
+ try:
179
+ while getattr(service, 'running', False):
180
+ import time
181
+ time.sleep(1)
182
+ except KeyboardInterrupt:
183
+ logger.info("\n🛑 Shutdown requested...")
184
+ finally:
185
+ service.stop()
186
+ logger.info("✅ Service stopped successfully")
187
+
188
+ except Exception as e:
189
+ logger.error(f"❌ Error: {e}")
190
+ traceback.print_exc()
191
+ sys.exit(1)
192
+
193
+
194
+ if __name__ == "__main__":
195
+ main()
@@ -0,0 +1,196 @@
1
+ import logging
2
+ from ..models.config import ConfigEntity # ORM model for server_config
3
+ from ..database.DatabaseManager import DatabaseManager # DatabaseManager for managing sessions
4
+
5
+
6
+ class ConfigurationManager:
7
+ """
8
+ A class to manage server configuration stored in the 'config' database using SQLAlchemy.
9
+ """
10
+
11
+ @staticmethod
12
+ def init_database():
13
+ """
14
+ Initialize the 'config' database and create the `server_config` table if it doesn't exist.
15
+ """
16
+ try:
17
+ DatabaseManager.init_databases()
18
+ logging.info("✅ [APP] Configuration database initialized successfully.")
19
+ except Exception as e:
20
+ logging.exception("❌ [APP] Failed to initialize the configuration database.")
21
+ raise RuntimeError("Database initialization failed.") from e
22
+
23
+ @staticmethod
24
+ def is_config_initialized() -> bool:
25
+ """
26
+ Check if the configuration is already initialized.
27
+
28
+ Returns:
29
+ bool: True if configuration exists, False otherwise.
30
+ """
31
+ session = None
32
+ try:
33
+ session = DatabaseManager.get_session("config")
34
+ config_count = session.query(ConfigEntity).count()
35
+ return config_count > 0 # True if at least one config exists
36
+ except Exception as e:
37
+ logging.exception("❌ [APP] Failed to check if configuration is initialized.")
38
+ return False
39
+ finally:
40
+ if session:
41
+ session.close()
42
+
43
+ @staticmethod
44
+ def set_config(key: str, value: str):
45
+ """
46
+ Set or update a configuration key-value pair in the 'config' database.
47
+
48
+ Args:
49
+ key (str): The configuration key.
50
+ value (str): The configuration value.
51
+ """
52
+ if not key or not isinstance(key, str):
53
+ raise ValueError("⚠️ [APP] The 'key' must be a non-empty string.")
54
+ if not isinstance(value, str):
55
+ raise ValueError("⚠️ [APP] The 'value' must be a string.")
56
+
57
+ session = None
58
+ try:
59
+ session = DatabaseManager.get_session("config")
60
+ logging.info(f"🔧 Attempting to set configuration: {key} = {value}")
61
+ existing_config = session.query(ConfigEntity).filter_by(key=key).first()
62
+ if existing_config:
63
+ logging.info(f"🔄 [APP] Updating configuration key: {key}")
64
+ existing_config.value = value
65
+ else:
66
+ logging.info(f"➕ [APP] Adding new configuration key: {key}")
67
+ new_config = ConfigEntity(key=key, value=value)
68
+ session.add(new_config)
69
+ session.commit()
70
+ logging.info(f"✅ [APP] Configuration key '{key}' set successfully.")
71
+ except Exception as e:
72
+ if session:
73
+ session.rollback()
74
+ logging.exception(f"❌ [APP] Failed to set configuration key '{key}': {e}")
75
+ raise RuntimeError(f"Failed to set configuration key '{key}'") from e
76
+ finally:
77
+ if session:
78
+ session.close()
79
+
80
+ @staticmethod
81
+ def set_config_batch(configs: dict):
82
+ """
83
+ Set or update multiple configuration key-value pairs in the 'config' database in a batch operation.
84
+
85
+ Args:
86
+ configs (dict): A dictionary containing configuration key-value pairs.
87
+ """
88
+ if not isinstance(configs, dict) or not configs:
89
+ raise ValueError("⚠️ [APP] The 'configs' parameter must be a non-empty dictionary.")
90
+
91
+ session = None
92
+ try:
93
+ session = DatabaseManager.get_session("config")
94
+ logging.info(f"🔄 [APP] Attempting to set {len(configs)} configuration keys in batch.")
95
+
96
+ existing_configs = session.query(ConfigEntity).filter(ConfigEntity.key.in_(configs.keys())).all()
97
+ existing_keys = {config.key: config for config in existing_configs}
98
+
99
+ for key, value in configs.items():
100
+ if key in existing_keys:
101
+ logging.info(f"🔄 [APP] Updating configuration key: {key}")
102
+ existing_keys[key].value = value
103
+ else:
104
+ logging.info(f"➕ [APP] Adding new configuration key: {key}")
105
+ new_config = ConfigEntity(key=key, value=value)
106
+ session.add(new_config)
107
+
108
+ session.commit()
109
+ logging.info("✅ [APP] All configuration keys set successfully.")
110
+ except Exception as e:
111
+ if session:
112
+ session.rollback()
113
+ logging.exception(f"❌ [APP] Failed to set batch configuration keys: {e}")
114
+ raise RuntimeError("Failed to set batch configuration keys.") from e
115
+ finally:
116
+ if session:
117
+ session.close()
118
+
119
+ @staticmethod
120
+ def get_config(key: str) -> str:
121
+ """
122
+ Retrieve the value of a specific configuration key from the 'config' database.
123
+
124
+ Args:
125
+ key (str): The configuration key.
126
+
127
+ Returns:
128
+ str: The configuration value, or None if the key does not exist.
129
+ """
130
+ if not key or not isinstance(key, str):
131
+ raise ValueError("⚠️ The 'key' must be a non-empty string.")
132
+
133
+ session = None
134
+ try:
135
+ session = DatabaseManager.get_session("config")
136
+ logging.info(f"🔍 [APP] Retrieving configuration key: {key}")
137
+ config = session.query(ConfigEntity).filter_by(key=key).first()
138
+ if config:
139
+ logging.info(f"✅ [APP] Configuration key '{key}' retrieved successfully.")
140
+ return config.value
141
+ else:
142
+ logging.warning(f"⚠️ [APP] Configuration key '{key}' not found.")
143
+ return None
144
+ except Exception as e:
145
+ logging.exception(f"❌ [APP] Failed to retrieve configuration key '{key}': {e}")
146
+ raise RuntimeError(f"Failed to retrieve configuration key '{key}'") from e
147
+ finally:
148
+ if session:
149
+ session.close()
150
+
151
+ @staticmethod
152
+ def get_all_configs() -> dict:
153
+ """
154
+ Retrieve all configuration key-value pairs from the 'config' database.
155
+
156
+ Returns:
157
+ dict: A dictionary of all configuration key-value pairs.
158
+ """
159
+ session = None
160
+ try:
161
+ session = DatabaseManager.get_session("config")
162
+ logging.info("🔍 [APP] Retrieving all configuration keys.")
163
+ configs = session.query(ConfigEntity).all()
164
+ if configs:
165
+ logging.info("✅ [APP] All configuration keys retrieved successfully.")
166
+ return {config.key: config.value for config in configs}
167
+ else:
168
+ logging.info("⚠️ [APP] No configuration keys found.")
169
+ return {}
170
+ except Exception as e:
171
+ logging.exception("❌ [APP] Failed to retrieve all configuration keys.")
172
+ raise RuntimeError("Failed to retrieve all configuration keys.") from e
173
+ finally:
174
+ if session:
175
+ session.close()
176
+
177
+ @staticmethod
178
+ def print_config():
179
+ """
180
+ Print all configuration key-value pairs to the console.
181
+ """
182
+ try:
183
+ configs = ConfigurationManager.get_all_configs()
184
+ if configs:
185
+ print("📄 Current Configuration:")
186
+ for key, value in configs.items():
187
+ # Mask sensitive information completely
188
+ if key.lower() in ['token', 'password']:
189
+ print(f" 🔹 {key}: ***")
190
+ else:
191
+ print(f" 🔹 {key}: {value}")
192
+ else:
193
+ print("⚠️ No configuration found. Please initialize the configuration.")
194
+ except Exception as e:
195
+ logging.exception("❌ Failed to print configuration keys.")
196
+ raise RuntimeError("Failed to print configuration keys.") from e
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,219 @@
1
+ import os
2
+ from alembic.migration import MigrationContext
3
+ from alembic.operations import Operations
4
+ from alembic.operations.ops import AlterColumnOp
5
+ from alembic.autogenerate import produce_migrations
6
+ from sqlalchemy import Column, Connection, Table, create_engine, inspect, MetaData, select, text
7
+ from sqlalchemy.orm import sessionmaker, declarative_base, scoped_session
8
+ from pathlib import Path
9
+ import logging
10
+
11
+ # Base class for ORM models
12
+ Base = declarative_base()
13
+
14
+ # Dictionary to hold engines and session factories
15
+ ENGINES = {}
16
+ SESSION_FACTORIES = {}
17
+ _initialized = False # To track whether databases have been initialized
18
+ _storage_path = None # Global storage path
19
+
20
+ def set_storage_path(storage_path: str):
21
+ """Set the global storage path for the application."""
22
+ global _storage_path
23
+ _storage_path = Path(storage_path)
24
+
25
+ def _get_storage_paths():
26
+ """Get storage paths using the configured storage path."""
27
+ global _storage_path
28
+ if _storage_path is None:
29
+ _storage_path = Path("data") # Default fallback
30
+
31
+ return {
32
+ "db": _storage_path / "sqlite",
33
+ "files": _storage_path / "files",
34
+ "models": _storage_path / "model",
35
+ }
36
+
37
+ def get_storage_path(subdir: str = None) -> Path:
38
+ """Get a storage path for a specific subdirectory."""
39
+ global _storage_path
40
+ if _storage_path is None:
41
+ _storage_path = Path("data") # Default fallback
42
+
43
+ if subdir:
44
+ return _storage_path / subdir
45
+ return _storage_path
46
+
47
+ def alter_drop_not_null_sqlite(conn: Connection, table_name: str, column_name: str):
48
+ # Load existing metadata
49
+ metadata = MetaData()
50
+ metadata.reflect(conn)
51
+
52
+ table = metadata.tables[table_name]
53
+
54
+ # Create new table with updated schema (column without NOT NULL)
55
+ new_columns = []
56
+ for col in table.columns:
57
+ new_col = Column(
58
+ col.name,
59
+ col.type,
60
+ nullable=(True if col.name == column_name else col.nullable),
61
+ primary_key=col.primary_key,
62
+ default=col.default,
63
+ server_default=col.server_default
64
+ )
65
+ new_columns.append(new_col)
66
+
67
+ new_table = Table(f"{table_name}_new", MetaData(), *new_columns)
68
+ new_table.create(conn)
69
+
70
+ # Copy data
71
+ data = conn.execute(select(table)).mappings().all()
72
+ if data:
73
+ conn.execute(new_table.insert(), data)
74
+
75
+ # Drop old table, rename new table
76
+ table.drop(conn)
77
+ conn.execute(text(f"ALTER TABLE {table_name}_new RENAME TO {table_name}"))
78
+
79
+ class DatabaseManager:
80
+ """
81
+ Manages multiple database connections, engines, and session factories.
82
+ """
83
+
84
+ @staticmethod
85
+ def init_databases():
86
+ """
87
+ Initialize databases and create tables for all models based on their `__bind_key__`.
88
+ """
89
+ global _initialized
90
+ if _initialized:
91
+ logging.info("✅ Databases already initialized.")
92
+ return
93
+
94
+ # Get storage paths with lazy loading
95
+ storage_paths = _get_storage_paths()
96
+
97
+ # Define database paths
98
+ DB_PATHS = {
99
+ "default": storage_paths["db"] / "default.db",
100
+ "config": storage_paths["db"] / "config.db",
101
+ "logging": storage_paths["db"] / "logging.db",
102
+ }
103
+
104
+ # Initialize engines and session factories for each database
105
+ for name, path in DB_PATHS.items():
106
+ path.parent.mkdir(parents=True, exist_ok=True) # Ensure directory exists
107
+ engine = create_engine(f"sqlite:///{path.as_posix()}")
108
+ ENGINES[name] = engine
109
+ SESSION_FACTORIES[name] = scoped_session(sessionmaker(bind=engine)) # Use scoped sessions
110
+ DatabaseManager.synchronize(name)
111
+
112
+ _initialized = True
113
+ logging.info("✅ [APP] Databases initialized successfully.")
114
+
115
+ @staticmethod
116
+ def synchronize(db_name):
117
+ engine = ENGINES[db_name]
118
+
119
+ models = []
120
+ for mapper_object in Base.registry.mappers:
121
+ model_class = mapper_object.class_
122
+ if hasattr(model_class, "__bind_key__") and model_class.__bind_key__ == db_name:
123
+ models.append(model_class)
124
+
125
+ inspector = inspect(engine)
126
+ dialect = inspector.dialect.name
127
+ existing_tables = inspector.get_table_names()
128
+
129
+ if not existing_tables:
130
+ if not models:
131
+ return
132
+
133
+ for model in models:
134
+ model.metadata.create_all(bind=engine)
135
+
136
+ logging.info(f"✅ [APP] Created initial tables for '{db_name}' database")
137
+ return
138
+
139
+ connection: Connection = engine.connect()
140
+ try:
141
+ with connection.begin():
142
+ context = MigrationContext.configure(connection)
143
+
144
+ target_metadata = MetaData()
145
+ for model in models:
146
+ for table in model.metadata.tables.values():
147
+ if table.name not in target_metadata.tables:
148
+ table.tometadata(target_metadata)
149
+
150
+ ops_list = produce_migrations(context, target_metadata).upgrade_ops_list
151
+
152
+ if ops_list[0].ops:
153
+ logging.info(f"⚡ [APP] Detected schema changes in '{db_name}' database. Applying migrations...")
154
+
155
+ op = Operations(context)
156
+ for upgrade_ops in ops_list:
157
+ for schema_element in upgrade_ops.ops:
158
+ if hasattr(schema_element, 'ops'):
159
+ for table_op in schema_element.ops:
160
+ if dialect == 'sqlite' and isinstance(table_op, AlterColumnOp):
161
+ alter_drop_not_null_sqlite(connection, table_op.table_name, table_op.column_name)
162
+ else:
163
+ op.invoke(table_op)
164
+ else:
165
+ if dialect == 'sqlite' and isinstance(schema_element, AlterColumnOp):
166
+ alter_drop_not_null_sqlite(connection, schema_element.table_name, schema_element.column_name)
167
+ else:
168
+ op.invoke(schema_element)
169
+
170
+ for diff in upgrade_ops.as_diffs():
171
+ if diff[0] == 'add_table':
172
+ table = diff[1]
173
+ logging.info(f"🆕 [APP] Creating new table: {table.name}")
174
+
175
+ elif diff[0] == 'remove_table':
176
+ table_name = diff[1].name
177
+ logging.info(f"🗑️ [APP] Removing table: {table_name}")
178
+
179
+ elif diff[0] == 'add_column':
180
+ table_name, column = diff[2], diff[3]
181
+ logging.info(f"➕ [APP] Adding column: {column.name} to table {table_name}")
182
+
183
+ elif diff[0] == 'remove_column':
184
+ table_name, column = diff[2], diff[3]
185
+ logging.info(f"➖ [APP] Removing column: {column.name} from table {table_name}")
186
+
187
+ logging.info(f"✅ [APP] Schema migrations for '{db_name}' database completed successfully")
188
+ else:
189
+ logging.info(f"✅ [APP] No schema changes detected for '{db_name}' database")
190
+
191
+ finally:
192
+ connection.close()
193
+
194
+ @staticmethod
195
+ def get_session(db_name: str = "default"):
196
+ """
197
+ Provide a session object for the specified database.
198
+
199
+ Args:
200
+ db_name (str): The name of the database connection.
201
+
202
+ Returns:
203
+ sqlalchemy.orm.Session: A session for the specified database.
204
+
205
+ Raises:
206
+ ValueError: If the database name is invalid.
207
+ """
208
+ if db_name not in SESSION_FACTORIES:
209
+ raise ValueError(f"❌ Invalid database name: {db_name}")
210
+ return SESSION_FACTORIES[db_name]
211
+
212
+ @staticmethod
213
+ def shutdown():
214
+ """
215
+ Cleanly dispose of all database connections.
216
+ """
217
+ for name, engine in ENGINES.items():
218
+ engine.dispose()
219
+ logging.info("🛑 All database connections have been closed.")
@@ -0,0 +1 @@
1
+