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.
@@ -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