keepup-db 0.4.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: keepup-db
3
+ Version: 0.4.0
4
+ Summary: The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: sqlalchemy<3,>=2.0
9
+
10
+ # keepup-db
11
+
12
+ The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
13
+
14
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
15
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
16
+ this distribution carries one part of it. It is installed on its own
17
+ (`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
18
+ and it declares itself to the kernel through its
19
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,10 @@
1
+ # keepup-db
2
+
3
+ The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
4
+
5
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
6
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
7
+ this distribution carries one part of it. It is installed on its own
8
+ (`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
9
+ and it declares itself to the kernel through its
10
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,22 @@
1
+ """The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
2
+
3
+ This distribution provides `datasource` and declares one plugin,
4
+ `db`, in the `keepup.plugins` group. It depends on `keepup-admin`
5
+ for the constructor -- the descriptor, the catalogue, the service registry and
6
+ the lifecycle -- and on nothing of another capability.
7
+
8
+ `keepup.db` moved here in keepup-124, and the old name keeps working for one
9
+ release: `keepup.compat` answers for it and names this distribution when it is not
10
+ installed. The three names below are the declaration the moved module carried.
11
+ """
12
+
13
+ __version__ = "0.4.0"
14
+
15
+ from keepup_db import tables # noqa: E402 - the table language, with the manager
16
+ from keepup_db.manager import ( # noqa: E402 - after the version, as a public face
17
+ DatabaseConfig,
18
+ DatabaseManagerV2,
19
+ db_config,
20
+ )
21
+
22
+ __all__ = ["DatabaseConfig", "DatabaseManagerV2", "db_config", "tables"]
@@ -0,0 +1,603 @@
1
+ """Access to the database: configuration and the pooled manager.
2
+
3
+ `DatabaseManagerV2` is the one way in -- a SQLAlchemy engine with a pool and a
4
+ session context manager, named parameters only. The legacy `DatabaseManager`,
5
+ a layer over raw drivers with positional parameters that opened a connection
6
+ per call, is gone (0.2.0); code written against a cursor takes one from the
7
+ pool with `DatabaseManagerV2.raw_connection()`.
8
+
9
+ Where the database is comes from the environment when `DB_TYPE` is set and from
10
+ `config/postgres.properties` otherwise. Both paths are read from the working
11
+ directory of the started application, which is how two applications on one
12
+ framework keep separate databases. SQLite exists for development only.
13
+ """
14
+
15
+ import asyncio
16
+ import contextvars
17
+ import os
18
+ import threading
19
+ import time
20
+ import warnings
21
+ from contextlib import contextmanager
22
+ from typing import Optional, List, Dict, Any
23
+
24
+ from sqlalchemy import create_engine, event, exc, text
25
+ from sqlalchemy.engine import Engine
26
+ from sqlalchemy.orm import sessionmaker, Session
27
+
28
+ _RETURNED_AT = "keepup_returned_at"
29
+
30
+
31
+ def _ping_when_idle(engine, idle_seconds: int) -> None:
32
+ """Ping a connection at checkout only when it has lain in the pool a while.
33
+
34
+ The pool used to ping on every checkout (pool_pre_ping), and a request that
35
+ takes three connections pinged three times -- on the load stand (keepup-53)
36
+ a fifth of what a replica spent. A connection handed back a moment ago has
37
+ had no time to be closed by the database or the network; one that has lain
38
+ longer than ``idle_seconds`` is pinged, and one that fails the ping is
39
+ replaced, exactly as the built-in ping does (keepup-86). The ping is the
40
+ dialect's own. A connection the database dropped while it was busy a
41
+ moment ago still fails its next statement once, as it would have after a
42
+ ping made just before the drop.
43
+ """
44
+ if not isinstance(engine, Engine):
45
+ # A stand-in engine (a test's mock) has no pool events to listen to.
46
+ return
47
+
48
+ @event.listens_for(engine, "connect")
49
+ def _fresh(dbapi_connection, record):
50
+ record.info[_RETURNED_AT] = time.monotonic()
51
+
52
+ @event.listens_for(engine, "checkin")
53
+ def _returned(dbapi_connection, record):
54
+ if record is not None:
55
+ record.info[_RETURNED_AT] = time.monotonic()
56
+
57
+ @event.listens_for(engine, "checkout")
58
+ def _ping(dbapi_connection, record, proxy):
59
+ returned = record.info.get(_RETURNED_AT)
60
+ if idle_seconds and returned is not None and time.monotonic() - returned < idle_seconds:
61
+ return
62
+ try:
63
+ engine.dialect.do_ping(dbapi_connection)
64
+ except Exception as error:
65
+ # The pool discards the connection and hands out a fresh one.
66
+ raise exc.DisconnectionError(f"connection failed its ping: {error}") from error
67
+
68
+
69
+ def _reads_only(query: str) -> bool:
70
+ """Whether the statement is a plain read: a SELECT that takes no lock."""
71
+ body = query.lstrip().lstrip("(").lstrip()
72
+ return body[:6].upper() == "SELECT" and "FOR UPDATE" not in query.upper() \
73
+ and "FOR SHARE" not in query.upper()
74
+
75
+
76
+ #: The session of the shared_session() block running on this thread, if any,
77
+ #: with the thread it belongs to.
78
+ _SHARED_SESSION: contextvars.ContextVar = contextvars.ContextVar(
79
+ "keepup_shared_session", default=None)
80
+
81
+ #: What an application may import from this module. Everything else is
82
+ #: internal and may change without notice -- see doc/keepup.md.
83
+ __all__ = [
84
+ "DatabaseConfig",
85
+ "DatabaseManagerV2",
86
+ "db_config",
87
+ ]
88
+
89
+
90
+ class DatabaseConfig:
91
+ """Database configuration."""
92
+
93
+ def __init__(self):
94
+ self.db_type = 'sqlite'
95
+ self.db_host = 'localhost'
96
+ self.db_port = '5432'
97
+ self.db_name = 'keepup_app'
98
+ self.db_user = 'postgres'
99
+ self.db_password = ''
100
+ self.db_path = 'data/keepup_app.db'
101
+ # The defaults come first and the configuration over them: the other way
102
+ # round, DB_POOL_SIZE and db.pool.size were read and then written over
103
+ # with these very numbers, and never applied (keepup-38).
104
+ self.pool_size = 5
105
+ self.pool_max_overflow = 10
106
+ self.pool_timeout = 30
107
+ self.pool_recycle = 3600
108
+ #: Seconds a connection may lie in the pool and still be handed out
109
+ #: without a ping; 0 pings on every checkout (keepup-86).
110
+ self.pool_ping_after_idle = 10
111
+ self._load_config()
112
+
113
+ def apply_pool(self, size=None, max_overflow=None, timeout=None, recycle=None,
114
+ ping_after_idle=None) -> bool:
115
+ """Take the application's pool values over the deployment's; True if any changed."""
116
+ changed = False
117
+ for field_name, value in (("pool_size", size), ("pool_max_overflow", max_overflow),
118
+ ("pool_timeout", timeout), ("pool_recycle", recycle),
119
+ ("pool_ping_after_idle", ping_after_idle)):
120
+ if value is not None and getattr(self, field_name) != int(value):
121
+ setattr(self, field_name, int(value))
122
+ changed = True
123
+ return changed
124
+
125
+ def _load_config(self):
126
+ self.db_type = os.getenv('DB_TYPE', '').lower()
127
+ if not self.db_type:
128
+ self._load_from_file()
129
+ else:
130
+ self._load_from_env()
131
+
132
+ def _load_from_env(self):
133
+ self.db_host = os.getenv('DB_HOST', 'localhost')
134
+ self.db_port = os.getenv('DB_PORT', '5432')
135
+ self.db_name = os.getenv('DB_NAME', 'keepup')
136
+ self.db_user = os.getenv('DB_USER', 'postgres')
137
+ self.db_password = os.getenv('DB_PASSWORD', '')
138
+ self.db_path = os.getenv('DB_PATH', 'data/keepup.db')
139
+
140
+ self.pool_size = int(os.getenv('DB_POOL_SIZE', '5'))
141
+ self.pool_max_overflow = int(os.getenv('DB_POOL_MAX_OVERFLOW', '10'))
142
+ self.pool_timeout = int(os.getenv('DB_POOL_TIMEOUT', '30'))
143
+ self.pool_recycle = int(os.getenv('DB_POOL_RECYCLE', '3600'))
144
+ self.pool_ping_after_idle = int(os.getenv('DB_POOL_PING_AFTER_IDLE', '10'))
145
+
146
+ if self.db_type == 'sqlite':
147
+ os.makedirs(os.path.dirname(self.db_path), exist_ok=True)
148
+
149
+ def _load_from_file(self):
150
+ self.config_file = "config/postgres.properties"
151
+ config = {}
152
+ if os.path.exists(self.config_file):
153
+ with open(self.config_file, 'r') as f:
154
+ for line in f:
155
+ line = line.strip()
156
+ if line and not line.startswith('#'):
157
+ if '=' in line:
158
+ key, value = line.split('=', 1)
159
+ config[key.strip()] = value.strip()
160
+
161
+ self.db_type = config.get('db.type', 'sqlite').lower()
162
+ self.db_host = config.get('db.host', 'localhost')
163
+ self.db_port = config.get('db.port', '5432')
164
+ self.db_name = config.get('db.name', 'keepup')
165
+ self.db_user = config.get('db.user', 'postgres')
166
+ # The file names where the database is; the password never lives in it.
167
+ self.db_password = os.getenv('DB_PASSWORD') or config.get('db.password', '')
168
+ self.db_path = config.get('db.path', 'data/keepup.db')
169
+
170
+ self.pool_size = int(config.get('db.pool.size', '5'))
171
+ self.pool_max_overflow = int(config.get('db.pool.max_overflow', '10'))
172
+ self.pool_timeout = int(config.get('db.pool.timeout', '30'))
173
+ self.pool_recycle = int(config.get('db.pool.recycle', '3600'))
174
+
175
+ if self.db_type == 'sqlite':
176
+ db_dir = os.path.dirname(self.db_path)
177
+ if db_dir:
178
+ os.makedirs(db_dir, exist_ok=True)
179
+
180
+ def get_connection_string(self) -> str:
181
+ if self.db_type == 'postgres':
182
+ # The driver is named, not left to SQLAlchemy: from 2.1 a bare
183
+ # postgresql:// means psycopg 3, which this package does not
184
+ # install, and every fresh installation failed to reach the
185
+ # database at all (keepup-84). psycopg2 is the declared dependency.
186
+ return (f"postgresql+psycopg2://{self.db_user}:{self.db_password}"
187
+ f"@{self.db_host}:{self.db_port}/{self.db_name}")
188
+ else:
189
+ return f"sqlite:///{self.db_path}"
190
+
191
+ def get_sqlalchemy_engine_params(self) -> dict:
192
+ params = {}
193
+ if self.is_postgres():
194
+ params.update({
195
+ 'pool_size': self.pool_size,
196
+ 'max_overflow': self.pool_max_overflow,
197
+ 'pool_timeout': self.pool_timeout,
198
+ 'pool_recycle': self.pool_recycle,
199
+ # Pinged by _ping_when_idle() instead: on every checkout the
200
+ # ping was a fifth of what a replica spent (keepup-86).
201
+ 'pool_pre_ping': False,
202
+ 'echo': os.getenv('SQL_ECHO', 'false').lower() == 'true'
203
+ })
204
+ else:
205
+ params.update({
206
+ 'pool_size': self.pool_size,
207
+ 'pool_timeout': self.pool_timeout,
208
+ })
209
+ params['connect_args'] = {'check_same_thread': False}
210
+ return params
211
+
212
+ def is_postgres(self) -> bool:
213
+ return self.db_type == 'postgres'
214
+
215
+ def is_sqlite(self) -> bool:
216
+ return self.db_type == 'sqlite'
217
+
218
+ def __str__(self):
219
+ if self.is_postgres():
220
+ return (f"DatabaseConfig(type={self.db_type}, host={self.db_host}, "
221
+ f"port={self.db_port}, db={self.db_name}, user={self.db_user}, "
222
+ f"pool_size={self.pool_size})")
223
+ else:
224
+ return f"DatabaseConfig(type={self.db_type}, path={self.db_path})"
225
+
226
+
227
+
228
+ class DatabaseManagerV2:
229
+ """Database access through SQLAlchemy with a connection pool."""
230
+
231
+ _engine = None
232
+ _session_factory = None
233
+
234
+ @classmethod
235
+ def initialize(cls):
236
+ """Initialise the SQLAlchemy engine."""
237
+ if cls._engine is None:
238
+ connection_string = db_config.get_connection_string()
239
+ engine_params = db_config.get_sqlalchemy_engine_params()
240
+ cls._engine = create_engine(connection_string, **engine_params)
241
+ _ping_when_idle(cls._engine, db_config.pool_ping_after_idle)
242
+ cls._session_factory = sessionmaker(bind=cls._engine)
243
+ import logging
244
+ logging.getLogger(__name__).info(f"DatabaseManagerV2 initialized with pool_size={db_config.pool_size}")
245
+
246
+ @classmethod
247
+ @contextmanager
248
+ def get_session(cls) -> Session:
249
+ """Context manager yielding a session, committing or rolling back on exit.
250
+
251
+ Inside ``shared_session()`` on the same thread it yields that block's
252
+ session instead, and leaves committing to the block.
253
+ """
254
+ shared = _SHARED_SESSION.get()
255
+ if shared is not None and shared[0] == threading.get_ident():
256
+ yield shared[1]
257
+ return
258
+ if cls._engine is None:
259
+ cls.initialize()
260
+ session = cls._session_factory()
261
+ try:
262
+ yield session
263
+ session.commit()
264
+ except Exception as e:
265
+ session.rollback()
266
+ raise e
267
+ finally:
268
+ session.close()
269
+
270
+ @classmethod
271
+ @contextmanager
272
+ def shared_session(cls):
273
+ """Run every query of this block on one session: one connection, one commit.
274
+
275
+ Each call of the query helpers takes a connection from the pool on its
276
+ own -- a checkout, a ping, the query and a commit -- and a path that
277
+ asks three questions paid that three times. The session check on every
278
+ request did: it was half of what a replica spent (keepup-85). Nested
279
+ blocks join the outer one. Bound to the thread that opened it, so work
280
+ handed to another thread from inside the block takes a session of its
281
+ own rather than sharing one across threads.
282
+ """
283
+ shared = _SHARED_SESSION.get()
284
+ if shared is not None and shared[0] == threading.get_ident():
285
+ yield shared[1]
286
+ return
287
+ with cls.get_session() as session:
288
+ token = _SHARED_SESSION.set((threading.get_ident(), session))
289
+ try:
290
+ yield session
291
+ finally:
292
+ _SHARED_SESSION.reset(token)
293
+
294
+ # --- the same calls, awaitable ------------------------------------------
295
+ #
296
+ # Every query here blocks its thread until the database answers, and in a
297
+ # coroutine that thread is the event loop's: while it waits, the process
298
+ # serves nobody. These run the synchronous call in a worker thread
299
+ # (asyncio.to_thread) and await it, so async code never calls the database
300
+ # on the loop. They call the synchronous methods by name, so whatever
301
+ # replaces one of those (a test's stand-in, a recorder) is used here too.
302
+
303
+ @classmethod
304
+ async def execute_async(cls, query: str, params: Optional[dict] = None) -> List[Dict]:
305
+ return await asyncio.to_thread(cls.execute, query, params)
306
+
307
+ @classmethod
308
+ async def execute_one_async(cls, query: str, params: Optional[dict] = None) -> Optional[Dict]:
309
+ return await asyncio.to_thread(cls.execute_one, query, params)
310
+
311
+ @classmethod
312
+ async def execute_commit_async(cls, query: str, params: Optional[dict] = None) -> int:
313
+ return await asyncio.to_thread(cls.execute_commit, query, params)
314
+
315
+ @classmethod
316
+ async def execute_many_async(cls, query: str, params_list: List[dict]) -> int:
317
+ return await asyncio.to_thread(cls.execute_many, query, params_list)
318
+
319
+ @classmethod
320
+ async def execute_commit_returning_async(cls, query: str, params: Optional[dict] = None,
321
+ returning: str = "id") -> Optional[Dict]:
322
+ return await asyncio.to_thread(cls.execute_commit_returning, query, params, returning)
323
+
324
+ @classmethod
325
+ def test_connection(cls) -> Dict[str, Any]:
326
+ """Whether the database answers, and which one it is; never raises."""
327
+ try:
328
+ if db_config.is_postgres():
329
+ version = cls.execute_one("SELECT version() AS version")
330
+ db_type = "PostgreSQL"
331
+ else:
332
+ version = cls.execute_one("SELECT sqlite_version() AS version")
333
+ db_type = "SQLite"
334
+ return {
335
+ "success": True,
336
+ "database_type": db_type,
337
+ "version": (version or {}).get("version", "Unknown"),
338
+ "config": str(db_config),
339
+ }
340
+ except Exception as e:
341
+ return {"success": False, "error": str(e), "config": str(db_config)}
342
+
343
+ @classmethod
344
+ def raw_connection(cls):
345
+ """A driver connection out of the pool, for code written against a cursor.
346
+
347
+ The application's table hook and the first-start accounts take a
348
+ cursor; the statements run on it are the driver's own (``?`` on
349
+ SQLite, ``%s`` on PostgreSQL) and rows come back as the driver makes
350
+ them. ``close()`` hands the connection back to the pool.
351
+ """
352
+ if cls._engine is None:
353
+ cls.initialize()
354
+ return cls._engine.raw_connection()
355
+
356
+ @classmethod
357
+ @contextmanager
358
+ def _statement_scope(cls, query: str):
359
+ """Where one statement of execute()/execute_one() runs.
360
+
361
+ A plain read runs on a connection in autocommit: it wrote nothing, and
362
+ the transaction around it cost a BEGIN and a COMMIT -- a round trip to
363
+ the database for every read (keepup-87). Anything else, and anything
364
+ inside shared_session(), keeps its transaction as before: these helpers
365
+ are also called with INSERT ... RETURNING, and a SELECT ... FOR UPDATE
366
+ means its lock.
367
+ """
368
+ shared = _SHARED_SESSION.get()
369
+ if _reads_only(query) and not (shared is not None and shared[0] == threading.get_ident()):
370
+ if cls._engine is None:
371
+ cls.initialize()
372
+ with cls._engine.connect() as connection:
373
+ yield connection.execution_options(isolation_level="AUTOCOMMIT")
374
+ return
375
+ with cls.get_session() as session:
376
+ yield session
377
+
378
+ @classmethod
379
+ def execute(cls, query: str, params: Optional[dict] = None) -> List[Dict]:
380
+ """Run a SQL query.
381
+
382
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
383
+ """
384
+ with cls._statement_scope(query) as session:
385
+ result = session.execute(text(query), params or {})
386
+ if result.returns_rows:
387
+ result = [dict(row._mapping) for row in result.fetchall()]
388
+ return result
389
+ return []
390
+
391
+ @classmethod
392
+ def execute_one(cls, query: str, params: Optional[dict] = None) -> Optional[Dict]:
393
+ """Run a SQL query and return a single row.
394
+
395
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
396
+ """
397
+ with cls._statement_scope(query) as session:
398
+ result = session.execute(text(query), params or {})
399
+ if result.returns_rows:
400
+ row = result.fetchone()
401
+ return dict(row._mapping) if row else None
402
+ return None
403
+
404
+ @classmethod
405
+ def execute_commit(cls, query: str, params: Optional[dict] = None) -> int:
406
+ """Run a SQL query and commit.
407
+
408
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
409
+ """
410
+ with cls.get_session() as session:
411
+ result = session.execute(text(query), params or {})
412
+ return result.rowcount
413
+
414
+ @classmethod
415
+ def column_exists(cls, table: str, column: str) -> bool:
416
+ """Whether the column is already on the table, asked of the database.
417
+
418
+ Each dialect is asked the way it answers: PostgreSQL keeps a catalogue,
419
+ SQLite answers a pragma. Neither reads an error message, which is the
420
+ whole point -- see add_column_if_missing.
421
+ """
422
+ if db_config.is_postgres():
423
+ found = cls.execute_one(
424
+ "SELECT 1 AS present FROM information_schema.columns "
425
+ "WHERE table_name = :table AND column_name = :column",
426
+ {"table": table, "column": column})
427
+ return found is not None
428
+
429
+ # PRAGMA takes no parameters, so the name is inlined -- and therefore
430
+ # checked: see keepup.tables.identifier for why this is a refusal and
431
+ # not a quoting.
432
+ from keepup.tables import identifier
433
+
434
+ rows = cls.execute(f"PRAGMA table_info({identifier(table)})")
435
+ return any(str(row.get("name")) == column for row in rows or [])
436
+
437
+ @classmethod
438
+ def add_column_if_missing(cls, table: str, column: str,
439
+ definition: str) -> bool:
440
+ """Add a column unless it is already there. True if this call added it.
441
+
442
+ There are no migrations in this project: every start re-runs its DDL, so
443
+ an ALTER that has already been applied has to be a no-op rather than an
444
+ error. That used to be decided by matching the text of the failure
445
+ against "duplicate column" -- which is what SQLite says and PostgreSQL
446
+ does not. PostgreSQL answers `(psycopg2.errors.DuplicateColumn) column
447
+ "x" of relation "y" already exists`: no such substring, so the exception
448
+ was re-raised, the plugin failed to initialise, and every one of its
449
+ routes answered 404 while the application itself reported healthy. On
450
+ the stand that happened on the second start after any new column -- the
451
+ first one added it, the next one fell over.
452
+
453
+ A method rather than a function beside the class on purpose: the test
454
+ harnesses swap this module and rebind the manager by name, so a helper
455
+ of its own would quietly talk to a different database than its caller.
456
+ """
457
+ from keepup.tables import identifier
458
+
459
+ if db_config.is_postgres():
460
+ # PostgreSQL can say it in the statement, and then a repeat start
461
+ # raises nothing at all -- no aborted transaction to recover from.
462
+ cls.execute_commit(
463
+ f"ALTER TABLE {identifier(table)} "
464
+ f"ADD COLUMN IF NOT EXISTS {identifier(column)} {definition}")
465
+ return True
466
+
467
+ # SQLite has no such form: try, and ask the catalogue if it failed.
468
+ try:
469
+ cls.execute_commit(
470
+ f"ALTER TABLE {identifier(table)} ADD COLUMN {identifier(column)} {definition}")
471
+ return True
472
+ except Exception:
473
+ if cls.column_exists(table, column):
474
+ return False
475
+ raise
476
+
477
+ @classmethod
478
+ def execute_many(cls, query: str, params_list: List[dict]) -> int:
479
+ """Run a SQL query for many parameter sets.
480
+
481
+ NOTE: takes NAMED parameters (:param_name), not positional ones.
482
+ """
483
+ total = 0
484
+ with cls.get_session() as session:
485
+ for params in params_list:
486
+ result = session.execute(text(query), params)
487
+ total += result.rowcount
488
+ return total
489
+
490
+ @classmethod
491
+ def get_pool_status(cls) -> Dict[str, Any]:
492
+ """Return the connection pool status."""
493
+ if cls._engine is None:
494
+ cls.initialize()
495
+ pool = cls._engine.pool
496
+ return {
497
+ 'size': pool.size(),
498
+ 'checked_in': pool.checkedin(),
499
+ 'overflow': pool.overflow(),
500
+ 'total': pool.size() + pool.overflow(), # total = size + overflow
501
+ 'checked_out': pool.checkedout(),
502
+ }
503
+
504
+ @classmethod
505
+ def dispose(cls):
506
+ """Close every connection."""
507
+ if cls._engine:
508
+ cls._engine.dispose()
509
+ cls._engine = None
510
+ cls._session_factory = None
511
+
512
+ @classmethod
513
+ def get_last_insert_rowid(cls, session=None) -> int:
514
+ """Return the id of the last inserted row.
515
+
516
+ Works on both PostgreSQL and SQLite -- but only with the session that
517
+ made the insert. Without one it opens a new session, which may be
518
+ another pooled connection, and answers another insert's id or 0
519
+ (keepup-82); that form is deprecated. Use execute_commit_returning().
520
+ """
521
+ if session is None:
522
+ warnings.warn(
523
+ "get_last_insert_rowid() without the inserting session may answer "
524
+ "another insert's id; use execute_commit_returning()",
525
+ DeprecationWarning, stacklevel=2)
526
+ if cls._engine is None:
527
+ cls.initialize()
528
+
529
+ if db_config.is_postgres():
530
+ query = "SELECT lastval() as id"
531
+ result = cls.execute_one(query)
532
+ return result["id"] if result else 0
533
+ else:
534
+ if session:
535
+ result = session.execute(text("SELECT last_insert_rowid() as id"))
536
+ row = result.fetchone()
537
+ return row[0] if row else 0
538
+ else:
539
+ with cls.get_session() as sess:
540
+ result = sess.execute(text("SELECT last_insert_rowid() as id"))
541
+ row = result.fetchone()
542
+ return row[0] if row else 0
543
+
544
+ @classmethod
545
+ def execute_commit_returning(cls, query: str, params: Optional[dict] = None, returning: str = "id") -> Optional[
546
+ Dict]:
547
+ """Run a SQL query and return the inserted record.
548
+
549
+ Uses RETURNING on PostgreSQL and last_insert_rowid on SQLite.
550
+ """
551
+ if db_config.is_postgres():
552
+ modified_query = f"{query} RETURNING {returning}"
553
+ with cls.get_session() as session:
554
+ result = session.execute(text(modified_query), params or {})
555
+ row = result.fetchone()
556
+ if row:
557
+ return dict(row._mapping)
558
+ return None
559
+ else:
560
+ with cls.get_session() as session:
561
+ session.execute(text(query), params or {})
562
+ result = session.execute(text(f"SELECT last_insert_rowid() as {returning}"))
563
+ row = result.fetchone()
564
+ if row:
565
+ return {returning: row[0]}
566
+ return None
567
+
568
+ @classmethod
569
+ def execute_commit_with_positional(cls, query: str, params: tuple) -> int:
570
+ """Run a SQL query with POSITIONAL parameters (?) and commit.
571
+
572
+ Deprecated: a leftover of the transition from the manager that took
573
+ positional parameters, removed in 0.2.0 (keepup-55). Named parameters
574
+ are what this manager takes; statements still written with `?` go
575
+ through the application's own `positional()`. Removed in the next minor
576
+ release, once the applications that still call it have moved.
577
+ """
578
+ warnings.warn(
579
+ "DatabaseManagerV2.execute_commit_with_positional is deprecated; write "
580
+ "the statement with named parameters", DeprecationWarning, stacklevel=2)
581
+ with cls.get_session() as session:
582
+ # SQLAlchemy only binds named parameters, so ? placeholders are rewritten to :paramN.
583
+ if '?' in query:
584
+ param_count = query.count('?')
585
+ for i in range(param_count):
586
+ query = query.replace('?', f':param{i}', 1)
587
+
588
+ named_params = {f'param{i}': params[i] for i in range(len(params))}
589
+ result = session.execute(text(query), named_params)
590
+ else:
591
+ result = session.execute(text(query), params or {})
592
+ return result.rowcount
593
+
594
+
595
+ db_config = DatabaseConfig()
596
+
597
+ # Log the resolved configuration on import, so a mis-pointed run is visible early.
598
+
599
+ if __name__ != "__main__":
600
+ import logging
601
+
602
+ logger = logging.getLogger(__name__)
603
+ logger.info(f"Database configuration loaded: {db_config}")
@@ -0,0 +1,125 @@
1
+ """Positional ``?`` parameters for DatabaseManagerV2, which takes named ones.
2
+
3
+ The manager removed in 0.2.0 took SQL with ``?`` placeholders and a tuple; the
4
+ pooled DatabaseManagerV2 takes ``:name`` parameters. Statements written for the
5
+ old one -- several assembled at run time, with lists of ``?`` -- are converted
6
+ here, at the call, rather than rewritten by hand one by one:
7
+
8
+ DatabaseManagerV2.execute(*positional("SELECT ... WHERE id = ?", (5,)))
9
+
10
+ ``?`` (or ``%s``) outside quotes becomes ``:p0``, ``:p1``...; a colon that
11
+ SQLAlchemy would take for a parameter of its own -- a time in a literal,
12
+ ``'12:30'`` -- is escaped, and ``::`` casts are left alone.
13
+
14
+ Three applications kept identical copies of this module, held equal by a test,
15
+ because the framework did not have it (keepup-56). Moving to this release, an
16
+ application imports it from here and deletes its copy.
17
+ """
18
+
19
+ import logging
20
+ from typing import Any, Dict, List, Optional, Sequence, Tuple
21
+
22
+
23
+ #: What an application may import from this module. Everything else is
24
+ #: internal and may change without notice -- see doc/keepup.md.
25
+ __all__ = [
26
+ "execute_many",
27
+ "insert_returning_id",
28
+ "positional",
29
+ "positional_many",
30
+ "raw_connection",
31
+ ]
32
+
33
+
34
+ def positional(query: str, params: Optional[Sequence[Any]] = None) -> Tuple[str, Dict[str, Any]]:
35
+ """The query with named placeholders, and its parameters as a dictionary."""
36
+ out, count, i, quote = [], 0, 0, None
37
+ while i < len(query):
38
+ ch = query[i]
39
+ if quote:
40
+ if ch == quote:
41
+ # A doubled quote inside a literal is the quote itself.
42
+ if i + 1 < len(query) and query[i + 1] == quote:
43
+ out.append(ch * 2)
44
+ i += 2
45
+ continue
46
+ quote = None
47
+ if ch == ":" and _takes_colon(query, i):
48
+ out.append("\\:")
49
+ else:
50
+ out.append(ch)
51
+ elif ch in ("'", '"'):
52
+ quote = ch
53
+ out.append(ch)
54
+ elif ch == "?" or (ch == "%" and query[i + 1:i + 2] == "s"):
55
+ # %s is the driver's own style, which some PostgreSQL branches used.
56
+ out.append(f":p{count}")
57
+ count += 1
58
+ i += 2 if ch == "%" else 1
59
+ continue
60
+ elif ch == ":" and _takes_colon(query, i):
61
+ out.append("\\:")
62
+ else:
63
+ out.append(ch)
64
+ i += 1
65
+ values = list(params or ())
66
+ if len(values) != count:
67
+ raise ValueError(f"{count} placeholders and {len(values)} parameters in: {query[:120]}")
68
+ return "".join(out), {f"p{n}": value for n, value in enumerate(values)}
69
+
70
+
71
+ def _takes_colon(query: str, i: int) -> bool:
72
+ """Whether SQLAlchemy would read the colon at i as the start of a parameter."""
73
+ before = query[i - 1] if i else ""
74
+ after = query[i + 1] if i + 1 < len(query) else ""
75
+ return before != ":" and after != ":" and (after.isalnum() or after == "_")
76
+
77
+
78
+ def positional_many(query: str, rows: Sequence[Sequence[Any]]) -> Tuple[str, List[Dict[str, Any]]]:
79
+ """The query with named placeholders, and one parameter dictionary per row."""
80
+ named, _ = positional(query, rows[0] if rows else [None] * query.count("?"))
81
+ return named, [positional(query, row)[1] for row in rows]
82
+
83
+
84
+ def insert_returning_id(query: str, params: Optional[Sequence[Any]] = None) -> Optional[int]:
85
+ """Run one INSERT and return the id of the row it made, on both dialects.
86
+
87
+ What the removed manager's ``execute_commit`` answered; the query carries no RETURNING
88
+ of its own -- DatabaseManagerV2 adds it on PostgreSQL.
89
+ """
90
+ from keepup_db import DatabaseManagerV2
91
+
92
+ row = DatabaseManagerV2.execute_commit_returning(*positional(query, params), "id")
93
+ return row["id"] if row else None
94
+
95
+
96
+ def execute_many(query: str, rows: Sequence[Sequence[Any]]) -> bool:
97
+ """Run one statement for many positional rows in one transaction.
98
+
99
+ True on success and False on a failure, which rolls the whole batch back --
100
+ what the removed manager's ``executemany_commit`` answered.
101
+ """
102
+ if not rows:
103
+ return True
104
+ try:
105
+ from keepup_db import DatabaseManagerV2
106
+
107
+ DatabaseManagerV2.execute_many(*positional_many(query, rows))
108
+ return True
109
+ except Exception as e:
110
+ logging.error(f"Batch statement failed and was rolled back: {e}")
111
+ return False
112
+
113
+
114
+ def raw_connection():
115
+ """A driver connection out of DatabaseManagerV2's pool, for code written
116
+ against a cursor; ``close()`` hands it back to the pool.
117
+
118
+ The statements run on it are the driver's own: ``?`` on SQLite, ``%s`` on
119
+ PostgreSQL, with no conversion.
120
+ """
121
+ from keepup_db import DatabaseManagerV2
122
+
123
+ with DatabaseManagerV2.get_session() as session:
124
+ engine = session.get_bind()
125
+ return engine.raw_connection()
@@ -0,0 +1,304 @@
1
+ """Tables declared once and created on either dialect.
2
+
3
+ Until this module every table was written out twice -- once for PostgreSQL and
4
+ once for SQLite -- and every column added later was a third and fourth copy in
5
+ an ``ALTER``. The copies drifted: a foreign key on one side only, ``VARCHAR``
6
+ on one and ``TEXT`` on the other, a column added to the PostgreSQL branch and
7
+ forgotten in the SQLite one.
8
+
9
+ A table is now a SQLAlchemy ``Table`` in the owner's data-access module, and
10
+ ``ensure_tables()`` makes the database match it, idempotently and on every
11
+ start -- there are still no migrations:
12
+
13
+ - the table is created with ``CREATE TABLE IF NOT EXISTS``, which, unlike
14
+ inspecting first and creating second, is safe when two replicas start at
15
+ once;
16
+ - a declared column the table lacks is added -- one catalogue read per table,
17
+ not an ``ALTER`` per column per start, which on PostgreSQL would take a lock
18
+ each time;
19
+ - declared indexes are created ``IF NOT EXISTS``.
20
+
21
+ What a column cannot say for both dialects at once is said with SQLAlchemy's
22
+ own means: ``with_variant()`` for a type, ``ddl_if(dialect=...)`` for a
23
+ constraint that only one dialect had. The DDL is compiled for the dialect in
24
+ force and run through ``DatabaseManagerV2.execute_commit`` -- the same path as
25
+ any other statement, so a test harness that rebinds the manager sees it too.
26
+
27
+ See doc/database_tables.md.
28
+ """
29
+
30
+ import logging
31
+ import re
32
+ from typing import Dict, Iterable, List, Set
33
+
34
+ from sqlalchemy import BigInteger, Column, ForeignKeyConstraint, Index, Integer, MetaData, Sequence, Table
35
+ from sqlalchemy import text as sql_text
36
+ from sqlalchemy.ext.compiler import compiles
37
+ from sqlalchemy.sql.expression import ColumnElement
38
+ from sqlalchemy.dialects import postgresql, sqlite
39
+ from sqlalchemy.schema import CreateColumn, CreateIndex, CreateSequence, CreateTable
40
+
41
+ from keepup import db as _db
42
+
43
+ #: What an application may import from this module. Everything else is
44
+ #: internal and may change without notice -- see doc/keepup.md.
45
+ __all__ = [
46
+ "NOW",
47
+ "auto_id",
48
+ "ensure_columns",
49
+ "ensure_tables",
50
+ "foreign_key",
51
+ "identifier",
52
+ "per_dialect",
53
+ "table",
54
+ ]
55
+
56
+ logger = logging.getLogger(__name__)
57
+
58
+ #: One catalogue for every declared table of the process: a foreign key names
59
+ #: its target table, and SQLAlchemy resolves it here.
60
+ metadata = MetaData()
61
+
62
+ #: The default "now" of the hand-written DDL, kept as the database's own.
63
+ NOW = sql_text("CURRENT_TIMESTAMP")
64
+
65
+ _DIALECTS = {"postgres": postgresql.dialect(), "sqlite": sqlite.dialect()}
66
+
67
+
68
+ def dialect():
69
+ """The SQLAlchemy dialect of the database in force."""
70
+ return _DIALECTS["postgres"] if _db.db_config.is_postgres() else _DIALECTS["sqlite"]
71
+
72
+
73
+ def table(name: str, *columns, **kwargs) -> Table:
74
+ """Declare a table in the shared catalogue.
75
+
76
+ ``sqlite_autoincrement`` is on, as it was in every hand-written
77
+ ``INTEGER PRIMARY KEY AUTOINCREMENT``: without it SQLite reuses the ids of
78
+ deleted rows, and an id that once meant another order is a bug waiting.
79
+ """
80
+ kwargs.setdefault("sqlite_autoincrement", True)
81
+ # Test harnesses re-import data-access modules; a second import replaces
82
+ # the declaration instead of failing. Two different modules declaring one
83
+ # name is caught by tests/schema_declaration_tests.py, not here.
84
+ kwargs.setdefault("extend_existing", True)
85
+ declared = Table(name, metadata, *columns, **kwargs)
86
+ # SQLAlchemy writes NOT NULL on every key column; the hand-written SQLite
87
+ # DDL never did on a single-column key (``id INTEGER PRIMARY KEY
88
+ # AUTOINCREMENT``). On PostgreSQL the primary key makes the column NOT NULL
89
+ # by itself, so dropping the explicit word changes nothing there and keeps
90
+ # SQLite as it was. Composite keys were written with NOT NULL on each part
91
+ # and keep whatever the declaration says.
92
+ key = list(declared.primary_key.columns)
93
+ if len(key) == 1:
94
+ key[0].nullable = True
95
+ return declared
96
+
97
+
98
+ def sequence(name: str, start: int = 1, increment: int = 1) -> Sequence:
99
+ """A PostgreSQL sequence read by hand (``nextval``); SQLite has none."""
100
+ return Sequence(name, start=start, increment=increment, metadata=metadata)
101
+
102
+
103
+ def ensure_sequences(*sequences: Sequence) -> None:
104
+ """Create these sequences on PostgreSQL; nothing to do on SQLite."""
105
+ if not _db.db_config.is_postgres():
106
+ return
107
+ for declared in sequences:
108
+ _db.DatabaseManagerV2.execute_commit(
109
+ str(CreateSequence(declared, if_not_exists=True).compile(dialect=dialect())).strip())
110
+
111
+
112
+ class per_dialect(ColumnElement):
113
+ """A server default (or any SQL fragment) that the two dialects spell differently.
114
+
115
+ ``server_default=per_dialect(postgres="NOW()", sqlite="CURRENT_TIMESTAMP")``.
116
+ SQLAlchemy has ``with_variant`` for types but nothing for defaults, and a
117
+ default that differed between the hand-written branches must keep differing.
118
+ """
119
+
120
+ inherit_cache = False
121
+
122
+ def __init__(self, postgres: str, sqlite: str):
123
+ self.postgres = postgres
124
+ self.sqlite = sqlite
125
+
126
+
127
+ @compiles(per_dialect)
128
+ def _compile_per_dialect(element, compiler, **kw):
129
+ return element.postgres if compiler.dialect.name == "postgresql" else element.sqlite
130
+
131
+
132
+ def _wanted_here(item, current) -> bool:
133
+ """Whether an index or constraint marked ``ddl_if(dialect=...)`` belongs to this dialect."""
134
+ condition = getattr(item, "_ddl_if", None)
135
+ if condition is None or not condition.dialect:
136
+ return True
137
+ wanted = condition.dialect if isinstance(condition.dialect, (list, tuple, set)) else [condition.dialect]
138
+ return current.name in wanted
139
+
140
+
141
+ def foreign_key(columns, target: str, target_columns=("id",), **kwargs) -> ForeignKeyConstraint:
142
+ """A foreign key to a table that may be declared by another module, later.
143
+
144
+ SQLAlchemy resolves the target in the catalogue when compiling. Modules are
145
+ imported in whatever order start-up and tests happen to use, so the target
146
+ may not be declared yet; a placeholder holding just the referenced columns
147
+ stands in (``keep_existing``: it never overrides a real declaration), and
148
+ the real declaration replaces it when it comes (``extend_existing``).
149
+ The placeholder is never created: ``ensure_tables`` makes only the tables
150
+ it is given.
151
+ """
152
+ if target not in metadata.tables:
153
+ Table(target, metadata, *[Column(name, Integer, primary_key=True) for name in target_columns],
154
+ keep_existing=True)
155
+ columns = [columns] if isinstance(columns, str) else list(columns)
156
+ return ForeignKeyConstraint(columns, [f"{target}.{c}" for c in target_columns], **kwargs)
157
+
158
+
159
+ def auto_id(name: str = "id") -> Column:
160
+ """``SERIAL PRIMARY KEY`` / ``INTEGER PRIMARY KEY AUTOINCREMENT``."""
161
+ return Column(name, Integer, primary_key=True, autoincrement=True)
162
+
163
+
164
+ def big_auto_id(name: str = "id") -> Column:
165
+ """``BIGSERIAL PRIMARY KEY`` on PostgreSQL, the same integer key on SQLite."""
166
+ return Column(name, BigInteger().with_variant(Integer(), "sqlite"),
167
+ primary_key=True, autoincrement=True)
168
+
169
+
170
+ def create_statements(tables: Iterable[Table]) -> List[str]:
171
+ """The DDL ``ensure_tables`` runs for these tables, compiled for the dialect in force."""
172
+ current = dialect()
173
+ statements = []
174
+ for declared in tables:
175
+ statements.append(str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
176
+ for index in sorted(declared.indexes, key=lambda i: i.name or ""):
177
+ if not _wanted_here(index, current):
178
+ continue
179
+ statements.append(str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
180
+ return statements
181
+
182
+
183
+ def existing_columns(name: str) -> Set[str]:
184
+ """The columns the table has now, asked of the database in one read."""
185
+ manager = _db.DatabaseManagerV2
186
+ if _db.db_config.is_postgres():
187
+ # current_schema(): a table of the same name in another schema must not
188
+ # make a column look present when it is not.
189
+ rows = manager.execute(
190
+ "SELECT column_name FROM information_schema.columns "
191
+ "WHERE table_name = :table AND table_schema = current_schema()",
192
+ {"table": name})
193
+ return {row["column_name"] for row in rows or []}
194
+ # PRAGMA takes no parameters; the name comes from a declaration, never a request.
195
+ rows = manager.execute(f'PRAGMA table_info("{identifier(name)}")')
196
+ return {row["name"] for row in rows or []}
197
+
198
+
199
+ #: What a table or column may be called. Checked rather than escaped: an
200
+ #: identifier of a schema is written by the code that owns it, so anything
201
+ #: outside this shape is a mistake to stop, not a string to quote. The package
202
+ #: offers a template where a table name is data (a table per application), and
203
+ #: an author who copies it without a check of their own would otherwise reach
204
+ #: the DDL with whatever arrived (task keepup-15).
205
+ IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
206
+
207
+
208
+ def identifier(name: str) -> str:
209
+ """The name, if it is one.
210
+
211
+ Args:
212
+ name: a table or column name about to be written into a statement.
213
+
214
+ Returns:
215
+ The same name.
216
+
217
+ Raises:
218
+ ValueError: when it is not a plain identifier.
219
+ """
220
+ if not isinstance(name, str) or not IDENTIFIER.match(name):
221
+ raise ValueError(f"{name!r} is not a usable table or column name")
222
+ return name
223
+
224
+
225
+ def column_definition(column: Column) -> str:
226
+ """What follows the column name in ``ADD COLUMN``, for the dialect in force."""
227
+ spec = str(CreateColumn(column).compile(dialect=dialect())).strip()
228
+ quoted = dialect().identifier_preparer.quote(column.name)
229
+ for prefix in (quoted + " ", column.name + " "):
230
+ if spec.startswith(prefix):
231
+ return spec[len(prefix):]
232
+ return spec
233
+
234
+
235
+ def ensure_tables(*tables: Table) -> Dict[str, List[str]]:
236
+ """Make the database hold these tables, their columns and their indexes.
237
+
238
+ Returns, per table, the columns this call added -- empty when the table was
239
+ already complete. Order matters only for foreign keys, and the caller passes
240
+ tables in the order their references need, as the hand-written DDL did.
241
+ """
242
+ manager = _db.DatabaseManagerV2
243
+ current = dialect()
244
+ added: Dict[str, List[str]] = {}
245
+ for declared in tables:
246
+ manager.execute_commit(
247
+ str(CreateTable(declared, if_not_exists=True).compile(dialect=current)).strip())
248
+ added[declared.name] = _add_missing(declared.name, declared.columns)
249
+ for index in sorted(declared.indexes, key=lambda i: i.name or ""):
250
+ if not _wanted_here(index, current):
251
+ continue
252
+ manager.execute_commit(
253
+ str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip())
254
+ return added
255
+
256
+
257
+ def _add_missing(name: str, columns: Iterable[Column]) -> List[str]:
258
+ manager = _db.DatabaseManagerV2
259
+ present = existing_columns(name)
260
+ missing = [c for c in columns if c.name not in present]
261
+ for column in missing:
262
+ manager.add_column_if_missing(name, column.name, column_definition(column))
263
+ return [c.name for c in missing]
264
+
265
+
266
+ def ensure_columns(name: str, *columns: Column) -> List[str]:
267
+ """Add columns to a table another module declares.
268
+
269
+ The application extends tables of the framework (``users`` gets a tenant's
270
+ SSH key, a Telegram persona); the framework cannot know those columns, and
271
+ declaring ``users`` a second time would make two owners of one table. The
272
+ columns are declared here, by the module that needs them, and added to the
273
+ existing table -- never creating it: that is its owner's job.
274
+ """
275
+ for column in columns:
276
+ if getattr(column, "table", None) is None:
277
+ # A free column cannot be compiled; a throw-away table outside the
278
+ # catalogue gives it a parent without declaring anything.
279
+ Table(f"_columns_of_{name}", MetaData(), column)
280
+ return _add_missing(name, columns)
281
+
282
+
283
+ def ensure_indexes(name: str, *indexes: Index) -> None:
284
+ """Create indexes on a table another module declares.
285
+
286
+ The counterpart of ``ensure_columns``: a plugin that joins against a table
287
+ it does not own may need an index there, and declaring the table a second
288
+ time to carry it would make two owners of one table. Each index is given
289
+ by name and column names; the table is never created here.
290
+ """
291
+ current = dialect()
292
+ for index in indexes:
293
+ if index.table is None:
294
+ # Unbound columns cannot be compiled; a throw-away table outside the
295
+ # catalogue gives the index its columns without declaring anything.
296
+ Table(f"_indexes_of_{name}", MetaData(),
297
+ *[Column(expression, Integer) for expression in index.expressions], index)
298
+ if not _wanted_here(index, current):
299
+ continue
300
+ text = str(CreateIndex(index, if_not_exists=True).compile(dialect=current)).strip()
301
+ # The throw-away table's name is not the real one.
302
+ text = text.replace(f"_indexes_of_{name}", name)
303
+ _db.DatabaseManagerV2.execute_commit(text)
304
+
@@ -0,0 +1,19 @@
1
+ Metadata-Version: 2.4
2
+ Name: keepup-db
3
+ Version: 0.4.0
4
+ Summary: The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
5
+ Requires-Python: >=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: keepup-admin>=0.4.0
8
+ Requires-Dist: sqlalchemy<3,>=2.0
9
+
10
+ # keepup-db
11
+
12
+ The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables.
13
+
14
+ One capability of [keepup](https://github.com/FedorSergeev/keepup-admin), the framework over FastAPI for admin
15
+ panels: the base is installed as `keepup-admin` and imported as `keepup`, and
16
+ this distribution carries one part of it. It is installed on its own
17
+ (`pip install keepup-db`) or together with the base (`pip install "keepup-admin[panel]"`),
18
+ and it declares itself to the kernel through its
19
+ `[project.entry-points."keepup.plugins"]` entry point.
@@ -0,0 +1,12 @@
1
+ README.md
2
+ pyproject.toml
3
+ keepup_db/__init__.py
4
+ keepup_db/manager.py
5
+ keepup_db/positional_sql.py
6
+ keepup_db/tables.py
7
+ keepup_db.egg-info/PKG-INFO
8
+ keepup_db.egg-info/SOURCES.txt
9
+ keepup_db.egg-info/dependency_links.txt
10
+ keepup_db.egg-info/entry_points.txt
11
+ keepup_db.egg-info/requires.txt
12
+ keepup_db.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [keepup.plugins]
2
+ db = keepup_db:DbPlugin
@@ -0,0 +1,2 @@
1
+ keepup-admin>=0.4.0
2
+ sqlalchemy<3,>=2.0
@@ -0,0 +1 @@
1
+ keepup_db
@@ -0,0 +1,20 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "keepup-db"
7
+ version = "0.4.0"
8
+ description = "The abstraction that holds SQLAlchemy: the table language, the pool, the dialect registry and the creation of declared tables."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ dependencies = [
12
+ "keepup-admin>=0.4.0",
13
+ "sqlalchemy>=2.0,<3",
14
+ ]
15
+
16
+ [project.entry-points."keepup.plugins"]
17
+ db = "keepup_db:DbPlugin"
18
+
19
+ [tool.setuptools]
20
+ packages = ["keepup_db"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+