smdb-dbm 0.1.0__tar.gz

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,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: smdb_dbm
3
+ Version: 0.1.0
4
+ Summary: Database Manager for Server Monitoring Discord bot and other projects
5
+ Home-page: https://github.com/NightKey/smdb-dbm
6
+ Author: Janthó Dávid
7
+ Author-email: davidjantho@gmail.com
8
+ Project-URL: Bug Tracker, https://github.com/NightKey/smdb-dbm/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: Freeware
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.14
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Server Monitoring Discord Bot - Data Base Manager
17
+
18
+ This is a database manager used by the [Server Monitoring Discord Bot](https://github.com/NightKey/Server-monitoring-discord-bot) and other projects created by me.
19
+
@@ -0,0 +1,4 @@
1
+ # Server Monitoring Discord Bot - Data Base Manager
2
+
3
+ This is a database manager used by the [Server Monitoring Discord Bot](https://github.com/NightKey/Server-monitoring-discord-bot) and other projects created by me.
4
+
@@ -0,0 +1,9 @@
1
+ [build-system]
2
+ requires = [
3
+ "setuptools>=42",
4
+ "wheel>=0.45.1",
5
+ "requests>=2.32.5",
6
+ "aiosqlite>=0.22.1",
7
+ "smdb_logger>=1.0.0"
8
+ ]
9
+ build-backend = "setuptools.build_meta"
@@ -0,0 +1,25 @@
1
+ [metadata]
2
+ name = smdb_dbm
3
+ version = 0.1.0
4
+ author = Janthó Dávid
5
+ author_email = davidjantho@gmail.com
6
+ description = Database Manager for Server Monitoring Discord bot and other projects
7
+ long_description = file: README.md
8
+ long_description_content_type = text/markdown
9
+ url = https://github.com/NightKey/smdb-dbm
10
+ project_urls =
11
+ Bug Tracker = https://github.com/NightKey/smdb-dbm/issues
12
+ classifiers =
13
+ Development Status :: 4 - Beta
14
+ Programming Language :: Python :: 3
15
+ License :: Freeware
16
+ Operating System :: OS Independent
17
+
18
+ [options]
19
+ packages = find:
20
+ python_requires = >=3.14
21
+
22
+ [egg_info]
23
+ tag_build =
24
+ tag_date = 0
25
+
@@ -0,0 +1,313 @@
1
+ import asyncio
2
+ from abc import abstractmethod, ABC
3
+ from asyncio import Lock
4
+ from functools import wraps
5
+ from typing import Any, List, Tuple
6
+ from os import path, mkdir
7
+
8
+ import aiosqlite
9
+ from aiosqlite import Connection
10
+ from smdb_logger import Logger
11
+
12
+ from smdb_db_manager.utils import Timer, DBStatus, ClosedException, Version, DefaultTableNames
13
+
14
+ class DBManager(ABC):
15
+ lock: Lock = Lock()
16
+ db: Connection | None = None
17
+ status: DBStatus
18
+ logger: Logger
19
+ data_path: str
20
+ db_name: str
21
+
22
+ # <editor-fold desc="Wrappers">
23
+ @staticmethod
24
+ def async_timed(func):
25
+ """
26
+ Times the provided async function's runtime.
27
+ """
28
+ @wraps(func)
29
+ async def async_timed_wrapper(self: DBManager, *args, **kwargs):
30
+ timer = Timer()
31
+ result = await func(self, *args, **kwargs)
32
+ self.logger.debug(f"Function {func.__name__} returned {result}")
33
+ self.logger.trace(f"Function {func.__name__} finished under {timer.stop(1_000_000)} ms")
34
+ return result
35
+
36
+ return async_timed_wrapper
37
+
38
+ @staticmethod
39
+ def async_required_argument(arguments: List[str]):
40
+ """
41
+ Ensures at least one provided argument is present of the 'arguments' list provided.
42
+ Should only be used with functions using multiple optional arguments, but requiring at least one to be present.
43
+ """
44
+ def async_required_argument_decorator(func):
45
+ @wraps(func)
46
+ async def wrapper(self: DBManager, *args, **kwargs):
47
+ for argument in arguments:
48
+ if argument in kwargs:
49
+ break
50
+ else:
51
+ raise KeyError(f"At least one of the following argument needs to be present: {arguments}")
52
+ return await func(self, *args, **kwargs)
53
+
54
+ return wrapper
55
+
56
+ return async_required_argument_decorator
57
+
58
+ @staticmethod
59
+ def async_database_safe(func):
60
+ """
61
+ Handles waiting for the database to be running, and lock to be available to run the function provided,
62
+ handling the database connection's closure when the function finishes or fails.
63
+ This wrapper will fail the call when database is in 'stopping', 'stopped' or 'failed' status, so no threads will be hanging.
64
+ """
65
+ @wraps(func)
66
+ async def async_database_safe_wrapper(cls: DBManager, *args, **kwargs):
67
+ during_init: bool = getattr(func, "during_init", False)
68
+ fail_value: Any = getattr(func, "fail_value", False)
69
+ fail_exception_index: int | str | None = getattr(func, "fail_exception_index", None)
70
+ if cls.status > DBStatus.RUNNING:
71
+ cls.logger.exception(
72
+ ClosedException(f"Function {func.__name__} called after database stop was called or the database failed!"))
73
+ return fail_value
74
+ if cls.status != DBStatus.RUNNING and not during_init:
75
+ cls.logger.trace(f"Called {func.__name__} called while db not ready, waiting")
76
+ while cls.status < DBStatus.RUNNING:
77
+ await asyncio.sleep(0.5)
78
+ cls.logger.trace(f"Acquiring lock for function {func.__name__}")
79
+ await cls.lock.acquire()
80
+ if cls.status > DBStatus.STOPPING:
81
+ cls.logger.exception(
82
+ ClosedException(f"Function {func.__name__} called after database was stopped or failed!"))
83
+ cls.lock.release()
84
+ return fail_value
85
+ cls.logger.debug(f"Running {func.__name__} with keyword arguments: {kwargs}")
86
+ cls.db = await aiosqlite.connect(cls.db_path).__aenter__()
87
+ await cls.db.execute("PRAGMA foreign_keys = ON")
88
+ try:
89
+ return await func(cls, *args, **kwargs)
90
+ except BaseException as ex:
91
+ cls.logger.error(f"Exception during {func.__name__}", ex)
92
+ if fail_exception_index is not None:
93
+ if isinstance(fail_value, (list, tuple, dict)):
94
+ fail_value[fail_exception_index] = ex
95
+ else:
96
+ fail_value = ex
97
+ return fail_value
98
+ finally:
99
+ cls.logger.debug(f"Closing db connection for {func.__name__}")
100
+ await cls.db.close()
101
+ cls.db = None
102
+ cls.lock.release()
103
+ cls.logger.trace(f"Lock released by {func.__name__}")
104
+
105
+ return async_database_safe_wrapper
106
+
107
+ @staticmethod
108
+ def with_fail_value(fail_value: Any):
109
+ """
110
+ When provided before 'async_database_safe' wrapper, will ensure the fail case will be the provided value
111
+ """
112
+ def with_fail_value_decorator(func):
113
+ func.fail_value = fail_value
114
+ return func
115
+
116
+ return with_fail_value_decorator
117
+
118
+ @staticmethod
119
+ def fail_with_exception(func):
120
+ """
121
+ When provided before 'async_database_safe' wrapper, will ensure the fail case will contain the raised exception - if present - on the -1st index or as the return value.
122
+ The wrapper 'with_fail_value' is not needed for this to overwrite the result on a failure.
123
+ """
124
+ func.fail_exception_index = -1
125
+ return func
126
+
127
+ @staticmethod
128
+ def async_during_init(func):
129
+ """
130
+ Overrides the "async_database_safe" wrapper's safety net of waiting for database to be running, so it can be used during initialization
131
+ """
132
+ func.during_init = True
133
+ return func
134
+
135
+ # </editor-fold>
136
+
137
+ @property
138
+ def db_path(self) -> str:
139
+ return path.join(self.data_path, self.db_name)
140
+
141
+ def __init__(self, logger: Logger, data_path: str, db_name: str = "database.db", version: Version = Version(0, 0, 1)):
142
+ """
143
+ :param logger: 'smdb_logger' for detailed logging when using the wrappers
144
+ :param data_path: The path for the data folder where the database will be held:
145
+ :param db_name: The name of the database file.
146
+ :param version: The version of the database to use. Will be used to migrate to newer versions.
147
+ """
148
+ self.data_path = data_path
149
+ self.db_name = db_name
150
+ self.logger = logger
151
+ self.status = DBStatus.STARTING
152
+ self.init_task: asyncio.Task | None = None
153
+ loop = asyncio.get_event_loop()
154
+ if path.exists(self.db_path):
155
+ self.init_task = loop.create_task(self.__ensure_version(version))
156
+ return
157
+ self.init_task = loop.create_task(self.__prepare_versioned_db(version))
158
+
159
+ async def ensure_ready(self) -> None:
160
+ """
161
+ Will wait while the database is starting, then clean up the init_task, raising any exception that was raised during init.
162
+ :return:None
163
+ """
164
+ while self.status == DBStatus.STARTING:
165
+ await asyncio.sleep(0.1)
166
+ if self.init_task is None: return
167
+ _exception = self.init_task.exception()
168
+ self.init_task = None
169
+ if _exception is not None: raise _exception
170
+
171
+ async def __ensure_version(self, version: Version):
172
+ correct, current_version = await self.__check_version(version)
173
+ if not correct:
174
+ if isinstance(current_version, BaseException):
175
+ self.status = DBStatus.FAILED
176
+ raise current_version
177
+ result = await self.migrate_db(current_version, version)
178
+ if not result:
179
+ self.status = DBStatus.FAILED
180
+ raise Exception(f"Failed to migrate DB to version {version}")
181
+ self.logger.info(f"Database at {path.abspath(self.db_path)} ready")
182
+ self.status = DBStatus.RUNNING
183
+
184
+ @async_timed
185
+ async def __prepare_versioned_db(self, version: Version):
186
+ await self.lock.acquire()
187
+ if not path.exists(self.data_path):
188
+ mkdir(self.data_path)
189
+ async with aiosqlite.connect(self.db_path) as db:
190
+ self.logger.trace(f"Creating {DefaultTableNames.Version.value} table with value {version}")
191
+ await db.execute(
192
+ f"""
193
+ CREATE TABLE IF NOT EXISTS {DefaultTableNames.Version.value} (
194
+ major INTEGER NOT NULL,
195
+ minor INTEGER NOT NULL,
196
+ patch INTEGER NOT NULL,
197
+ PRIMARY KEY (major, minor, patch)
198
+ ) STRICT;
199
+ """
200
+ )
201
+ self.logger.trace(f"Inserting version to {DefaultTableNames.Version.value} table")
202
+ await db.execute(
203
+ f"""
204
+ INSERT INTO {DefaultTableNames.Version.value} (major, minor, patch)
205
+ VALUES (?, ?, ?);
206
+ """,
207
+ version.to_db()
208
+ )
209
+ await db.commit()
210
+ self.lock.release()
211
+ await self.init_db()
212
+ self.status = DBStatus.RUNNING
213
+
214
+ @async_database_safe
215
+ @async_during_init
216
+ @with_fail_value([None, None])
217
+ @fail_with_exception
218
+ @async_timed
219
+ async def __check_version(self, version: Version) -> Tuple[bool, Version]:
220
+ rows = await self.db.execute_fetchall(
221
+ f"""
222
+ SELECT major, minor, patch FROM {DefaultTableNames.Version.name}
223
+ """
224
+ )
225
+ if len(rows) != 1:
226
+ raise ValueError(f"Database version contained {len(rows)} element, but 1 was expected!\nElements: {rows}")
227
+ db_version = Version.from_db(rows[0])
228
+ if db_version > version:
229
+ raise ValueError(f"DB version {db_version} is bigger than required version {version}")
230
+ return db_version == version, db_version
231
+
232
+ @async_database_safe
233
+ @async_timed
234
+ async def update_version(self, version: Version) -> bool:
235
+ """
236
+ Updates the database version to the new version.
237
+ :param version:
238
+ :return: True if the update was successful otherwise False
239
+ """
240
+ await self.db.execute(
241
+ f"""
242
+ UPDATE {DefaultTableNames.Version.value}
243
+ SET major = ?, minor = ?, patch = ?
244
+ """,
245
+ version.to_db()
246
+ )
247
+ await self.db.commit()
248
+ return True
249
+
250
+ @async_timed
251
+ async def close(self) -> None:
252
+ """
253
+
254
+ :return:
255
+ """
256
+ if self.status > DBStatus.RUNNING:
257
+ self.logger.warning(f"Close called while status is {self.status.name}")
258
+ return
259
+ self.logger.info("Close called!")
260
+ self.status = DBStatus.STOPPING
261
+ self.logger.trace("Acquiring lock for close")
262
+ await self.lock.acquire()
263
+ self.status = DBStatus.STOPPED
264
+ self.lock.release()
265
+ self.logger.trace("Close lock released")
266
+
267
+ @classmethod
268
+ async def create[T: DBManager](cls: type[T], logger: Logger, data_path: str, db_name: str = "database.db", version: Version = Version(0, 0, 1)) -> T:
269
+ """Async wrapper for creating the class.
270
+ :param logger: 'smdb_logger' for detailed logging when using the wrappers
271
+ :param data_path: The path for the data folder where the database will be held
272
+ :param db_name: The name of the database file.
273
+ :param version: The version of the database to use. Will be used to migrate to newer versions.
274
+ :return: The class created"""
275
+ result = cls(logger, data_path, db_name, version)
276
+ await result.ensure_ready()
277
+ return result
278
+
279
+ @async_database_safe
280
+ @async_during_init
281
+ @async_timed
282
+ @fail_with_exception
283
+ @abstractmethod
284
+ async def migrate_db(self, current: Version, target: Version) -> bool:
285
+ """
286
+ Will be called when version change was detected and migration is needed.
287
+ The following decorators are needed for ease of use and safety:
288
+ * @DBManager.async_database_safe
289
+ * @DBManager.async_during_init
290
+ * @DBManager.async_timed
291
+ * @DBManager.fail_with_exception
292
+ :param current: Version: The current version of the DB.
293
+ :param target: Version: The target version to migrate to.
294
+ :return: True if migration was successful otherwise False.
295
+ """
296
+ pass
297
+
298
+ @async_database_safe
299
+ @async_during_init
300
+ @async_timed
301
+ @fail_with_exception
302
+ @abstractmethod
303
+ async def init_db(self) -> None:
304
+ """
305
+ Will be called to initialize the db, create all the default tables and fill in any default values. Version table already created.
306
+ The following decorators are needed for ease of use and safety:
307
+ * @DBManager.async_database_safe
308
+ * @DBManager.async_during_init
309
+ * @DBManager.async_timed
310
+ * @DBManager.fail_with_exception
311
+ :return:None
312
+ """
313
+ pass
@@ -0,0 +1 @@
1
+ from .SMDB_DBM import DBManager
@@ -0,0 +1,4 @@
1
+ class ClosedException(Exception):
2
+ def __init__(self, message: str):
3
+ self.message = message
4
+ super().__init__(self.message)
@@ -0,0 +1,17 @@
1
+ from enum import Enum
2
+
3
+
4
+ class DBStatus(Enum):
5
+ STARTING = 0
6
+ RUNNING = 1
7
+ STOPPING = 2
8
+ STOPPED = 3
9
+ FAILED = 4
10
+
11
+ def __lt__(self, other) -> bool:
12
+ if not isinstance(other, DBStatus): return False
13
+ return self.value < other.value
14
+
15
+ def __gt__(self, other) -> bool:
16
+ if not isinstance(other, DBStatus): return False
17
+ return self.value > other.value
@@ -0,0 +1,5 @@
1
+ from enum import Enum
2
+
3
+
4
+ class DefaultTableNames(Enum):
5
+ Version = "Version"
@@ -0,0 +1,13 @@
1
+ from time import time_ns
2
+
3
+
4
+ class Timer:
5
+ start: float
6
+ end: float
7
+
8
+ def __init__(self):
9
+ self.start = time_ns()
10
+
11
+ def stop(self, divisor: float = 1.0) -> float:
12
+ self.end = time_ns()
13
+ return (self.end - self.start) / divisor
@@ -0,0 +1,36 @@
1
+ from dataclasses import dataclass
2
+ from typing import Tuple
3
+
4
+
5
+ @dataclass
6
+ class Version:
7
+ major: int
8
+ minor: int
9
+ patch: int
10
+
11
+ def __eq__(self, other) -> bool:
12
+ if not isinstance(other, Version): return False
13
+ return self.major == other.major and self.minor == other.minor and self.patch == other.patch
14
+
15
+ def __gt__(self, other):
16
+ if not isinstance(other, Version): return False
17
+ return (self.major > other.major or
18
+ (self.major == other.major and self.minor > other.minor) or
19
+ (self.major == other.major and self.minor == other.minor and self.patch > other.patch))
20
+
21
+ def __lt__(self, other):
22
+ if not isinstance(other, Version): return False
23
+ return (self.major < other.major or
24
+ (self.major == other.major and self.minor < other.minor) or
25
+ (self.major == other.major and self.minor == other.minor and self.patch < other.patch))
26
+
27
+ def to_db(self) -> Tuple[int, int, int]:
28
+ return self.major, self.minor, self.patch
29
+
30
+ @staticmethod
31
+ def from_db(row: Tuple[int, int, int]) -> 'Version':
32
+ return Version(
33
+ int(row[0]),
34
+ int(row[1]),
35
+ int(row[2])
36
+ )
@@ -0,0 +1,5 @@
1
+ from .Timer import Timer
2
+ from .ClosedException import ClosedException
3
+ from .DBStatus import DBStatus
4
+ from .Version import Version
5
+ from .DefaultTableNames import DefaultTableNames
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: smdb_dbm
3
+ Version: 0.1.0
4
+ Summary: Database Manager for Server Monitoring Discord bot and other projects
5
+ Home-page: https://github.com/NightKey/smdb-dbm
6
+ Author: Janthó Dávid
7
+ Author-email: davidjantho@gmail.com
8
+ Project-URL: Bug Tracker, https://github.com/NightKey/smdb-dbm/issues
9
+ Classifier: Development Status :: 4 - Beta
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: License :: Freeware
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.14
14
+ Description-Content-Type: text/markdown
15
+
16
+ # Server Monitoring Discord Bot - Data Base Manager
17
+
18
+ This is a database manager used by the [Server Monitoring Discord Bot](https://github.com/NightKey/Server-monitoring-discord-bot) and other projects created by me.
19
+
@@ -0,0 +1,15 @@
1
+ README.md
2
+ pyproject.toml
3
+ setup.cfg
4
+ smdb_db_manager/SMDB_DBM.py
5
+ smdb_db_manager/__init__.py
6
+ smdb_db_manager/utils/ClosedException.py
7
+ smdb_db_manager/utils/DBStatus.py
8
+ smdb_db_manager/utils/DefaultTableNames.py
9
+ smdb_db_manager/utils/Timer.py
10
+ smdb_db_manager/utils/Version.py
11
+ smdb_db_manager/utils/__init__.py
12
+ smdb_dbm.egg-info/PKG-INFO
13
+ smdb_dbm.egg-info/SOURCES.txt
14
+ smdb_dbm.egg-info/dependency_links.txt
15
+ smdb_dbm.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ smdb_db_manager