hypershell 2.6.4__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.
@@ -0,0 +1,232 @@
1
+ # SPDX-FileCopyrightText: 2025 Geoffrey Lentner
2
+ # SPDX-License-Identifier: Apache-2.0
3
+
4
+ """Core interface for database engine and session manager."""
5
+
6
+
7
+ # type annotations
8
+ from __future__ import annotations
9
+ from typing import Any, Type
10
+
11
+ # standard libs
12
+ import sys
13
+ import logging
14
+ from urllib.parse import urlencode
15
+
16
+ # external libs
17
+ from cmdkit.app import exit_status
18
+ from cmdkit.config import Namespace, ConfigurationError
19
+ from sqlalchemy.engine import create_engine, Engine
20
+ from sqlalchemy.pool import StaticPool
21
+ from sqlalchemy.orm import sessionmaker, scoped_session
22
+ from sqlalchemy.exc import ArgumentError
23
+
24
+ # internal libs
25
+ from hypershell.core.config import config
26
+ from hypershell.core.logging import handler
27
+ from hypershell.core.exceptions import display_critical, write_traceback
28
+
29
+ # public interface
30
+ __all__ = ['DatabaseURL', 'engine', 'Session', 'config', 'in_memory', 'schema', ]
31
+
32
+
33
+ class DatabaseURL(dict):
34
+ """
35
+ Dataclass-like representation for database URL.
36
+ Standard arguments apply. Extra arguments are encoded as URL parameters.
37
+
38
+ Example:
39
+ >>> url = DatabaseURL(provider='postgresql', database='mine')
40
+ >>> url.encode()
41
+ 'postgresql://localhost/mine'
42
+ """
43
+
44
+ def __init__(self: DatabaseURL, **fields) -> None:
45
+ """Initialize fields."""
46
+ try:
47
+ super().__init__(provider=fields.pop('provider'),
48
+ database=fields.pop('database', None),
49
+ file=fields.pop('file', None),
50
+ user=fields.pop('user', None),
51
+ password=fields.pop('password', None),
52
+ host=fields.pop('host', None),
53
+ port=fields.pop('port', None),
54
+ parameters=fields)
55
+ except KeyError as _error:
56
+ raise AttributeError('Missing \'provider\'') from _error
57
+ self._validate()
58
+
59
+ def __getattr__(self: DatabaseURL, field: str) -> Any:
60
+ return self.get(field)
61
+
62
+ def __repr__(self: DatabaseURL) -> str:
63
+ """Interactive representation."""
64
+ masked = self.__class__(**self)
65
+ masked['password'] = None if self.password is None else '****'
66
+ value = '<DatabaseURL('
67
+ value += ', '.join([field + '=' + repr(masked.get(field))
68
+ for field in ('provider', 'database', 'file', 'user', 'password', 'host', 'port')
69
+ if masked.get(field) is not None])
70
+ if self.parameters:
71
+ value += ', ' + ', '.join([field + '=' + repr(value)
72
+ for field, value in self.parameters.items()])
73
+ return value + ')>'
74
+
75
+ def _validate(self: DatabaseURL) -> None:
76
+ """Validate provided arguments."""
77
+ if self.provider == 'sqlite':
78
+ self._validate_for_sqlite()
79
+ else:
80
+ self._validate_database()
81
+ self._validate_user_and_password()
82
+
83
+ def _validate_user_and_password(self: DatabaseURL) -> None:
84
+ if self.user is not None and self.password is None:
85
+ raise AttributeError('Must provide \'password\' if \'user\' provided')
86
+ if self.user is None and self.password is not None:
87
+ raise AttributeError('Must provide \'user\' if \'password\' provided')
88
+
89
+ def _validate_for_sqlite(self: DatabaseURL) -> None:
90
+ if self.file is not None and self.database is not None:
91
+ raise AttributeError('Cannot provide both \'file\' and \'database\' for SQLite')
92
+ for field in ('user', 'password', 'host', 'port'):
93
+ if self.get(field) is not None:
94
+ raise AttributeError(f'Cannot provide \'{field}\' for SQLite')
95
+
96
+ def _validate_database(self: DatabaseURL) -> None:
97
+ if self.file:
98
+ raise AttributeError('Cannot provide \'file\' if not SQLite')
99
+ if not self.database:
100
+ raise AttributeError('Must provide \'database\' if not SQLite')
101
+
102
+ def encode(self: DatabaseURL) -> str:
103
+ """Construct URL string with encoded parameters."""
104
+ return ''.join([
105
+ f'{self.provider}://',
106
+ self._format_user_and_password(),
107
+ self._format_host_and_port(),
108
+ self._format_database_or_file(),
109
+ self._format_parameters(),
110
+ ])
111
+
112
+ def _format_parameters(self: DatabaseURL) -> str:
113
+ if self.parameters:
114
+ return '?' + urlencode(self.parameters)
115
+ else:
116
+ return ''
117
+
118
+ def _format_database_or_file(self: DatabaseURL) -> str:
119
+ if self.database:
120
+ return f'/{self.database}'
121
+ elif self.file:
122
+ return f'/{self.file}'
123
+ else:
124
+ return ''
125
+
126
+ def _format_host_and_port(self: DatabaseURL) -> str:
127
+ if self.host and self.port:
128
+ return f'{self.host}:{self.port}'
129
+ elif self.host and not self.port:
130
+ return f'{self.host}'
131
+ elif self.port and not self.host:
132
+ return f'localhost:{self.port}'
133
+ else:
134
+ if self.user or self.password:
135
+ return 'localhost'
136
+ else:
137
+ return ''
138
+
139
+ def _format_user_and_password(self: DatabaseURL) -> str:
140
+ if self.user and self.password:
141
+ return f'{self.user}:{self.password}@'
142
+ else:
143
+ return ''
144
+
145
+ def __str__(self: DatabaseURL) -> str:
146
+ return self.encode()
147
+
148
+ @staticmethod
149
+ def _strip_endings(value: str, *endings: str):
150
+ """Removing instances of each possible `endings` from `value`."""
151
+ r = str(value)
152
+ for ending in endings:
153
+ pos = len(ending)
154
+ if r[-pos:] == ending:
155
+ r = r[:-pos]
156
+ return r
157
+
158
+ @classmethod
159
+ def from_namespace(cls: Type[DatabaseURL], ns: Namespace) -> DatabaseURL:
160
+ fields = {}
161
+ for key in ns.keys():
162
+ key_ = cls._strip_endings(key, '_env', '_eval')
163
+ fields[key_] = getattr(ns, key_)
164
+ return cls(**fields)
165
+
166
+
167
+ # Allowed database providers
168
+ # Mapping translates from name to library/implementation name
169
+ # NOTE: mysql/mariadb and other providers not yet working
170
+ providers = {
171
+ 'sqlite': 'sqlite',
172
+ 'postgres': 'postgresql',
173
+ 'postgresql': 'postgresql',
174
+ }
175
+
176
+
177
+ config = Namespace(config.database.copy())
178
+ schema = config.pop('schema', None)
179
+ engine_echo = config.pop('echo', False)
180
+ connect_args = config.pop('connect_args', {})
181
+
182
+
183
+ # additional parameters for engine creation
184
+ engine_config = {}
185
+
186
+
187
+ # sqlite specific configuration
188
+ in_memory = False
189
+ if config.provider == 'sqlite':
190
+ in_memory = (config.get('file', None) or config.get('database', None)) in ('', ':memory:', None)
191
+ if in_memory:
192
+ engine_config['poolclass'] = StaticPool
193
+ if 'check_same_thread' not in connect_args:
194
+ connect_args['check_same_thread'] = False
195
+
196
+
197
+ def get_url() -> DatabaseURL:
198
+ """Wraps parsing within function."""
199
+ if config.provider not in providers:
200
+ raise ConfigurationError(f'Unsupported database \'{config.provider}\'')
201
+ try:
202
+ params = Namespace({**config, 'provider': providers[config.provider]})
203
+ return DatabaseURL.from_namespace(params)
204
+ except AttributeError as err:
205
+ raise ConfigurationError(str(err)) from err
206
+
207
+
208
+ def get_engine() -> Engine:
209
+ """Wraps engine creation."""
210
+ try:
211
+ if engine_echo:
212
+ logging.getLogger('sqlalchemy.engine').addHandler(handler)
213
+ logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO)
214
+ return create_engine(get_url().encode(), connect_args=connect_args, **engine_config)
215
+ except ArgumentError as err:
216
+ raise ConfigurationError(f'DatabaseURL: {err}') from err
217
+
218
+
219
+ try:
220
+ engine = get_engine()
221
+ factory = sessionmaker(bind=engine)
222
+ Session = scoped_session(factory)
223
+ except ModuleNotFoundError as error:
224
+ if 'psycopg2' in error.args[0]:
225
+ display_critical(f'Missing optional dependency "psycopg2" needed for PostgreSQL', module=__name__)
226
+ sys.exit(exit_status.runtime_error)
227
+ else:
228
+ write_traceback(error, module=__name__)
229
+ sys.exit(exit_status.bad_config)
230
+ except Exception as error:
231
+ write_traceback(error, module=__name__)
232
+ sys.exit(exit_status.bad_config)