diff --git a/.gitignore b/.gitignore index a60cd30..ee4fccc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,144 @@ dist tests/db.sqlite3 +# Django # +*.log +*.pot +*.pyc +__pycache__ +db.sqlite3 +media + +# Backup files # +*.bak + +# If you are using PyCharm # +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# File-based project format +*.iws + +# IntelliJ +out/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Python # +*.py[cod] +*$py.class + +# Distribution / packaging +.Python build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +.pytest_cache/ +nosetests.xml +coverage.xml +*.cover +.hypothesis/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery +celerybeat-schedule.* + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ + +# Sublime Text # +*.tmlanguage.cache +*.tmPreferences.cache +*.stTheme.cache +*.sublime-workspace +*.sublime-project + +# sftp configuration file +sftp-config.json + +# Package control specific files Package +Control.last-run +Control.ca-list +Control.ca-bundle +Control.system-ca-bundle +GitHub.sublime-settings + +# Visual Studio Code # +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json +.history + +node_modules +static/build +.vite +staticfiles \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d675929..bae3d14 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,6 +37,7 @@ dependencies = [ "django>=3.2", "textual", "trogon", + "textual[syntax]", ] [project.urls] diff --git a/src/django_tui/management/commands/ish.py b/src/django_tui/management/commands/ish.py new file mode 100644 index 0000000..56b4dfd --- /dev/null +++ b/src/django_tui/management/commands/ish.py @@ -0,0 +1,446 @@ +from __future__ import annotations + +import os +import sys +from subprocess import run + +from textual import events +from textual.app import ComposeResult +from textual.binding import Binding +from textual.containers import Vertical,HorizontalScroll,VerticalScroll +from textual.widgets import ( + Footer, + Label, +) +from textual.widgets import TextArea +import django +import traceback +import importlib +import warnings +from django.apps import apps + + +from textual.widgets.text_area import Selection,Location +from textual.screen import ModalScreen,Screen +from textual.widgets import MarkdownViewer +from typing import List, Tuple +from rich.syntax import Syntax + +try: + # Only for python 2 + from StringIO import StringIO +except ImportError: + # For python 3 + from io import StringIO + + +def get_py_version(): + ver = sys.version_info + return "{0}.{1}.{2}".format(ver.major, ver.minor, ver.micro) + +def get_dj_version(): + return django.__version__ + + +DEFAULT_IMPORT = { + 'rich':[ + 'print_json', + 'print' + ], + 'django.db.models': [ + 'Avg', + 'Case', + 'Count', + 'F', + 'Max', + 'Min', + 'Prefetch', + 'Q', + 'Sum', + 'When', + ], + 'django.conf': [ + 'settings', + ], + 'django.core.cache': [ + 'cache', + ], + 'django.contrib.auth': [ + 'get_user_model', + ], + 'django.utils': [ + 'timezone', + ], + 'django.urls': [ + 'reverse' + ], +} + +class Importer(object): + + def __init__(self, import_django=None, import_models=None, extra_imports=None): + self.import_django = import_django or True + self.import_models = import_models or True + self.FROM_DJANGO = DEFAULT_IMPORT + if extra_imports is not None and isinstance(extra_imports, dict): + self.FROM_DJANGO.update(extra_imports) + + _mods = None + + def get_modules(self): + """ + Return list of modules and symbols to import + """ + if self._mods is None: + self._mods = {} + + if self.import_django and self.FROM_DJANGO: + + for module_name, symbols in self.FROM_DJANGO.items(): + try: + module = importlib.import_module(module_name) + except ImportError as e: + warnings.warn( + "django_admin_shell - autoimport warning :: {msg}".format( + msg=str(e) + ), + ImportWarning + ) + continue + + self._mods[module_name] = [] + for symbol_name in symbols: + if hasattr(module, symbol_name): + self._mods[module_name].append(symbol_name) + else: + warnings.warn( + "django_admin_shell - autoimport warning :: " + "AttributeError module '{mod}' has no attribute '{attr}'".format( + mod=module_name, + attr=symbol_name + ), + ImportWarning + ) + + if self.import_models: + for model_class in apps.get_models(): + _mod = model_class.__module__ + classes = self._mods.get(_mod, []) + classes.append(model_class.__name__) + self._mods[_mod] = classes + + return self._mods + + _scope = None + + def get_scope(self): + """ + Return map with symbols to module/object + Like: + "reverse" -> "django.urls.reverse" + """ + if self._scope is None: + self._scope = {} + for module_name, symbols in self.get_modules().items(): + module = importlib.import_module(module_name) + for symbol_name in symbols: + self._scope[symbol_name] = getattr( + module, + symbol_name + ) + + return self._scope + + def clear_scope(self): + """ + clear the scope. + + Freeing declared variables to be garbage collected. + """ + self._scope = None + + def __str__(self): + buf = "" + for module, symbols in self.get_modules().items(): + if symbols: + buf += "from {mod} import {symbols}\n".format( + mod=module, + symbols=", ".join(symbols) + ) + return buf + +class Runner(object): + + def __init__(self): + self.importer = Importer() + + def run_code(self, code): + """ + Execute code and return result with status = success|error + Function manipulate stdout to grab output from exec + """ + status = "success" + out = "" + tmp_stdout = sys.stdout + buf = StringIO() + + try: + sys.stdout = buf + exec(code, None, self.importer.get_scope()) + # exec(code, globals()) + except Exception: + out = traceback.format_exc() + status = 'error' + else: + out = buf.getvalue() + finally: + sys.stdout = tmp_stdout + + result = { + 'code': code, + 'out': out, + 'status': status, + } + return result + +class ExtendedTextArea(TextArea): + """A subclass of TextArea with parenthesis-closing functionality.""" + + def _on_key(self, event: events.Key) -> None: + if event.character == "(": + self.insert("()") + self.move_cursor_relative(columns=-1) + event.prevent_default() + + if event.character == "[": + self.insert("[]") + self.move_cursor_relative(columns=-1) + event.prevent_default() + + if event.character == "{": + self.insert("{}") + self.move_cursor_relative(columns=-1) + event.prevent_default() + + if event.character == '"': + self.insert('""') + self.move_cursor_relative(columns=-1) + event.prevent_default() + + if event.character == "'": + self.insert("''") + self.move_cursor_relative(columns=-1) + event.prevent_default() + +class TextEditorBindingsInfo(ModalScreen[None]): + BINDINGS = [ + Binding("escape", "dismiss(None)", "", show=False), + ] + + DEFAULT_CSS = """ + TextEditorBindingsInfo { + align: center middle; + } +""" + + key_bindings = """ +Text Editor Key Bindings List +| Key(s) | Description | +|-------------|---------------------------------------------| +| escape | Focus on the next item. | +| up | Move the cursor up. | +| down | Move the cursor down. | +| left | Move the cursor left. | +| ctrl+left | Move the cursor to the start of the word. | +| ctrl+shift+left | Move the cursor to the start of the word and select. | +| right | Move the cursor right. | +| ctrl+right | Move the cursor to the end of the word. | +| ctrl+shift+right | Move the cursor to the end of the word and select. | +| home,ctrl+a | Move the cursor to the start of the line. | +| end,ctrl+e | Move the cursor to the end of the line. | +| shift+home | Move the cursor to the start of the line and select. | +| shift+end | Move the cursor to the end of the line and select. | +| pageup | Move the cursor one page up. | +| pagedown | Move the cursor one page down. | +| shift+up | Select while moving the cursor up. | +| shift+down | Select while moving the cursor down. | +| shift+left | Select while moving the cursor left. | +| shift+right | Select while moving the cursor right. | +| backspace | Delete character to the left of cursor. | +| ctrl+w | Delete from cursor to start of the word. | +| delete,ctrl+d | Delete character to the right of cursor. | +| ctrl+f | Delete from cursor to end of the word. | +| ctrl+x | Delete the current line. | +| ctrl+u | Delete from cursor to the start of the line. | +| ctrl+k | Delete from cursor to the end of the line. | +| f6 | Select the current line. | +| f7 | Select all text in the document. | +""" + _title = "Editor Keys Bindings" + + def compose(self) -> ComposeResult: + """Compose the content of the modal dialog.""" + with Vertical(id="dialog"): + yield MarkdownViewer(self.key_bindings,classes="spaced",show_table_of_contents=False) + +class DefaultImportsInfo(ModalScreen[None]): + BINDINGS = [ + Binding("escape", "dismiss(None)", "Close",), + ] + + DEFAULT_CSS = """ + DefaultImportsInfo { + align: center middle; + } +""" + + _title = "Default Imported Modules" + + def __init__(self,imported_modules:str,name: str | None = None, + id: str | None = None, + classes: str | None = None,): + self.imported_modules = imported_modules + super().__init__(name, id, classes) + + def compose(self) -> ComposeResult: + """Compose the content of the modal dialog.""" + syntax = Syntax( + code=self.imported_modules, + lexer="python", + line_numbers=True, + word_wrap=False, + indent_guides=True, + theme="dracula", + ) + with VerticalScroll(id="dialog"): + yield Label(syntax) + +class InteractiveShellScreen(Screen): + + def __init__( + self, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ): + super().__init__(name, id, classes) + self.runner = Runner() + self.input_tarea = ExtendedTextArea("",id="input", language="python", theme="dracula") + self.output_tarea = TextArea("# Output", id="output",language="python", theme="dracula",classes="text-area") + + + BINDINGS = [ + Binding(key="ctrl+r", action="test", description="Run the query"), + Binding(key="ctrl+z", action="copy_command", description="Copy to Clipboard"), + Binding(key="ctrl+underscore", action="toggle_comment", description="Toggle Comment",show=False), + Binding(key="f1", action="editor_keys", description="Key Bindings"), + Binding(key="f2", action="default_imports", description="Default imports"), + Binding(key="ctrl+j", action="select_mode('commands')", description="Commands"), + ] + + + def compose(self) -> ComposeResult: + self.input_tarea.focus() + + yield HorizontalScroll( + self.input_tarea, + self.output_tarea, + ) + yield Label(f"Python: {get_py_version()} Django: {get_dj_version()}") + yield Footer() + + def action_default_imports(self) ->None: + self.app.push_screen(DefaultImportsInfo(self.runner.importer.__str__())) + + def action_test(self) -> None: + # get Code from start till the position of the cursor + self.input_tarea.selection = Selection(start=(0, 0), end=self.input_tarea.cursor_location) + self.input_tarea.action_cursor_line_end() + code = self.input_tarea.get_text_range(start=(0,0),end=self.input_tarea.cursor_location) + + if len(code) > 0: + # Because the cli - texualize is running on a loop - has an event loop + # os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'rest.settings') + os.environ["DJANGO_ALLOW_ASYNC_UNSAFE"] = "true" + django.setup() + + result = self.runner.run_code(code) + self.output_tarea.load_text(result["out"]) + + def action_copy_command(self) -> None: + if sys.platform == "win32": + copy_command = ["clip"] + elif sys.platform == "darwin": + copy_command = ["pbcopy"] + else: + copy_command = ["xclip", "-selection", "clipboard"] + + try: + text_to_copy = self.input_tarea.selected_text + + # self.notify(f"`{copy_command}`") + # command = 'echo ' + text.strip() + '| clip' + # os.system(command) + + run( + copy_command, + input=text_to_copy, + text=True, + check=False, + ) + self.notify("Selction copied to clipboard.") + except FileNotFoundError: + self.notify(f"Could not copy to clipboard. `{copy_command[0]}` not found.", severity="error") + + def _get_selected_lines(self) -> Tuple[List[str], Location, Location]: + [first, last] = sorted([self.input_tarea.selection.start, self.input_tarea.selection.end]) + lines = [self.input_tarea.document.get_line(i) for i in range(first[0], last[0] + 1)] + return lines, first, last + + def action_toggle_comment(self) -> None: + # Setup for multiple language support + # INLINE_MARKERS = { + # "python": "#", + # "py": "#", + # } + # inline_comment_marker = INLINE_MARKERS.get(self.input_tarea.language) + inline_comment_marker = "#" + + if inline_comment_marker: + lines, first, last = self._get_selected_lines() + stripped_lines = [line.lstrip() for line in lines] + indents = [len(line) - len(line.lstrip()) for line in lines] + # if lines are already commented, remove them + if lines and all( + not line or line.startswith(inline_comment_marker) + for line in stripped_lines + ): + offsets = [ + 0 + if not line + else (2 if line[len(inline_comment_marker)].isspace() else 1) + for line in stripped_lines + ] + for lno, indent, offset in zip( + range(first[0], last[0] + 1), indents, offsets + ): + self.input_tarea.delete( + start=(lno, indent), + end=(lno, indent + offset), + maintain_selection_offset=True, + ) + # add comment tokens to all lines + else: + indent = min( + [indent for indent, line in zip(indents, stripped_lines) if line] + ) + for lno, stripped_line in enumerate(stripped_lines, start=first[0]): + if stripped_line: + self.input_tarea.insert( + f"{inline_comment_marker} ", + location=(lno, indent), + maintain_selection_offset=True, + ) + + def action_editor_keys(self) -> None: + self.app.push_screen(TextEditorBindingsInfo()) diff --git a/src/django_tui/management/commands/trogon.scss b/src/django_tui/management/commands/trogon.scss index 60bb3b4..53e90a8 100644 --- a/src/django_tui/management/commands/trogon.scss +++ b/src/django_tui/management/commands/trogon.scss @@ -286,3 +286,22 @@ Select.command-form-select:focus SelectCurrent { border: tall $accent; } + +.text-area{ + border-left: solid $accent; +} + +.status_bar{ + background: $primary-darken-3; +} + +#dialog { + grid-size: 2; + grid-gutter: 1 2; + grid-rows: 1fr 3; + padding: 0 1; + width: 95; + height: 20; + border: thick $background 80%; + background: $surface; + } \ No newline at end of file diff --git a/src/django_tui/management/commands/tui.py b/src/django_tui/management/commands/tui.py index bb9ef7e..4a6823d 100644 --- a/src/django_tui/management/commands/tui.py +++ b/src/django_tui/management/commands/tui.py @@ -25,6 +25,7 @@ Label, Static, Tree, + Header, ) from textual.widgets.tree import TreeNode from trogon.introspect import ArgumentSchema, CommandSchema, MultiValueParamData, OptionSchema @@ -34,7 +35,7 @@ from trogon.widgets.command_tree import CommandTree from trogon.widgets.form import CommandForm from trogon.widgets.multiple_choice import NonFocusableVerticalScroll - +from .ish import InteractiveShellScreen def introspect_django_commands() -> dict[str, CommandSchema]: groups = {} @@ -142,7 +143,6 @@ def introspect_django_commands() -> dict[str, CommandSchema]: return groups - class AboutDialog(TextDialog): DEFAULT_CSS = """ TextDialog > Vertical { @@ -160,7 +160,7 @@ def __init__(self) -> None: ) super().__init__(title, message) - +# 2 For the command screen class DjangoCommandBuilder(Screen): COMPONENT_CLASSES = {"version-string", "prompt", "command-name-syntax"} @@ -170,6 +170,8 @@ class DjangoCommandBuilder(Screen): Binding(key="ctrl+t", action="focus_command_tree", description="Focus Command Tree"), # Binding(key="ctrl+o", action="show_command_info", description="Command Info"), Binding(key="ctrl+s", action="focus('search')", description="Search"), + Binding(key="ctrl+j", action="select_mode('shell')", description="Shell"), + ("escape", "app.back", "Back"), Binding(key="f1", action="about", description="About"), ] @@ -337,12 +339,12 @@ async def _update_form_body(self, node: TreeNode[CommandSchema]) -> None: if not self.is_grouped_cli: command_form.focus() - class DjangoTui(App): CSS_PATH = Path(__file__).parent / "trogon.scss" def __init__( self, + open_shell: bool = False, ) -> None: super().__init__() self.post_run_command: list[str] = [] @@ -350,9 +352,15 @@ def __init__( self.execute_on_exit = False self.app_name = "python manage.py" self.command_name = "django-tui" + self.open_shell = open_shell + def on_mount(self): - self.push_screen(DjangoCommandBuilder(self.app_name, self.command_name)) + if self.open_shell: + self.push_screen(InteractiveShellScreen("Interactive Shell")) + else: + self.push_screen(DjangoCommandBuilder(self.app_name, self.command_name)) + # self.push_screen(HomeScreen(self.app_name)) @on(Button.Pressed, "#home-exec-button") def on_button_pressed(self): @@ -406,10 +414,20 @@ def action_visit(self, url: str) -> None: """ open_url(url) + def action_select_mode(self,mode_id:str) -> None: + if mode_id == "commands": + self.app.push_screen(DjangoCommandBuilder("pyhton manage.py", "Test command name")) + elif mode_id == "shell": + self.app.push_screen(InteractiveShellScreen("Interactive Shell")) class Command(BaseCommand): help = """Run and inspect Django commands in a text-based user interface (TUI).""" + def add_arguments(self, parser): + parser.add_argument("--shell",action="store_true", help="Open django shell") + def handle(self, *args: Any, **options: Any) -> None: - app = DjangoTui() + open_shell = options.get("shell") + + app = DjangoTui(open_shell=open_shell) app.run()