alchemist-shell 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.
- alchemist_shell-0.1.0/LICENSE +21 -0
- alchemist_shell-0.1.0/PKG-INFO +112 -0
- alchemist_shell-0.1.0/README.md +90 -0
- alchemist_shell-0.1.0/pyproject.toml +28 -0
- alchemist_shell-0.1.0/src/alchemist_shell/__init__.py +1 -0
- alchemist_shell-0.1.0/src/alchemist_shell/cli.py +206 -0
- alchemist_shell-0.1.0/src/alchemist_shell/discovery.py +34 -0
- alchemist_shell-0.1.0/src/alchemist_shell/inspect.py +32 -0
- alchemist_shell-0.1.0/src/alchemist_shell/session.py +55 -0
- alchemist_shell-0.1.0/src/alchemist_shell/utils.py +17 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Elkana Maina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: alchemist-shell
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: The missing interactive shell for SQLAlchemy projects.
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Elkana Maina
|
|
8
|
+
Author-email: elisoft.engineer@gmail.com
|
|
9
|
+
Requires-Python: >=3.12
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Classifier: Programming Language :: Python :: 3
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
15
|
+
Requires-Dist: ipython (>=8.0.0)
|
|
16
|
+
Requires-Dist: nest-asyncio (>=1.5.0)
|
|
17
|
+
Requires-Dist: python-dotenv (>=1.0.0)
|
|
18
|
+
Requires-Dist: rich (>=13.0.0)
|
|
19
|
+
Requires-Dist: sqlalchemy (>=2.0.0)
|
|
20
|
+
Requires-Dist: typer (>=0.9.0)
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# 🔮 Alchemist Shell
|
|
24
|
+
|
|
25
|
+
**The missing interactive shell for SQLAlchemy.**
|
|
26
|
+
|
|
27
|
+
Working with SQLAlchemy in a standard REPL is painful. You have to manually manage imports, handle async event loops, and deal with unreadable object representations. **Alchemist Shell** turns your terminal into a powerful, zero-setup database workbench.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 🛠 Why this exists?
|
|
32
|
+
|
|
33
|
+
The current workflow for inspecting SQLAlchemy projects in the terminal is broken:
|
|
34
|
+
|
|
35
|
+
1. **Manual Setup**
|
|
36
|
+
You shouldn't have to manually import every model and session helper just to check a record.
|
|
37
|
+
|
|
38
|
+
2. **Manual Session Management**
|
|
39
|
+
In interactive environments, setting up and managing a session is repetitive boilerplate.
|
|
40
|
+
Creating engines, instantiating sessions, and keeping them alive just to run a few queries breaks the flow.
|
|
41
|
+
|
|
42
|
+
3. **Bad Visibility**
|
|
43
|
+
Default object reprs like `<User 1>` tell you nothing. You deserve to see your data clearly.
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## ✨ DX Features
|
|
48
|
+
|
|
49
|
+
- **Auto-Discovery**
|
|
50
|
+
Recursively scans your project and injects all models into the namespace instantly.
|
|
51
|
+
|
|
52
|
+
- **Pre-loaded Toolkit**
|
|
53
|
+
`select`, `func`, `text`, and other SQLAlchemy essentials are available on startup.
|
|
54
|
+
|
|
55
|
+
- **Auto-Formatting**
|
|
56
|
+
Model instances render as high-fidelity `rich` tables when evaluated.
|
|
57
|
+
|
|
58
|
+
- **Modern Shell**
|
|
59
|
+
Powered by IPython with completion, syntax highlighting, and history-based autosuggestions.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## 📦 Installation
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
pip install alchemist-shell
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## 🧪 The Workflow
|
|
72
|
+
|
|
73
|
+
### 1. Zero-Await Session Management
|
|
74
|
+
|
|
75
|
+
Interact with your session without the async tax:
|
|
76
|
+
|
|
77
|
+
```python
|
|
78
|
+
alchemist ❯ user = User(username="alchemist")
|
|
79
|
+
alchemist ❯ db.add(user)
|
|
80
|
+
alchemist ❯ db.commit()
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
---
|
|
84
|
+
|
|
85
|
+
### 2. Immediate Feedback
|
|
86
|
+
|
|
87
|
+
Stop calling `print()` or `vars()`. Just evaluate the variable:
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
alchemist ❯ user
|
|
91
|
+
# Renders a clean table with all column values
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
---
|
|
95
|
+
|
|
96
|
+
### 3. Native Async Queries
|
|
97
|
+
|
|
98
|
+
Full auto-await support when you need complex queries:
|
|
99
|
+
|
|
100
|
+
```python
|
|
101
|
+
alchemist ❯ orders = (await db.execute(select(Order))).scalars().all()
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
---
|
|
105
|
+
|
|
106
|
+
## 📜 License
|
|
107
|
+
|
|
108
|
+
MIT License. Permissive and simple.
|
|
109
|
+
|
|
110
|
+
---
|
|
111
|
+
|
|
112
|
+
**Built to make SQLAlchemy development suck less.**
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# 🔮 Alchemist Shell
|
|
2
|
+
|
|
3
|
+
**The missing interactive shell for SQLAlchemy.**
|
|
4
|
+
|
|
5
|
+
Working with SQLAlchemy in a standard REPL is painful. You have to manually manage imports, handle async event loops, and deal with unreadable object representations. **Alchemist Shell** turns your terminal into a powerful, zero-setup database workbench.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 🛠 Why this exists?
|
|
10
|
+
|
|
11
|
+
The current workflow for inspecting SQLAlchemy projects in the terminal is broken:
|
|
12
|
+
|
|
13
|
+
1. **Manual Setup**
|
|
14
|
+
You shouldn't have to manually import every model and session helper just to check a record.
|
|
15
|
+
|
|
16
|
+
2. **Manual Session Management**
|
|
17
|
+
In interactive environments, setting up and managing a session is repetitive boilerplate.
|
|
18
|
+
Creating engines, instantiating sessions, and keeping them alive just to run a few queries breaks the flow.
|
|
19
|
+
|
|
20
|
+
3. **Bad Visibility**
|
|
21
|
+
Default object reprs like `<User 1>` tell you nothing. You deserve to see your data clearly.
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## ✨ DX Features
|
|
26
|
+
|
|
27
|
+
- **Auto-Discovery**
|
|
28
|
+
Recursively scans your project and injects all models into the namespace instantly.
|
|
29
|
+
|
|
30
|
+
- **Pre-loaded Toolkit**
|
|
31
|
+
`select`, `func`, `text`, and other SQLAlchemy essentials are available on startup.
|
|
32
|
+
|
|
33
|
+
- **Auto-Formatting**
|
|
34
|
+
Model instances render as high-fidelity `rich` tables when evaluated.
|
|
35
|
+
|
|
36
|
+
- **Modern Shell**
|
|
37
|
+
Powered by IPython with completion, syntax highlighting, and history-based autosuggestions.
|
|
38
|
+
|
|
39
|
+
---
|
|
40
|
+
|
|
41
|
+
## 📦 Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install alchemist-shell
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## 🧪 The Workflow
|
|
50
|
+
|
|
51
|
+
### 1. Zero-Await Session Management
|
|
52
|
+
|
|
53
|
+
Interact with your session without the async tax:
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
alchemist ❯ user = User(username="alchemist")
|
|
57
|
+
alchemist ❯ db.add(user)
|
|
58
|
+
alchemist ❯ db.commit()
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
### 2. Immediate Feedback
|
|
64
|
+
|
|
65
|
+
Stop calling `print()` or `vars()`. Just evaluate the variable:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
alchemist ❯ user
|
|
69
|
+
# Renders a clean table with all column values
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
### 3. Native Async Queries
|
|
75
|
+
|
|
76
|
+
Full auto-await support when you need complex queries:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
alchemist ❯ orders = (await db.execute(select(Order))).scalars().all()
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
---
|
|
83
|
+
|
|
84
|
+
## 📜 License
|
|
85
|
+
|
|
86
|
+
MIT License. Permissive and simple.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
**Built to make SQLAlchemy development suck less.**
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "alchemist-shell"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "The missing interactive shell for SQLAlchemy projects."
|
|
5
|
+
authors = [
|
|
6
|
+
{name = "Elkana Maina",email = "elisoft.engineer@gmail.com"}
|
|
7
|
+
]
|
|
8
|
+
readme = "README.md"
|
|
9
|
+
license = {text = "MIT"}
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"sqlalchemy>=2.0.0",
|
|
13
|
+
"typer>=0.9.0",
|
|
14
|
+
"ipython>=8.0.0",
|
|
15
|
+
"rich>=13.0.0",
|
|
16
|
+
"python-dotenv>=1.0.0",
|
|
17
|
+
"nest-asyncio>=1.5.0"
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
[tool.poetry]
|
|
21
|
+
packages = [{include = "alchemist_shell", from = "src"}]
|
|
22
|
+
|
|
23
|
+
[project.scripts]
|
|
24
|
+
alchemist = "alchemist_shell.cli:main"
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["poetry-core>=2.0.0,<3.0.0"]
|
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import os
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import Optional, Dict, Any, Type
|
|
7
|
+
|
|
8
|
+
import nest_asyncio
|
|
9
|
+
import typer
|
|
10
|
+
from IPython.terminal.embed import InteractiveShellEmbed
|
|
11
|
+
from IPython.terminal.prompts import Prompts
|
|
12
|
+
from pygments.token import Token
|
|
13
|
+
from traitlets.config import Config
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.table import Table
|
|
16
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
17
|
+
from sqlalchemy.engine import Row
|
|
18
|
+
|
|
19
|
+
nest_asyncio.apply()
|
|
20
|
+
|
|
21
|
+
from sqlalchemy import (
|
|
22
|
+
select, insert, update, delete,
|
|
23
|
+
func, and_, or_, not_, desc, asc, text
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
from .discovery import discover_models
|
|
27
|
+
from .inspect import inspect_model
|
|
28
|
+
from .session import get_session
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
app = typer.Typer(name="alchemist", help="The Modern SQLAlchemy Shell")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- OS-aware IPython color profile ---
|
|
35
|
+
def get_ipython_colors():
|
|
36
|
+
if sys.platform.startswith("win"):
|
|
37
|
+
return "neutral" # Windows terminals can be inconsistent
|
|
38
|
+
if "TERM" in os.environ and "256color" in os.environ["TERM"]:
|
|
39
|
+
return "linux"
|
|
40
|
+
return "neutral"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class AlchemistPrompts(Prompts):
|
|
44
|
+
def in_prompt_tokens(self):
|
|
45
|
+
return [(Token.Prompt, "alchemist "), (Token.PromptMarker, "❯ ")]
|
|
46
|
+
|
|
47
|
+
def out_prompt_tokens(self):
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
def continuation_prompt_tokens(self, width=None):
|
|
51
|
+
return [(Token.Prompt, " ❯ ")]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def make_sync_proxy(db: AsyncSession) -> Any:
|
|
55
|
+
class AsyncProxy:
|
|
56
|
+
def __init__(self, obj: AsyncSession):
|
|
57
|
+
self._obj = obj
|
|
58
|
+
|
|
59
|
+
def __getattr__(self, name: str) -> Any:
|
|
60
|
+
attr = getattr(self._obj, name)
|
|
61
|
+
if callable(attr):
|
|
62
|
+
@wraps(attr)
|
|
63
|
+
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
64
|
+
result = attr(*args, **kwargs)
|
|
65
|
+
if asyncio.iscoroutine(result):
|
|
66
|
+
try:
|
|
67
|
+
asyncio.get_running_loop()
|
|
68
|
+
return result
|
|
69
|
+
except RuntimeError:
|
|
70
|
+
return asyncio.get_event_loop().run_until_complete(result)
|
|
71
|
+
return result
|
|
72
|
+
return wrapper
|
|
73
|
+
return attr
|
|
74
|
+
|
|
75
|
+
return AsyncProxy(db)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def version_callback(value: bool):
|
|
79
|
+
if value:
|
|
80
|
+
from . import __version__
|
|
81
|
+
console.print(f"Alchemist Shell v{__version__}")
|
|
82
|
+
raise typer.Exit()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.callback()
|
|
86
|
+
def common(
|
|
87
|
+
version: Optional[bool] = typer.Option(
|
|
88
|
+
None, "--version", "-v", callback=version_callback, is_eager=True
|
|
89
|
+
)
|
|
90
|
+
):
|
|
91
|
+
"""The Modern SQLAlchemy Shell."""
|
|
92
|
+
pass
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# --- FINAL FORMATTER ---
|
|
96
|
+
def alchemist_display_formatter(obj, p, cycle):
|
|
97
|
+
if cycle:
|
|
98
|
+
return p.text(repr(obj))
|
|
99
|
+
|
|
100
|
+
# Handle SQLAlchemy Row
|
|
101
|
+
if isinstance(obj, Row):
|
|
102
|
+
if len(obj) == 1 and hasattr(obj[0], "__table__"):
|
|
103
|
+
obj = obj[0]
|
|
104
|
+
else:
|
|
105
|
+
return p.text(repr(obj))
|
|
106
|
+
|
|
107
|
+
# Handle SQLAlchemy model instances
|
|
108
|
+
if hasattr(obj, "__table__"):
|
|
109
|
+
# ✅ Direct Rich rendering (no ANSI stripping)
|
|
110
|
+
inspect_model(obj)
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
return p.text(repr(obj))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@app.command()
|
|
117
|
+
def shell(
|
|
118
|
+
db_url: Optional[str] = typer.Option(None, "--db-url", "-u"),
|
|
119
|
+
path: str = typer.Option(".", "--path", "-p"),
|
|
120
|
+
env: Optional[str] = typer.Option(None, "--env", "-e")
|
|
121
|
+
) -> None:
|
|
122
|
+
with console.status("[cyan]Scanning modules...[/cyan]"):
|
|
123
|
+
models: Dict[str, Type[Any]] = discover_models(path)
|
|
124
|
+
|
|
125
|
+
try:
|
|
126
|
+
db, engine = get_session(db_url, env)
|
|
127
|
+
except Exception as e:
|
|
128
|
+
console.print(f"[bold red]Initialization Error:[/bold red] {e}")
|
|
129
|
+
raise typer.Exit(1)
|
|
130
|
+
|
|
131
|
+
is_async = isinstance(db, AsyncSession)
|
|
132
|
+
mode_label = "ASYNC" if is_async else "SYNC"
|
|
133
|
+
|
|
134
|
+
cfg = Config()
|
|
135
|
+
cfg.InteractiveShell.autoawait = True
|
|
136
|
+
cfg.InteractiveShell.display_banner = False
|
|
137
|
+
cfg.InteractiveShell.quiet = True
|
|
138
|
+
cfg.TerminalInteractiveShell.display_completions_help = False
|
|
139
|
+
cfg.TerminalInteractiveShell.prompts_class = AlchemistPrompts
|
|
140
|
+
cfg.TerminalInteractiveShell.term_title = False
|
|
141
|
+
cfg.IPCompleter.greedy = True
|
|
142
|
+
cfg.TerminalInteractiveShell.autosuggestions_provider = "NavigableAutoSuggestFromHistory"
|
|
143
|
+
cfg.IPCompleter.use_jedi = True
|
|
144
|
+
|
|
145
|
+
active_db = make_sync_proxy(db) if is_async else db
|
|
146
|
+
|
|
147
|
+
namespace: Dict[str, Any] = {
|
|
148
|
+
"db": active_db,
|
|
149
|
+
"engine": engine,
|
|
150
|
+
"inspect": inspect_model,
|
|
151
|
+
"sql_on": lambda: engine.__setattr__("echo", True),
|
|
152
|
+
"sql_off": lambda: engine.__setattr__("echo", False),
|
|
153
|
+
"select": select, "insert": insert, "update": update,
|
|
154
|
+
"delete": delete, "func": func, "and_": and_,
|
|
155
|
+
"or_": or_, "not_": not_, "desc": desc,
|
|
156
|
+
"asc": asc, "text": text,
|
|
157
|
+
**models
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
ipshell = InteractiveShellEmbed(
|
|
161
|
+
config=cfg,
|
|
162
|
+
user_ns=namespace,
|
|
163
|
+
colors=get_ipython_colors(), # ✅ OS-aware colors
|
|
164
|
+
banner1=""
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
formatter = ipshell.display_formatter.formatters["text/plain"]
|
|
168
|
+
|
|
169
|
+
# Register formatter for models
|
|
170
|
+
for model_cls in models.values():
|
|
171
|
+
formatter.for_type(model_cls, alchemist_display_formatter)
|
|
172
|
+
|
|
173
|
+
# Register formatter for SQLAlchemy Row
|
|
174
|
+
formatter.for_type(Row, alchemist_display_formatter)
|
|
175
|
+
|
|
176
|
+
# --- UI ---
|
|
177
|
+
table = Table(show_header=True, header_style="bold blue", box=None)
|
|
178
|
+
table.add_column("Model", style="magenta")
|
|
179
|
+
table.add_column("Table", style="dim")
|
|
180
|
+
|
|
181
|
+
for name, cls in models.items():
|
|
182
|
+
table.add_row(name, str(getattr(cls, "__tablename__", "N/A")))
|
|
183
|
+
|
|
184
|
+
console.print("\n[bold magenta]🔮 Alchemist Shell[/bold magenta]")
|
|
185
|
+
console.print(table)
|
|
186
|
+
|
|
187
|
+
db_name = engine.url.database or "memory"
|
|
188
|
+
console.print(f"[bold cyan]Connected:[/bold cyan] [white]{db_name}[/white] [dim]({mode_label})[/dim]")
|
|
189
|
+
console.print("[dim]Common imports pre-loaded. Type a model instance to view it.[/dim]\n")
|
|
190
|
+
|
|
191
|
+
history_file = Path.home() / ".alchemist_history"
|
|
192
|
+
if not history_file.exists():
|
|
193
|
+
history_file.touch()
|
|
194
|
+
|
|
195
|
+
ipshell.history_manager.hist_file = str(history_file)
|
|
196
|
+
ipshell.history_manager.enabled = True
|
|
197
|
+
|
|
198
|
+
ipshell()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def main():
|
|
202
|
+
app()
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
if __name__ == "__main__":
|
|
206
|
+
main()
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import importlib
|
|
2
|
+
import pkgutil
|
|
3
|
+
import sys
|
|
4
|
+
import inspect
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Dict, Type
|
|
7
|
+
|
|
8
|
+
def discover_models(start_dir: str = ".") -> Dict[str, Type[Any]]:
|
|
9
|
+
"""
|
|
10
|
+
Dynamically discovers SQLAlchemy models in the project root.
|
|
11
|
+
"""
|
|
12
|
+
path: Path = Path(start_dir).resolve()
|
|
13
|
+
if str(path) not in sys.path:
|
|
14
|
+
sys.path.insert(0, str(path))
|
|
15
|
+
|
|
16
|
+
found_models: Dict[str, Type[Any]] = {}
|
|
17
|
+
ignored_patterns: list[str] = ["venv", "tests", "__pycache__", "alembic"]
|
|
18
|
+
|
|
19
|
+
for _, module_name, _ in pkgutil.walk_packages([str(path)]):
|
|
20
|
+
if any(p in module_name for p in ignored_patterns):
|
|
21
|
+
continue
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
module = importlib.import_module(module_name)
|
|
25
|
+
for _, obj in inspect.getmembers(module):
|
|
26
|
+
# Identify models by the __tablename__ convention
|
|
27
|
+
if inspect.isclass(obj) and hasattr(obj, "__tablename__"):
|
|
28
|
+
if obj.__module__.startswith("sqlalchemy"):
|
|
29
|
+
continue
|
|
30
|
+
found_models[obj.__name__] = obj
|
|
31
|
+
except (ImportError, AttributeError):
|
|
32
|
+
continue
|
|
33
|
+
|
|
34
|
+
return found_models
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from sqlalchemy import inspect
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def inspect_model(obj: Any, console: Console | None = None) -> None:
|
|
8
|
+
console = console or Console()
|
|
9
|
+
|
|
10
|
+
if not hasattr(obj, "__table__"):
|
|
11
|
+
console.print("[bold red]Error:[/bold red] Object is not a SQLAlchemy model instance.")
|
|
12
|
+
return
|
|
13
|
+
|
|
14
|
+
table = Table(
|
|
15
|
+
title=f"Inspecting {obj.__class__.__name__}",
|
|
16
|
+
show_header=True,
|
|
17
|
+
header_style="bold cyan",
|
|
18
|
+
)
|
|
19
|
+
table.add_column("Column", style="magenta")
|
|
20
|
+
table.add_column("Value", style="white")
|
|
21
|
+
|
|
22
|
+
state = inspect(obj)
|
|
23
|
+
|
|
24
|
+
for column in obj.__table__.columns:
|
|
25
|
+
name = column.name
|
|
26
|
+
if name in state.unloaded:
|
|
27
|
+
value = "[italic yellow]<Not Loaded>[/italic yellow]"
|
|
28
|
+
else:
|
|
29
|
+
value = str(getattr(obj, name))
|
|
30
|
+
table.add_row(name, value)
|
|
31
|
+
|
|
32
|
+
console.print(table)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from typing import Optional, Union, Tuple
|
|
3
|
+
from sqlalchemy import create_engine, Engine
|
|
4
|
+
from sqlalchemy.ext.asyncio import (
|
|
5
|
+
create_async_engine,
|
|
6
|
+
AsyncSession,
|
|
7
|
+
AsyncEngine,
|
|
8
|
+
async_sessionmaker
|
|
9
|
+
)
|
|
10
|
+
from sqlalchemy.orm import sessionmaker, Session
|
|
11
|
+
from .utils import find_and_load_env
|
|
12
|
+
|
|
13
|
+
# Strict Type Aliases
|
|
14
|
+
DatabaseSession = Union[Session, AsyncSession]
|
|
15
|
+
DatabaseEngine = Union[Engine, AsyncEngine]
|
|
16
|
+
|
|
17
|
+
def _create_sync_session(url: str) -> Tuple[Session, Engine]:
|
|
18
|
+
engine: Engine = create_engine(url, echo=False)
|
|
19
|
+
# Correct sessionmaker typing for SQLAlchemy 2.0
|
|
20
|
+
factory: sessionmaker[Session] = sessionmaker(
|
|
21
|
+
bind=engine,
|
|
22
|
+
expire_on_commit=False
|
|
23
|
+
)
|
|
24
|
+
return factory(), engine
|
|
25
|
+
|
|
26
|
+
def _create_async_session(url: str) -> Tuple[AsyncSession, AsyncEngine]:
|
|
27
|
+
engine: AsyncEngine = create_async_engine(url, echo=False)
|
|
28
|
+
# async_sessionmaker is the future-proof standard
|
|
29
|
+
factory: async_sessionmaker[AsyncSession] = async_sessionmaker(
|
|
30
|
+
engine,
|
|
31
|
+
expire_on_commit=False,
|
|
32
|
+
class_=AsyncSession
|
|
33
|
+
)
|
|
34
|
+
return factory(), engine
|
|
35
|
+
|
|
36
|
+
def get_session(
|
|
37
|
+
db_url: Optional[str] = None,
|
|
38
|
+
env_file: Optional[str] = None
|
|
39
|
+
) -> Tuple[DatabaseSession, DatabaseEngine]:
|
|
40
|
+
"""
|
|
41
|
+
Gateway to initialize the database connection.
|
|
42
|
+
"""
|
|
43
|
+
find_and_load_env(env_file)
|
|
44
|
+
url: Optional[str] = db_url or os.getenv("DATABASE_URL")
|
|
45
|
+
|
|
46
|
+
if not url:
|
|
47
|
+
raise ValueError("DATABASE_URL not found. Check your .env or --db-url.")
|
|
48
|
+
|
|
49
|
+
# Detection logic
|
|
50
|
+
is_async: bool = any(d in url for d in ["+asyncpg", "+aiosqlite"])
|
|
51
|
+
|
|
52
|
+
if is_async:
|
|
53
|
+
return _create_async_session(url)
|
|
54
|
+
|
|
55
|
+
return _create_sync_session(url)
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from dotenv import load_dotenv
|
|
5
|
+
|
|
6
|
+
def find_and_load_env(env_file: Optional[str] = None) -> None:
|
|
7
|
+
"""Searches for and loads the appropriate .env file."""
|
|
8
|
+
if env_file and Path(env_file).exists():
|
|
9
|
+
load_dotenv(env_file)
|
|
10
|
+
return
|
|
11
|
+
|
|
12
|
+
# Priority: .env.dev -> .env.local -> .env
|
|
13
|
+
targets: list[str] = [".env.dev", ".env.local", ".env"]
|
|
14
|
+
for target in targets:
|
|
15
|
+
if Path(target).exists():
|
|
16
|
+
load_dotenv(target)
|
|
17
|
+
break
|