diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bb6e88 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +venv +env +.env +.eggs +*.egg-info +build +dist +__pycache__ +.pytest_cache +*.log +.coverage +coverage.xml diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..f81e7a1 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,10 @@ +{ + "objectscript.conn": { + "active": false, + "host": "localhost", + "ns": "USER", + "port": 8273, + "username": "_SYSTEM", + "password": "SYS" + } +} \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f6e2f51 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Dmitry Maslennikov + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c59913c --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# iTerm + +Original IRIS Terminal as web application, which can run shell commands and python + +```shell +zpm "install iterm" +``` + +![iTerm](https://raw.githubusercontent.com/caretdev/iterm/main/images/Screenshot1.png) + +## Demo tools + +To test how iterm works in browser, added to routines when running in docker + +term routine to test colors, formatting and special symbols + +```objectscript +do ^term +``` + +clock routine to test some alive application with positioning + +```objectscript +do ^clock +``` + +![iTerm](https://raw.githubusercontent.com/caretdev/iterm/main/images/Screenshot2.png) \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cf8a8a1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + iris: + image: intersystemsdc/iris-community:latest-preview-zpm + volumes: + - ./:/home/irisowner/iterm + ports: + - 8273:52773 + working_dir: /home/irisowner/iterm + environment: + - IRIS_USERNAME=_SYSTEM + - IRIS_PASSWORD=SYS + command: + -a /home/irisowner/iterm/init-dev.sh \ No newline at end of file diff --git a/images/Screenshot1.png b/images/Screenshot1.png new file mode 100644 index 0000000..5ea0a6c Binary files /dev/null and b/images/Screenshot1.png differ diff --git a/images/Screenshot2.png b/images/Screenshot2.png new file mode 100644 index 0000000..da2e424 Binary files /dev/null and b/images/Screenshot2.png differ diff --git a/init-dev.sh b/init-dev.sh new file mode 100755 index 0000000..fa266d5 --- /dev/null +++ b/init-dev.sh @@ -0,0 +1,17 @@ +#!/bin/bash + +SCRIPT_DIR=$( cd -- "$( dirname -- "${BASH_SOURCE[0]}" )" &> /dev/null && pwd ) + +cd $SCRIPT_DIR +pip install -e . + +cat <<"EOF" | iris session iris -U %SYS +zpm "load -v /home/irisowner/iterm" +halt +EOF + +cat <<"EOF" | iris session iris -U USER +do $system.OBJ.Load("/home/irisowner/iterm/src/clock.mac", "ck") +do $system.OBJ.Load("/home/irisowner/iterm/src/term.mac", "ck") +halt +EOF \ No newline at end of file diff --git a/iterm/__init__.py b/iterm/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/iterm/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/iterm/__main__.py b/iterm/__main__.py new file mode 100644 index 0000000..86c0df5 --- /dev/null +++ b/iterm/__main__.py @@ -0,0 +1,9 @@ +""" +iterm package main entry point +""" + +from .main import cli + + +if __name__ == "__main__": + cli() diff --git a/iterm/clitoolbar.py b/iterm/clitoolbar.py new file mode 100644 index 0000000..1ccda75 --- /dev/null +++ b/iterm/clitoolbar.py @@ -0,0 +1,44 @@ +from __future__ import unicode_literals + +from prompt_toolkit.key_binding.vi_state import InputMode +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.application import get_app + + +def create_toolbar_tokens_func(cli): + """ + Return a function that generates the toolbar tokens. + """ + + def get_toolbar_tokens(): + result = [] + result.append(("class:bottom-toolbar", " ")) + + if cli.multi_line: + result.append( + ("class:bottom-toolbar", " (Semi-colon [;] will end the line) ") + ) + + if cli.multi_line: + result.append(("class:bottom-toolbar.on", "[F3] Multiline: ON ")) + else: + result.append(("class:bottom-toolbar.off", "[F3] Multiline: OFF ")) + if cli.prompt_app.editing_mode == EditingMode.VI: + result.append( + ("class:botton-toolbar.on", "Vi-mode ({})".format(_get_vi_mode())) + ) + + return result + + return get_toolbar_tokens + + +def _get_vi_mode(): + """Get the current vi mode for display.""" + return { + InputMode.INSERT: "I", + InputMode.NAVIGATION: "N", + InputMode.REPLACE: "R", + InputMode.INSERT_MULTIPLE: "M", + InputMode.REPLACE_SINGLE: "R", + }[get_app().vi_state.input_mode] diff --git a/iterm/completer.py b/iterm/completer.py new file mode 100644 index 0000000..b1ea1ff --- /dev/null +++ b/iterm/completer.py @@ -0,0 +1,58 @@ +import logging +from re import compile, escape + +from prompt_toolkit.completion import Completer, Completion + +# from .packages.completion_engine import suggest_type +# from .packages.parseutils import last_word +# from .packages.special.iocommands import favoritequeries +# from .packages.filepaths import parse_path, complete_path, suggest_path + +_logger = logging.getLogger(__name__) + + +class IRISCompleter(Completer): + keywords = [ + "set", + "for", + "read", + "open", + "use", + "close", + "while", + "merge", + ] + + variables = [ + "$HOROLOG", + "$JOB", + "$NAMESPACE", + "$TLEVEL", + "$USERNAME", + "$ZHOROLOG", + "$ZJOB", + "$ZPI", + "$ZTIMESTAMP", + "$ZTIMEZONE", + "$ZVERSION", + ] + + def __init__(self, keyword_casing="auto"): + super(self.__class__, self).__init__() + self.reserved_words = set() + for x in self.keywords: + self.reserved_words.update(x.split()) + self.name_pattern = compile(r"^[_a-z][_a-z0-9\$]*$") + + self.special_commands = [] + if keyword_casing not in ("upper", "lower", "auto"): + keyword_casing = "auto" + self.keyword_casing = keyword_casing + # self.reset_completions() + + def get_completions(self, document, complete_event): + word_before_cursor = document.get_word_before_cursor(WORD=True) + completions = [] + suggestions = [] + + return completions diff --git a/iterm/completion_refresher.py b/iterm/completion_refresher.py new file mode 100644 index 0000000..b648b10 --- /dev/null +++ b/iterm/completion_refresher.py @@ -0,0 +1,130 @@ +import threading +from .packages.special.main import COMMANDS +from collections import OrderedDict + +from .sqlcompleter import SQLCompleter +from .sqlexecute import SQLExecute + + +class CompletionRefresher(object): + + refreshers = OrderedDict() + + def __init__(self): + self._completer_thread = None + self._restart_refresh = threading.Event() + + def refresh(self, executor, callbacks, completer_options=None): + + """Creates a SQLCompleter object and populates it with the relevant + completion suggestions in a background thread. + + executor - SQLExecute object, used to extract the credentials to connect + to the database. + callbacks - A function or a list of functions to call after the thread + has completed the refresh. The newly created completion + object will be passed in as an argument to each callback. + completer_options - dict of options to pass to SQLCompleter. + + """ + if completer_options is None: + completer_options = {} + + if self.is_refreshing(): + self._restart_refresh.set() + return [(None, None, None, "Auto-completion refresh restarted.")] + else: + self._completer_thread = threading.Thread( + target=self._bg_refresh, + args=(executor, callbacks, completer_options), + name="completion_refresh", + ) + self._completer_thread.setDaemon(True) + self._completer_thread.start() + return [ + ( + None, + None, + None, + "Auto-completion refresh started in the background.", + ) + ] + + def is_refreshing(self): + return self._completer_thread and self._completer_thread.is_alive() + + def _bg_refresh(self, sqlexecute, callbacks, completer_options): + completer = SQLCompleter(**completer_options) + + e = sqlexecute + # Create a new sqlexecute method to populate the completions. + executor = SQLExecute( + hostname=e.hostname, + port=e.port, + namespace=e.namespace, + username=e.username, + password=e.password, + embedded=e.embedded, + sslcontext=e.sslcontext, + **e.extra_params, + ) + + # If callbacks is a single function then push it into a list. + if callable(callbacks): + callbacks = [callbacks] + + while 1: + for refresher in self.refreshers.values(): + refresher(completer, executor) + if self._restart_refresh.is_set(): + self._restart_refresh.clear() + break + else: + # Break out of while loop if the for loop finishes naturally + # without hitting the break statement. + break + + # Start over the refresh from the beginning if the for loop hit the + # break statement. + continue + + for callback in callbacks: + callback(completer) + + +def refresher(name, refreshers=CompletionRefresher.refreshers): + """Decorator to add the decorated function to the dictionary of + refreshers. Any function decorated with a @refresher will be executed as + part of the completion refresh routine.""" + + def wrapper(wrapped): + refreshers[name] = wrapped + return wrapped + + return wrapper + + +# @refresher("databases") +# def refresh_databases(completer, executor): +# completer.extend_database_names(executor.databases()) + + +@refresher("schemas") +def refresh_schemata(completer, executor): + completer.extend_schemas(executor.schemas(), kind="tables") + + +@refresher("tables") +def refresh_tables(completer, executor): + completer.extend_relations(executor.tables(), kind="tables") + completer.extend_columns(executor.table_columns(), kind="tables") + + +# @refresher("functions") +# def refresh_functions(completer, executor): +# completer.extend_functions(executor.functions()) + + +@refresher("special_commands") +def refresh_special(completer, executor): + completer.extend_special_commands(COMMANDS.keys()) diff --git a/iterm/config.py b/iterm/config.py new file mode 100644 index 0000000..072cebb --- /dev/null +++ b/iterm/config.py @@ -0,0 +1,63 @@ +import errno +import shutil +import os +import platform +from os.path import expanduser, exists, dirname +from configobj import ConfigObj + + +def config_location(): + if "XDG_CONFIG_HOME" in os.environ: + return "%s/iterm/" % expanduser(os.environ["XDG_CONFIG_HOME"]) + elif platform.system() == "Windows": + return os.getenv("USERPROFILE") + "\\AppData\\Local\\dbcli\\iterm\\" + else: + return expanduser("~/.config/iterm/") + + +def load_config(usr_cfg, def_cfg=None): + cfg = ConfigObj() + cfg.merge(ConfigObj(def_cfg, interpolation=False)) + cfg.merge(ConfigObj(expanduser(usr_cfg), interpolation=False, encoding="utf-8")) + cfg.filename = expanduser(usr_cfg) + + return cfg + + +def ensure_dir_exists(path): + parent_dir = expanduser(dirname(path)) + try: + if parent_dir: + os.makedirs(parent_dir) + except OSError as exc: + # ignore existing destination (py2 has no exist_ok arg to makedirs) + if exc.errno != errno.EEXIST: + raise + + +def write_default_config(source, destination, overwrite=False): + destination = expanduser(destination) + if not overwrite and exists(destination): + return + + ensure_dir_exists(destination) + + shutil.copyfile(source, destination) + + +def upgrade_config(config, def_config): + cfg = load_config(config, def_config) + cfg.write() + + +def get_config(itermrc_file=None): + from iterm import __file__ as package_root + + package_root = os.path.dirname(package_root) + + itermrc_file = itermrc_file or "%sconfig" % config_location() + + default_config = os.path.join(package_root, "itermrc") + write_default_config(default_config, itermrc_file) + + return load_config(itermrc_file, default_config) diff --git a/iterm/irissession.py b/iterm/irissession.py new file mode 100644 index 0000000..b8981f9 --- /dev/null +++ b/iterm/irissession.py @@ -0,0 +1,87 @@ +import os +import sys +import pty +import subprocess +import time +import select + +def read_and_forward_pty_output(fd): + max_read_bytes = 1024 * 20 + while True: + timeout_sec = 0 + (data_ready, _, _) = select.select([fd], [], [], timeout_sec) + if not data_ready: + time.sleep(0.01) + continue + + output = os.read(fd, max_read_bytes).decode(errors="ignore") + # print(output) + +def command(): + # return ["iris", "session", "iris"] + import iris + bin = iris.system.Util.BinaryDirectory() + "irisdb" + mgr = "-s" + iris.system.Util.ManagerDirectory() + return [bin, mgr] + +class IRISSession(): + max_read_bytes = 1024 + + def __init__(self, pid, fd) -> None: + self.pid = pid + self.fd = fd + + @staticmethod + def start(cmd=None): + cmd = cmd if cmd else command() + + print('pid', os.getpid()) + master_fd, slave_fd = pty.openpty() + + proc = subprocess.Popen( + cmd, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + shell=True, + start_new_session=True, + ) + return IRISSession(proc.pid, master_fd) + + + (child_pid, fd) = pty.fork() + print('fork', child_pid, fd) + if child_pid == 0: + proc = subprocess.Popen(cmd if cmd else command()) + print('child', proc.pid) + else: + print('main') + return IRISSession(child_pid, fd) + + def read(self, timeout_sec = 0): + if not self.fd: + return + (data_ready, _, _) = select.select([self.fd], [], [], timeout_sec) + if not data_ready: + return + + return os.read(self.fd, self.max_read_bytes).decode(errors="ignore") + + def write(self, input: str): + if not self.fd: + return + + os.write(self.fd, input.encode()) + + def alive(self): + if not self.fd: + return + + os.write(self.fd, input.encode()) + + def running(self): + if not self.fd: + return + + return diff --git a/iterm/itermrc b/iterm/itermrc new file mode 100644 index 0000000..882d107 --- /dev/null +++ b/iterm/itermrc @@ -0,0 +1,111 @@ +# vi: ft=dosini +[main] + +# Multi-line mode allows breaking up the sql statements into multiple lines. If +# this is set to True, then the end of the statements must have a semi-colon. +# If this is set to False then sql statements can't be split into multiple +# lines. End of line (return) is considered as the end of the statement. +multi_line = False + +# Destructive warning mode will alert you before executing a sql statement +# that may cause harm to the database such as "drop table", "drop database" +# or "shutdown". +destructive_warning = True + +# log_file location. +# In Unix/Linux: ~/.config/iterm/log +# In Windows: %USERPROFILE%\AppData\Local\dbcli\iterm\log +# %USERPROFILE% is typically C:\Users\{username} +log_file = default + +# Default log level. Possible values: "CRITICAL", "ERROR", "WARNING", "INFO" +# and "DEBUG". "NONE" disables logging. +log_level = INFO + +# history_file location. +# In Unix/Linux: ~/.config/iterm/history +# In Windows: %USERPROFILE%\AppData\Local\dbcli\iterm\history +# %USERPROFILE% is typically C:\Users\{username} +history_file = default + +# Syntax coloring style. Possible values (many support the "-dark" suffix): +# manni, igor, xcode, vim, autumn, vs, rrt, native, perldoc, borland, tango, emacs, +# friendly, monokai, paraiso, colorful, murphy, bw, pastie, paraiso, trac, default, +# fruity. +# Screenshots at http://mycli.net/syntax +syntax_style = default + +# Keybindings: Possible values: emacs, vi. +# Emacs mode: Ctrl-A is home, Ctrl-E is end. All emacs keybindings are available in the REPL. +# When Vi mode is enabled you can use modal editing features offered by Vi in the REPL. +key_bindings = emacs + +# Enabling this option will show the suggestions in a wider menu. Thus more items are suggested. +wider_completion_menu = False + +# Autocompletion is on by default. This can be truned off by setting this +# option to False. Pressing tab will still trigger completion. +autocompletion = True + +# iterm prompt +# \t - Current date and time +# \u - Username +# \H - Hostname of the server +# \d|\N - Namespace +# \p - Database port +# \n - Newline +prompt = '\u@\N> ' +prompt_continuation = '-> ' + +# Number of lines to reserve for the suggestion menu +min_num_menu_lines = 4 + +# Character used to left pad multi-line queries to match the prompt size. +multiline_continuation_char = '' + +# The string used in place of a null value. +null_string = '' + +# Show/hide the informational toolbar with function keymap at the footer. +show_bottom_toolbar = True + +# Skip intro info on startup and outro info on exit +less_chatty = False + +# Use alias from --login-path instead of host name in prompt +login_path_as_host = False + +# keyword casing preference. Possible values "lower", "upper", "auto" +keyword_casing = auto + +# Custom colors for the completion menu, toolbar, etc. +[colors] +completion-menu.completion.current = 'bg:#ffffff #000000' +completion-menu.completion = 'bg:#008888 #ffffff' +completion-menu.meta.completion.current = 'bg:#44aaaa #000000' +completion-menu.meta.completion = 'bg:#448888 #ffffff' +completion-menu.multi-column-meta = 'bg:#aaffff #000000' +scrollbar.arrow = 'bg:#003333' +scrollbar = 'bg:#00aaaa' +selected = '#ffffff bg:#6666aa' +search = '#ffffff bg:#4444aa' +search.current = '#ffffff bg:#44aa44' +bottom-toolbar = 'bg:#222222 #aaaaaa' +bottom-toolbar.off = 'bg:#222222 #888888' +bottom-toolbar.on = 'bg:#222222 #ffffff' +search-toolbar = 'noinherit bold' +search-toolbar.text = 'nobold' +system-toolbar = 'noinherit bold' +arg-toolbar = 'noinherit bold' +arg-toolbar.text = 'nobold' +bottom-toolbar.transaction.valid = 'bg:#222222 #00ff5f bold' +bottom-toolbar.transaction.failed = 'bg:#222222 #ff005f bold' + +# style classes for colored table output +output.header = "#00ff5f bold" +output.odd-row = "" +output.even-row = "" + + +# Favorite queries. +[favorite_queries] diff --git a/iterm/itermrc_debug b/iterm/itermrc_debug new file mode 100644 index 0000000..3e1a35d --- /dev/null +++ b/iterm/itermrc_debug @@ -0,0 +1,4 @@ +[main] +log_file = ./.iterm.log +log_level = DEBUG +history_file = ./.iterm-history.log diff --git a/iterm/key_bindings.py b/iterm/key_bindings.py new file mode 100644 index 0000000..a237f07 --- /dev/null +++ b/iterm/key_bindings.py @@ -0,0 +1,83 @@ +from __future__ import unicode_literals +import logging +from prompt_toolkit.enums import EditingMode +from prompt_toolkit.filters import completion_is_selected +from prompt_toolkit.key_binding import KeyBindings + +_logger = logging.getLogger(__name__) + + +def iterm_bindings(cli): + kb = KeyBindings() + + @kb.add("f3") + def _(event): + """Enable/Disable Multiline Mode.""" + _logger.debug("Detected F3 key.") + cli.multi_line = not cli.multi_line + + @kb.add("f4") + def _(event): + """Toggle between Vi and Emacs mode.""" + _logger.debug("Detected F4 key.") + if cli.key_bindings == "vi": + event.app.editing_mode = EditingMode.EMACS + cli.key_bindings = "emacs" + else: + event.app.editing_mode = EditingMode.VI + cli.key_bindings = "vi" + + @kb.add("tab") + def _(event): + """Force autocompletion at cursor.""" + _logger.debug("Detected key.") + b = event.app.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=True) + + @kb.add("s-tab") + def _(event): + """Force autocompletion at cursor.""" + _logger.debug("Detected key.") + b = event.app.current_buffer + if b.complete_state: + b.complete_previous() + else: + b.start_completion(select_last=True) + + @kb.add("c-space") + def _(event): + """ + Initialize autocompletion at cursor. + + If the autocompletion menu is not showing, display it with the + appropriate completions for the context. + + If the menu is showing, select the next completion. + """ + _logger.debug("Detected key.") + + b = event.app.current_buffer + if b.complete_state: + b.complete_next() + else: + b.start_completion(select_first=False) + + @kb.add("enter", filter=completion_is_selected) + def _(event): + """Makes the enter key work as the tab key only when showing the menu. + + In other words, don't execute query when enter is pressed in + the completion dropdown menu, instead close the dropdown menu + (accept current selection). + + """ + _logger.debug("Detected enter key.") + + event.current_buffer.complete_state = None + b = event.app.current_buffer + b.complete_state = None + + return kb diff --git a/iterm/lexer.py b/iterm/lexer.py new file mode 100644 index 0000000..41e244b --- /dev/null +++ b/iterm/lexer.py @@ -0,0 +1,6 @@ +from pygments.lexer import inherit +from pygments.lexers.sql import SqlLexer + + +class IRISSqlLexer(SqlLexer): + pass diff --git a/iterm/main.py b/iterm/main.py new file mode 100644 index 0000000..7c5fdea --- /dev/null +++ b/iterm/main.py @@ -0,0 +1,591 @@ +import datetime as dt +import itertools +import functools +import logging +import os +import platform +import re +import sys +import ssl +import shutil +import threading +import traceback +from collections import namedtuple +from time import time +from getpass import getuser + +import click +import pendulum +from cli_helpers.tabular_output import TabularOutputFormatter +from cli_helpers.tabular_output.preprocessors import align_decimals, format_numbers +from cli_helpers.utils import strip_ansi +from prompt_toolkit.completion import DynamicCompleter, ThreadedCompleter +from prompt_toolkit.document import Document +from prompt_toolkit.enums import DEFAULT_BUFFER, EditingMode +from prompt_toolkit.filters import HasFocus, IsDone +from prompt_toolkit.formatted_text import ANSI +from prompt_toolkit.history import FileHistory +from prompt_toolkit.auto_suggest import AutoSuggestFromHistory +from prompt_toolkit.layout.processors import ( + ConditionalProcessor, + HighlightMatchingBracketProcessor, + TabsProcessor, +) +from prompt_toolkit.lexers import PygmentsLexer +from prompt_toolkit.shortcuts import CompleteStyle, PromptSession +from prompt_toolkit.input import create_input +from prompt_toolkit.keys import Keys + +from iterm.utils import parse_uri + +from .__init__ import __version__ +from .irissession import IRISSession +from .completer import IRISCompleter +from .clitoolbar import create_toolbar_tokens_func +from .config import config_location, get_config, ensure_dir_exists +from .key_bindings import iterm_bindings +from .style import style_factory, style_factory_output +from .packages.encodingutils import utf8tounicode, text_type +from .packages import special +from .packages.special import NO_QUERY +from .packages.prompt_utils import confirm + +try: + import iris +except Exception as ex: + print("oops", ex) + sys.exit(1) + +COLOR_CODE_REGEX = re.compile(r"\x1b(\[.*?[@-~]|\].*?(\x07|\x1b\\))") +DEFAULT_MAX_FIELD_WIDTH = 500 + +class iTermQuitError(Exception): + pass + + +class iTerm(object): + default_prompt = "\\u@\\N> " + max_len_prompt = 45 + + def __init__( + self, + quiet=False, + logfile=None, + itermrc=None, + warn=None, + ) -> None: + self.quiet = quiet + self.logfile = logfile + self.irissession = None + + self.username = iris.system.Process.UserName() + self.namespace = iris.system.Process.NameSpace() + + c = self.config = get_config(itermrc) + + self.output_file = None + + self.multi_line = c["main"].as_bool("multi_line") + c_dest_warning = c["main"].as_bool("destructive_warning") + self.destructive_warning = c_dest_warning if warn is None else warn + + self.min_num_menu_lines = c["main"].as_int("min_num_menu_lines") + + self.key_bindings = c["main"]["key_bindings"] + self.syntax_style = c["main"]["syntax_style"] + self.less_chatty = c["main"].as_bool("less_chatty") + self.show_bottom_toolbar = c["main"].as_bool("show_bottom_toolbar") + self.cli_style = c["colors"] + self.style_output = style_factory_output(self.syntax_style, self.cli_style) + self.wider_completion_menu = c["main"].as_bool("wider_completion_menu") + self.autocompletion = c["main"].as_bool("autocompletion") + self.login_path_as_host = c["main"].as_bool("login_path_as_host") + + self.logger = logging.getLogger(__name__) + self.initialize_logging() + + keyword_casing = c["main"].get("keyword_casing", "auto") + + self.now = dt.datetime.today() + + self.history = [] + + # Initialize completer. + self.completer = IRISCompleter( + keyword_casing=keyword_casing, + ) + self._completer_lock = threading.Lock() + self.prompt_format = c["main"].get("prompt", self.default_prompt) + + self.multiline_continuation_char = c["main"]["multiline_continuation_char"] + + self.register_special_commands() + + def quit(self): + raise iTermQuitError + + def register_special_commands(self): + special.register_special_command( + self.echo_test, + ".echo", + "", + "Outputs the value.", + case_sensitive=True, + arg_type=special.PARSED_QUERY, + ) + special.register_special_command( + self.change_prompt_format, + "prompt", + "\\R", + "Change prompt format.", + aliases=("\\R",), + case_sensitive=True, + ) + + def echo_test(self, arg, **_): + msg = arg + if len(msg) > 1: + msg = msg[1:-1] if msg[0] == "'" and msg[-1] == "'" else msg + msg = msg[1:-1] if msg[0] == '"' and msg[-1] == '"' else msg + print(msg) + yield (None, None, None, None) + + def change_prompt_format(self, arg, **_): + """ + Change the prompt format. + """ + if not arg: + message = "Missing required argument, format." + return [(None, None, None, message)] + + self.prompt_format = self.get_prompt(arg) + return [(None, None, None, "Changed prompt format to %s" % arg)] + + def get_prompt(self, string): + # should be before replacing \\d + string = string.replace("\\t", self.now.strftime("%x %X")) + string = string.replace("\\u", self.username) + string = string.replace("\\N", self.namespace) + string = string.replace("\\n", "\n") + return string + + def _build_cli(self, history): + key_bindings = iterm_bindings(self) + + def get_message(): + prompt_format = self.prompt_format + + prompt = self.get_prompt(prompt_format) + + if ( + prompt_format == self.default_prompt + and len(prompt) > self.max_len_prompt + ): + prompt = self.get_prompt("\\d> ") + + prompt = prompt.replace("\\x1b", "\x1b") + return ANSI(prompt) + + def get_continuation(width, line_number, is_soft_wrap): + continuation = self.multiline_continuation_char * (width - 1) + " " + return [("class:continuation", continuation)] + + get_toolbar_tokens = create_toolbar_tokens_func(self) + + if self.wider_completion_menu: + complete_style = CompleteStyle.MULTI_COLUMN + else: + complete_style = CompleteStyle.COLUMN + + with self._completer_lock: + prompt_app = PromptSession( + reserve_space_for_menu=self.min_num_menu_lines, + message=get_message, + prompt_continuation=get_continuation, + bottom_toolbar=get_toolbar_tokens if self.show_bottom_toolbar else None, + complete_style=complete_style, + input_processors=[ + # Highlight matching brackets while editing. + ConditionalProcessor( + processor=HighlightMatchingBracketProcessor(chars="[](){}"), + filter=HasFocus(DEFAULT_BUFFER) & ~IsDone(), + ), + # Render \t as 4 spaces instead of "^I" + TabsProcessor(char1=" ", char2=" "), + ], + auto_suggest=AutoSuggestFromHistory(), + history=history, + completer=ThreadedCompleter(DynamicCompleter(lambda: self.completer)), + complete_while_typing=True, + style=style_factory(self.syntax_style, self.cli_style), + include_default_pygments_style=False, + key_bindings=key_bindings, + enable_open_in_editor=True, + enable_system_prompt=True, + enable_suspend=True, + editing_mode=( + EditingMode.VI if self.key_bindings == "vi" else EditingMode.EMACS + ), + search_ignore_case=True, + ) + + return prompt_app + + def _evaluate_command(self, text): + output = [] + return output + + def execute_command(self, text, handle_closed_connection=True): + logger = self.logger + + self.irissession.write(text + "\n") + while True: + output = self.irissession.read(1) + if not output: + break + print(output) + + return text + + def refresh_completions(self, history=None, persist_priorities="all"): + """Refresh outdated completions + + :param history: A prompt_toolkit.history.FileHistory object. Used to + load keyword and identifier preferences + + :param persist_priorities: 'all' or 'keywords' + """ + + return + + def _on_completions_refreshed(self, new_completer, persist_priorities): + with self._completer_lock: + self.completer = new_completer + + if self.prompt_app: + # After refreshing, redraw the CLI to clear the statusbar + # "Refreshing completions..." indicator + self.prompt_app.app.invalidate() + + def get_completions(self, text, cursor_positition): + with self._completer_lock: + return self.completer.get_completions( + Document(text=text, cursor_position=cursor_positition), None + ) + + def log_output(self, output): + """Log the output in the audit log, if it's enabled.""" + if self.logfile: + click.echo(utf8tounicode(output), file=self.logfile) + + def echo(self, s, **kwargs): + """Print a message to stdout. + + The message will be logged in the audit log, if enabled. + + All keyword arguments are passed to click.echo(). + + """ + self.log_output(s) + click.secho(s, **kwargs) + + def get_output_margin(self, status=None): + """Get the output margin (number of rows for the prompt, footer and + timing message.""" + margin = ( + self.get_reserved_space() + + self.get_prompt(self.prompt_format).count("\n") + + 2 + ) + if status: + margin += 1 + status.count("\n") + + return margin + + def initialize_logging(self): + log_file = self.config["main"]["log_file"] + if log_file == "default": + log_file = config_location() + "log" + ensure_dir_exists(log_file) + log_level = "DEBUG" or self.config["main"]["log_level"] + + # Disable logging if value is NONE by switching to a no-op handler. + # Set log level to a high value so it doesn't even waste cycles getting called. + if log_level.upper() == "NONE": + handler = logging.NullHandler() + else: + handler = logging.FileHandler(os.path.expanduser(log_file)) + + level_map = { + "CRITICAL": logging.CRITICAL, + "ERROR": logging.ERROR, + "WARNING": logging.WARNING, + "INFO": logging.INFO, + "DEBUG": logging.DEBUG, + "NONE": logging.CRITICAL, + } + + log_level = level_map[log_level.upper()] + + formatter = logging.Formatter( + "%(asctime)s (%(process)d/%(threadName)s) " + "%(name)s %(levelname)s - %(message)s" + ) + + handler.setFormatter(formatter) + + root_logger = logging.getLogger("iterm") + root_logger.addHandler(handler) + root_logger.setLevel(log_level) + + root_logger.debug("Initializing iterm logging.") + root_logger.debug("Log file %r.", log_file) + + def read_my_cnf_files(self, keys): + """ + Reads a list of config files and merges them. The last one will win. + :param files: list of files to read + :param keys: list of keys to retrieve + :returns: tuple, with None for missing keys. + """ + cnf = self.config + + sections = ["main"] + + def get(key): + result = None + for sect in cnf: + if sect in sections and key in cnf[sect]: + result = cnf[sect][key] + return result + + return {x: get(x) for x in keys} + + def output(self, output, status=None): + """Output text to stdout or a pager command. + + The status text is not outputted to pager or files. + + The message will be logged in the audit log, if enabled. The + message will be written to the tee file, if enabled. The + message will be written to the output file, if enabled. + + """ + if output: + size = self.prompt_app.output.get_size() + + margin = self.get_output_margin(status) + + fits = True + buf = [] + output_via_pager = self.explicit_pager and special.is_pager_enabled() + for i, line in enumerate(output, 1): + self.log_output(line) + special.write_tee(line) + special.write_once(line) + + if fits or output_via_pager: + # buffering + buf.append(line) + if len(line) > size.columns or i > (size.rows - margin): + fits = False + if not self.explicit_pager and special.is_pager_enabled(): + # doesn't fit, use pager + output_via_pager = True + + if not output_via_pager: + # doesn't fit, flush buffer + for line in buf: + click.secho(line) + buf = [] + else: + click.secho(line) + + if buf: + if output_via_pager: + # sadly click.echo_via_pager doesn't accept generators + click.echo_via_pager("\n".join(buf)) + else: + for line in buf: + click.secho(line) + + if status: + self.log_output(status) + click.secho(status) + + def run_cli(self): + logger = self.logger + self.irissession = IRISSession.start() + + self.refresh_completions() + + history_file = self.config["main"]["history_file"] + if history_file == "default": + history_file = config_location() + "history" + history = FileHistory(os.path.expanduser(history_file)) + + self.prompt_app = self._build_cli(history) + self.input = create_input() + + output = self.irissession.read(5) + print(output) + + try: + while True: + try: + text = self.prompt_app.prompt() + except KeyboardInterrupt: + continue + + command = self.execute_command(text) + + self.history.append(command) + + self.now = dt.datetime.today() + + # with self._completer_lock: + # self.completer.extend_history(text) + + except (iTermQuitError, EOFError): + if not self.quiet: + print("Goodbye!") + + def get_reserved_space(self): + """Get the number of lines to reserve for the completion menu.""" + reserved_space_ratio = 0.45 + max_reserved_space = 8 + _, height = shutil.get_terminal_size() + return min(int(round(height * reserved_space_ratio)), max_reserved_space) + +CONTEXT_SETTINGS = {"help_option_names": ["--help"]} + + +@click.command(context_settings=CONTEXT_SETTINGS) +@click.option("-v", "--version", is_flag=True, help="Version of iterm.") +@click.option( + "-n", + "--nspace", + "namespace_opt", + envvar="IRIS_NAMESPACE", + help="namespace name to connect to.", +) +@click.option( + "-q", + "--quiet", + "quiet", + is_flag=True, + default=False, + help="Quiet mode, skip intro on startup and goodbye on exit.", +) +@click.option( + "-l", + "--logfile", + type=click.File(mode="a", encoding="utf-8"), + help="Log every query and its results to a file.", +) +@click.option( + "--itermrc", + default=config_location() + "config", + help="Location of itermrc file.", + type=click.Path(dir_okay=False), +) +@click.option("-e", "--execute", type=str, help="Execute command and quit.") +def cli( + version, + namespace_opt, + quiet, + logfile, + itermrc, + execute, +): + if version: + print("Version:", __version__) + sys.exit(0) + + namespace = namespace_opt.upper() if namespace_opt else None + iterm = iTerm( + quiet, + logfile=logfile, + itermrc=itermrc, + ) + + # --execute argument + if execute: + try: + # iterm.run_query(execute) + exit(0) + except Exception as e: + click.secho(str(e), err=True, fg="red") + sys.exit(1) + + if sys.stdin.isatty(): + iterm.run_cli() + else: + stdin = click.get_text_stream("stdin") + stdin_text = stdin.read() + + try: + sys.stdin = open("/dev/tty") + except (FileNotFoundError, OSError): + iterm.logger.warning("Unable to open TTY as stdin.") + + if ( + iterm.destructive_warning + ): + exit(0) + try: + new_line = True + + iterm.run_query(stdin_text, new_line=new_line) + exit(0) + except Exception as e: + click.secho(str(e), err=True, fg="red") + exit(1) + + +def has_change_db_cmd(query): + """Determines if the statement is a database switch such as 'use' or '\\c'""" + try: + first_token = query.split()[0] + if first_token.lower() in ("use", "\\c", "\\connect"): + return True + except Exception: + return False + + return False + + +def has_change_path_cmd(sql): + """Determines if the search_path should be refreshed by checking if the + sql has 'set search_path'.""" + return "set search_path" in sql.lower() + + +def is_mutating(status): + """Determines if the statement is mutating based on the status.""" + if not status: + return False + + mutating = {"insert", "update", "delete"} + return status.split(None, 1)[0].lower() in mutating + + +def has_meta_cmd(query): + """Determines if the completion needs a refresh by checking if the sql + statement is an alter, create, drop, commit or rollback.""" + try: + first_token = query.split()[0] + if first_token.lower() in ("alter", "create", "drop", "commit", "rollback"): + return True + except Exception: + return False + + return False + + +def exception_formatter(e): + return click.style(str(e), fg="red") + + +if __name__ == "__main__": + cli() diff --git a/iterm/packages/__init__.py b/iterm/packages/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iterm/packages/compat.py b/iterm/packages/compat.py new file mode 100644 index 0000000..7316261 --- /dev/null +++ b/iterm/packages/compat.py @@ -0,0 +1,9 @@ +# -*- coding: utf-8 -*- +"""Platform and Python version compatibility support.""" + +import sys + + +PY2 = sys.version_info[0] == 2 +PY3 = sys.version_info[0] == 3 +WIN = sys.platform in ("win32", "cygwin") diff --git a/iterm/packages/completion_engine.py b/iterm/packages/completion_engine.py new file mode 100644 index 0000000..e8455bf --- /dev/null +++ b/iterm/packages/completion_engine.py @@ -0,0 +1,43 @@ +from __future__ import print_function +from sqlparse.sql import Comparison, Identifier, Where +from .encodingutils import string_types, text_type +from .parseutils import last_word, extract_tables, find_prev_keyword +from .special import parse_special_command + + +def suggest_type(full_text, text_before_cursor): + + return [{"type": "keyword"}] + +def suggest_special(text): + text = text.lstrip() + cmd, _, arg = parse_special_command(text) + + if cmd == text: + # Trying to complete the special command itself + return [{"type": "special"}] + + if cmd in ["\\.", "source", ".open", ".read"]: + return [{"type": "file_name"}] + + return [{"type": "keyword"}, {"type": "special"}] + + +def _expecting_arg_idx(arg, text): + """Return the index of expecting argument. + + >>> _expecting_arg_idx("./da", ".import ./da") + 1 + >>> _expecting_arg_idx("./data.csv", ".import ./data.csv") + 1 + >>> _expecting_arg_idx("./data.csv", ".import ./data.csv ") + 2 + >>> _expecting_arg_idx("./data.csv t", ".import ./data.csv t") + 2 + """ + args = arg.split() + return len(args) + int(text[-1].isspace()) + + +def identifies(id, schema, table, alias): + return id == alias or id == table or (schema and (id == schema + "." + table)) diff --git a/iterm/packages/encodingutils.py b/iterm/packages/encodingutils.py new file mode 100644 index 0000000..8ff7aba --- /dev/null +++ b/iterm/packages/encodingutils.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from .compat import PY2 + + +if PY2: + binary_type = str + string_types = basestring + text_type = unicode +else: + binary_type = bytes + string_types = str + text_type = str + + +def unicode2utf8(arg): + """Convert strings to UTF8-encoded bytes. + + Only in Python 2. In Python 3 the args are expected as unicode. + + """ + + if PY2 and isinstance(arg, text_type): + return arg.encode("utf-8") + return arg + + +def utf8tounicode(arg): + """Convert UTF8-encoded bytes to strings. + + Only in Python 2. In Python 3 the errors are returned as strings. + + """ + + if PY2 and isinstance(arg, binary_type): + return arg.decode("utf-8") + return arg diff --git a/iterm/packages/filepaths.py b/iterm/packages/filepaths.py new file mode 100644 index 0000000..2659d3e --- /dev/null +++ b/iterm/packages/filepaths.py @@ -0,0 +1,88 @@ +# -*- coding: utf-8 + +from __future__ import unicode_literals + +from .encodingutils import text_type +import os + + +def list_path(root_dir): + """List directory if exists. + + :param dir: str + :return: list + + """ + res = [] + if os.path.isdir(root_dir): + for name in os.listdir(root_dir): + res.append(name) + return res + + +def complete_path(curr_dir, last_dir): + """Return the path to complete that matches the last entered component. + + If the last entered component is ~, expanded path would not + match, so return all of the available paths. + + :param curr_dir: str + :param last_dir: str + :return: str + + """ + if not last_dir or curr_dir.startswith(last_dir): + return curr_dir + elif last_dir == "~": + return os.path.join(last_dir, curr_dir) + + +def parse_path(root_dir): + """Split path into head and last component for the completer. + + Also return position where last component starts. + + :param root_dir: str path + :return: tuple of (string, string, int) + + """ + base_dir, last_dir, position = "", "", 0 + if root_dir: + base_dir, last_dir = os.path.split(root_dir) + position = -len(last_dir) if last_dir else 0 + return base_dir, last_dir, position + + +def suggest_path(root_dir): + """List all files and subdirectories in a directory. + + If the directory is not specified, suggest root directory, + user directory, current and parent directory. + + :param root_dir: string: directory to list + :return: list + + """ + if not root_dir: + return map(text_type, [os.path.abspath(os.sep), "~", os.curdir, os.pardir]) + + if "~" in root_dir: + root_dir = text_type(os.path.expanduser(root_dir)) + + if not os.path.exists(root_dir): + root_dir, _ = os.path.split(root_dir) + + return list_path(root_dir) + + +def dir_path_exists(path): + """Check if the directory path exists for a given file. + + For example, for a file /home/user/.cache/litecli/log, check if + /home/user/.cache/litecli exists. + + :param str path: The file path. + :return: Whether or not the directory path exists. + + """ + return os.path.exists(os.path.dirname(path)) diff --git a/iterm/packages/parseutils.py b/iterm/packages/parseutils.py new file mode 100644 index 0000000..da19daa --- /dev/null +++ b/iterm/packages/parseutils.py @@ -0,0 +1,60 @@ +from __future__ import print_function +import re + +cleanup_regex = { + # This matches only alphanumerics and underscores. + "alphanum_underscore": re.compile(r"(\w+)$"), + # This matches everything except spaces, parens, colon, and comma + "many_punctuations": re.compile(r"([^():,\s]+)$"), + # This matches everything except spaces, parens, colon, comma, and period + "most_punctuations": re.compile(r"([^\.():,\s]+)$"), + # This matches everything except a space. + "all_punctuations": re.compile(r"([^\s]+)$"), +} + + +def last_word(text, include="alphanum_underscore"): + """ + Find the last word in a sentence. + + >>> last_word('abc') + 'abc' + >>> last_word(' abc') + 'abc' + >>> last_word('') + '' + >>> last_word(' ') + '' + >>> last_word('abc ') + '' + >>> last_word('abc def') + 'def' + >>> last_word('abc def ') + '' + >>> last_word('abc def;') + '' + >>> last_word('bac $def') + 'def' + >>> last_word('bac $def', include='most_punctuations') + '$def' + >>> last_word('bac \\def', include='most_punctuations') + '\\\\def' + >>> last_word('bac \\def;', include='most_punctuations') + '\\\\def;' + >>> last_word('bac::def', include='most_punctuations') + 'def' + """ + + if not text: # Empty string + return "" + + if text[-1].isspace(): + return "" + else: + regex = cleanup_regex[include] + matches = regex.search(text) + if matches: + return matches.group(0) + else: + return "" + diff --git a/iterm/packages/prompt_utils.py b/iterm/packages/prompt_utils.py new file mode 100644 index 0000000..dcf5828 --- /dev/null +++ b/iterm/packages/prompt_utils.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + + +import sys +import click + + +def confirm(*args, **kwargs): + """Prompt for confirmation (yes/no) and handle any abort exceptions.""" + try: + return click.confirm(*args, **kwargs) + except click.Abort: + return False + + +def prompt(*args, **kwargs): + """Prompt the user for input and handle any abort exceptions.""" + try: + return click.prompt(*args, **kwargs) + except click.Abort: + return False diff --git a/iterm/packages/special/__init__.py b/iterm/packages/special/__init__.py new file mode 100644 index 0000000..0d8389b --- /dev/null +++ b/iterm/packages/special/__init__.py @@ -0,0 +1,13 @@ +__all__ = [] + + +def export(defn): + """Decorator to explicitly mark functions that are exposed in a lib.""" + globals()[defn.__name__] = defn + __all__.append(defn.__name__) + return defn + + +from .main import NO_QUERY, RAW_QUERY, PARSED_QUERY +from . import dbcommands +from . import iocommands diff --git a/iterm/packages/special/dbcommands.py b/iterm/packages/special/dbcommands.py new file mode 100644 index 0000000..0c3c348 --- /dev/null +++ b/iterm/packages/special/dbcommands.py @@ -0,0 +1,145 @@ +from __future__ import unicode_literals, print_function +import logging + +from iterm import __version__ +from .main import special_command, PARSED_QUERY + +log = logging.getLogger(__name__) + + +@special_command( + ".schemas", + "\\ds [schema]", + "List schemas.", + arg_type=PARSED_QUERY, + case_sensitive=True, + aliases=("\\ds",), +) +def list_schemas(cur, arg=None, arg_type=PARSED_QUERY, verbose=False): + if arg: + args = ("{0}%".format(arg),) + query = """ + SELECT SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + WHERE SCHEMA_NAME LIKE ? + ORDER BY SCHEMA_NAME + """ + else: + args = tuple() + query = """ + SELECT SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + WHERE + NOT SCHEMA_NAME %STARTSWITH '%' + AND NOT SCHEMA_NAME %STARTSWITH 'Ens' + AND SCHEMA_NAME <> 'INFORMATION_SCHEMA' + ORDER BY SCHEMA_NAME + """ + + log.debug(query) + cur.execute(query, args) + tables = cur.fetchall() + status = "" + if cur.description: + headers = [x[0] for x in cur.description] + else: + return [(None, None, None, "")] + + return [(None, tables, headers, status)] + + +@special_command( + ".tables", + "\\dt [schema]", + "List tables.", + arg_type=PARSED_QUERY, + case_sensitive=True, + aliases=("\\dt",), +) +def list_tables(cur, arg=None, arg_type=PARSED_QUERY, verbose=False): + schema = arg + query = """ + SELECT TABLE_SCHEMA || '.' || TABLE_NAME AS TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE + """ + if schema: + args = ("{0}%".format(schema),) + query += """ + TABLE_SCHEMA LIKE ? + """ + else: + args = tuple() + query += """ + NOT TABLE_SCHEMA %STARTSWITH '%' + AND NOT TABLE_SCHEMA %STARTSWITH 'Ens' + AND TABLE_SCHEMA <> 'INFORMATION_SCHEMA' + """ + + log.debug(query) + cur.execute(query, args) + tables = cur.fetchall() + status = "" + if cur.description: + headers = [x[0] for x in cur.description] + else: + return [(None, None, None, "")] + + return [(None, tables, headers, status)] + + +@special_command( + "tstart", + "\\ts", + "Start a Database Transaction.", + arg_type=PARSED_QUERY, + case_sensitive=True, + aliases=("\\ts",), +) +def start_db_transaction(cur, arg=None, arg_type=PARSED_QUERY, verbose=False): + cur.execute("START TRANSACTION") + status = "" + if cur.description: + headers = [x[0] for x in cur.description] + else: + return [(None, None, None, "Transaction Started")] + + return [(None, None, headers, status)] + + +@special_command( + "tcommit", + "\\tc", + "Commit a Database Transaction.", + arg_type=PARSED_QUERY, + case_sensitive=True, + aliases=("\\tc",), +) +def commit_db_transaction(cur, arg=None, arg_type=PARSED_QUERY, verbose=False): + cur.execute("COMMIT") + status = "" + if cur.description: + headers = [x[0] for x in cur.description] + else: + return [(None, None, None, "Transaction Committed")] + + return [(None, None, headers, status)] + + +@special_command( + "trollback", + "\\tr", + "Rollback a Database Transaction.", + arg_type=PARSED_QUERY, + case_sensitive=True, + aliases=("\\tr",), +) +def rollback_db_transaction(cur, arg=None, arg_type=PARSED_QUERY, verbose=False): + cur.execute("ROLLBACK") + status = "" + if cur.description: + headers = [x[0] for x in cur.description] + else: + return [(None, None, None, "Transaction Rolled Back")] + + return [(None, None, headers, status)] diff --git a/iterm/packages/special/favoritequeries.py b/iterm/packages/special/favoritequeries.py new file mode 100644 index 0000000..7da6fbf --- /dev/null +++ b/iterm/packages/special/favoritequeries.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + + +class FavoriteQueries(object): + + section_name = "favorite_queries" + + usage = """ +Favorite Queries are a way to save frequently used queries +with a short name. +Examples: + + # Save a new favorite query. + > \\fs simple select * from abc where a is not Null; + + # List all favorite queries. + > \\f + ╒════════╤═══════════════════════════════════════╕ + │ Name │ Query │ + ╞════════╪═══════════════════════════════════════╡ + │ simple │ SELECT * FROM abc where a is not NULL │ + ╘════════╧═══════════════════════════════════════╛ + + # Run a favorite query. + > \\f simple + ╒════════╤════════╕ + │ a │ b │ + ╞════════╪════════╡ + │ 日本語 │ 日本語 │ + ╘════════╧════════╛ + + # Delete a favorite query. + > \\fd simple + simple: Deleted +""" + + def __init__(self, config): + self.config = config + + def list(self): + return self.config.get(self.section_name, []) + + def get(self, name): + return self.config.get(self.section_name, {}).get(name, None) + + def save(self, name, query): + if self.section_name not in self.config: + self.config[self.section_name] = {} + self.config[self.section_name][name] = query + self.config.write() + + def delete(self, name): + try: + del self.config[self.section_name][name] + except KeyError: + return "%s: Not Found." % name + self.config.write() + return "%s: Deleted" % name diff --git a/iterm/packages/special/iocommands.py b/iterm/packages/special/iocommands.py new file mode 100644 index 0000000..1525c11 --- /dev/null +++ b/iterm/packages/special/iocommands.py @@ -0,0 +1,165 @@ +from __future__ import unicode_literals +import os +from io import open + +import click +from configobj import ConfigObj + +from . import export +from .main import special_command, NO_QUERY, PARSED_QUERY +from .favoritequeries import FavoriteQueries + +use_expanded_output = False +PAGER_ENABLED = True +tee_file = None +once_file = written_to_once_file = None +favoritequeries = FavoriteQueries(ConfigObj()) + + +@export +def set_favorite_queries(config): + global favoritequeries + favoritequeries = FavoriteQueries(config) + + +@export +def set_pager_enabled(val): + global PAGER_ENABLED + PAGER_ENABLED = val + + +@export +def is_pager_enabled(): + return PAGER_ENABLED + + +@export +@special_command( + "pager", + "\\P [command]", + "Set PAGER. Print the query results via PAGER.", + arg_type=PARSED_QUERY, + aliases=("\\P",), + case_sensitive=True, +) +def set_pager(arg, **_): + if arg: + os.environ["PAGER"] = arg + msg = "PAGER set to %s." % arg + set_pager_enabled(True) + else: + if "PAGER" in os.environ: + msg = "PAGER set to %s." % os.environ["PAGER"] + else: + # This uses click's default per echo_via_pager. + msg = "Pager enabled." + set_pager_enabled(True) + + return [(None, None, None, msg)] + + +@export +@special_command( + "nopager", + "\\n", + "Disable pager, print to stdout.", + arg_type=NO_QUERY, + aliases=("\\n",), + case_sensitive=True, +) +def disable_pager(): + set_pager_enabled(False) + return [(None, None, None, "Pager disabled.")] + + +def parseargfile(arg): + if arg.startswith("-o "): + mode = "w" + filename = arg[3:] + else: + mode = "a" + filename = arg + + if not filename: + raise TypeError("You must provide a filename.") + + return {"file": os.path.expanduser(filename), "mode": mode} + + +@special_command( + "tee", + "tee [-o] filename", + "Append all results to an output file (overwrite using -o).", +) +def set_tee(arg, **_): + global tee_file + + try: + tee_file = open(**parseargfile(arg)) + except (IOError, OSError) as e: + raise OSError("Cannot write to file '{}': {}".format(e.filename, e.strerror)) + + return [(None, None, None, "")] + + +@export +def close_tee(): + global tee_file + if tee_file: + tee_file.close() + tee_file = None + + +@special_command("notee", "notee", "Stop writing results to an output file.") +def no_tee(arg, **_): + close_tee() + return [(None, None, None, "")] + + +@export +def write_tee(output): + global tee_file + if tee_file: + click.echo(output, file=tee_file, nl=False) + click.echo("\n", file=tee_file, nl=False) + tee_file.flush() + + +@special_command( + ".once", + "\\o [-o] filename", + "Append next result to an output file (overwrite using -o).", + aliases=("\\o", "\\once"), +) +def set_once(arg, **_): + global once_file + + once_file = parseargfile(arg) + + return [(None, None, None, "")] + + +@export +def write_once(output): + global once_file, written_to_once_file + if output and once_file: + try: + f = open(**once_file) + except (IOError, OSError) as e: + once_file = None + raise OSError( + "Cannot write to file '{}': {}".format(e.filename, e.strerror) + ) + + with f: + click.echo(output, file=f, nl=False) + click.echo("\n", file=f, nl=False) + written_to_once_file = True + + +@export +def unset_once_if_written(): + """Unset the once file, if it has been written to.""" + global once_file, written_to_once_file + if written_to_once_file: + once_file = written_to_once_file = None diff --git a/iterm/packages/special/main.py b/iterm/packages/special/main.py new file mode 100644 index 0000000..64cd0dd --- /dev/null +++ b/iterm/packages/special/main.py @@ -0,0 +1,160 @@ +from __future__ import unicode_literals +import logging +from collections import namedtuple + +from . import export + +log = logging.getLogger(__name__) + +NO_QUERY = 0 +PARSED_QUERY = 1 +RAW_QUERY = 2 + +SpecialCommand = namedtuple( + "SpecialCommand", + [ + "handler", + "command", + "shortcut", + "description", + "arg_type", + "hidden", + "case_sensitive", + ], +) + +COMMANDS = {} + + +@export +class ArgumentMissing(Exception): + pass + + +@export +class CommandNotFound(Exception): + pass + + +@export +def parse_special_command(sql): + command, _, arg = sql.partition(" ") + verbose = "+" in command + command = command.strip().replace("+", "") + return (command, verbose, arg.strip()) + + +@export +def special_command( + command, + shortcut, + description, + arg_type=PARSED_QUERY, + hidden=False, + case_sensitive=False, + aliases=(), +): + def wrapper(wrapped): + register_special_command( + wrapped, + command, + shortcut, + description, + arg_type, + hidden, + case_sensitive, + aliases, + ) + return wrapped + + return wrapper + + +@export +def register_special_command( + handler, + command, + shortcut, + description, + arg_type=PARSED_QUERY, + hidden=False, + case_sensitive=False, + aliases=(), +): + cmd = command.lower() if not case_sensitive else command + COMMANDS[cmd] = SpecialCommand( + handler, command, shortcut, description, arg_type, hidden, case_sensitive + ) + for alias in aliases: + cmd = alias.lower() if not case_sensitive else alias + COMMANDS[cmd] = SpecialCommand( + handler, + command, + shortcut, + description, + arg_type, + case_sensitive=case_sensitive, + hidden=True, + ) + + +@export +def execute(cur, sql): + """Execute a special command and return the results. If the special command + is not supported a KeyError will be raised. + """ + command, verbose, arg = parse_special_command(sql) + + if (command not in COMMANDS) and (command.lower() not in COMMANDS): + raise CommandNotFound + + try: + special_cmd = COMMANDS[command] + except KeyError: + special_cmd = COMMANDS[command.lower()] + if special_cmd.case_sensitive: + raise CommandNotFound("Command not found: %s" % command) + + if special_cmd.arg_type == NO_QUERY: + return special_cmd.handler() + elif special_cmd.arg_type == PARSED_QUERY: + return special_cmd.handler(cur=cur, arg=arg, verbose=verbose) + elif special_cmd.arg_type == RAW_QUERY: + return special_cmd.handler(cur=cur, query=sql) + + +@special_command( + "help", "\\?", "Show this help.", arg_type=NO_QUERY, aliases=("\\?", "?") +) +def show_help(): # All the parameters are ignored. + headers = ["Command", "Shortcut", "Description"] + result = [] + + for _, value in sorted(COMMANDS.items()): + if not value.hidden: + result.append((value.command, value.shortcut, value.description)) + return [(None, result, headers, None)] + + +@special_command(".exit", "\\q", "Exit.", arg_type=NO_QUERY, aliases=("\\q", "exit")) +@special_command("quit", "\\q", "Quit.", arg_type=NO_QUERY) +def quit(*_args): + raise EOFError + + +@special_command( + "\\e", + "\\e", + "Edit command with editor (uses $EDITOR).", + arg_type=NO_QUERY, + case_sensitive=True, +) +# @special_command( +# "\\G", +# "\\G", +# "Display current query results vertically.", +# arg_type=NO_QUERY, +# case_sensitive=True, +# ) +def stub(): + raise NotImplementedError diff --git a/iterm/packages/special/utils.py b/iterm/packages/special/utils.py new file mode 100644 index 0000000..eed9306 --- /dev/null +++ b/iterm/packages/special/utils.py @@ -0,0 +1,48 @@ +import os +import subprocess + + +def handle_cd_command(arg): + """Handles a `cd` shell command by calling python's os.chdir.""" + CD_CMD = "cd" + tokens = arg.split(CD_CMD + " ") + directory = tokens[-1] if len(tokens) > 1 else None + if not directory: + return False, "No folder name was provided." + try: + os.chdir(directory) + subprocess.call(["pwd"]) + return True, None + except OSError as e: + return False, e.strerror + + +def format_uptime(uptime_in_seconds): + """Format number of seconds into human-readable string. + + :param uptime_in_seconds: The server uptime in seconds. + :returns: A human-readable string representing the uptime. + + >>> uptime = format_uptime('56892') + >>> print(uptime) + 15 hours 48 min 12 sec + """ + + m, s = divmod(int(uptime_in_seconds), 60) + h, m = divmod(m, 60) + d, h = divmod(h, 24) + + uptime_values = [] + + for value, unit in ((d, "days"), (h, "hours"), (m, "min"), (s, "sec")): + if value == 0 and not uptime_values: + # Don't include a value/unit if the unit isn't applicable to + # the uptime. E.g. don't do 0 days 0 hours 1 min 30 sec. + continue + elif value == 1 and unit.endswith("s"): + # Remove the "s" if the unit is singular. + unit = unit[:-1] + uptime_values.append("{0} {1}".format(value, unit)) + + uptime = " ".join(uptime_values) + return uptime diff --git a/iterm/sqlcompleter.py b/iterm/sqlcompleter.py new file mode 100644 index 0000000..58462f1 --- /dev/null +++ b/iterm/sqlcompleter.py @@ -0,0 +1,725 @@ +from __future__ import print_function +from __future__ import unicode_literals +import logging +from re import compile, escape +from collections import Counter + +from prompt_toolkit.completion import Completer, Completion + +from .packages.completion_engine import suggest_type +from .packages.parseutils import last_word +from .packages.special.iocommands import favoritequeries +from .packages.filepaths import parse_path, complete_path, suggest_path + +_logger = logging.getLogger(__name__) + + +class SQLCompleter(Completer): + keywords = [ + "ABORT", + "ACTION", + "ADD", + "AFTER", + "ALL", + "ALTER", + "ANALYZE", + "AND", + "AS", + "ASC", + "ATTACH", + "AUTOINCREMENT", + "BEFORE", + "BEGIN", + "BETWEEN", + "BIGINT", + "BLOB", + "BOOLEAN", + "BY", + "CASCADE", + "CASE", + "CAST", + "CHARACTER", + "CHECK", + "CLOB", + "COLLATE", + "COLUMN", + "COMMIT", + "CONFLICT", + "CONSTRAINT", + "CREATE", + "CROSS", + "CURRENT", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "DATABASE", + "DATE", + "DATETIME", + "DECIMAL", + "DEFAULT", + "DEFERRABLE", + "DEFERRED", + "DELETE", + "DETACH", + "DISTINCT", + "DO", + "DOUBLE PRECISION", + "DOUBLE", + "DROP", + "EACH", + "ELSE", + "END", + "ESCAPE", + "EXCEPT", + "EXCLUSIVE", + "EXISTS", + "EXPLAIN", + "FAIL", + "FILTER", + "FLOAT", + "FOLLOWING", + "FOR", + "FOREIGN", + "FROM", + "FULL", + "GLOB", + "GROUP", + "HAVING", + "IF", + "IGNORE", + "IMMEDIATE", + "IN", + "INDEX", + "INDEXED", + "INITIALLY", + "INNER", + "INSERT", + "INSTEAD", + "INT", + "INT2", + "INT8", + "INTEGER", + "INTERSECT", + "INTO", + "IS", + "ISNULL", + "JOIN", + "KEY", + "LEFT", + "LIKE", + "MATCH", + "MEDIUMINT", + "NATIVE CHARACTER", + "NATURAL", + "NCHAR", + "NO", + "NOT", + "NOTHING", + "NULL", + "NULLS FIRST", + "NULLS LAST", + "NUMERIC", + "NVARCHAR", + "OF", + "OFFSET", + "ON", + "OR", + "ORDER BY", + "OUTER", + "OVER", + "PARTITION", + "PLAN", + "PRAGMA", + "PRECEDING", + "PRIMARY", + "QUERY", + "RAISE", + "RANGE", + "REAL", + "RECURSIVE", + "REFERENCES", + "REGEXP", + "REINDEX", + "RELEASE", + "RENAME", + "REPLACE", + "RESTRICT", + "RIGHT", + "ROLLBACK", + "ROW", + "ROWS", + "SAVEPOINT", + "SELECT", + "SET", + "SMALLINT", + "TABLE", + "TEMP", + "TEMPORARY", + "TEXT", + "THEN", + "TINYINT", + "TO", + "TRANSACTION", + "TRIGGER", + "UNBOUNDED", + "UNION", + "UNIQUE", + "UNSIGNED BIG INT", + "UPDATE", + "USING", + "VACUUM", + "VALUES", + "VARCHAR", + "VARYING CHARACTER", + "VIEW", + "VIRTUAL", + "WHEN", + "WHERE", + "WINDOW", + "WITH", + "WITHOUT", + ] + + agg_functions = [ + "AVG", + "COUNT", + "%DLIST", + "LIST", + "MIN", + "MAX", + ] + + functions = [ + "ABS", + "ACOS", + "ASCII", + "ASIN", + "ATAN", + "ATAN2", + "CAST", + "CEILING", + "CHAR", + "CHARACTER_LENGTH", + "CHARINDEX", + "CHAR_LENGTH", + "COALESCE", + "CONCAT", + "CONVERT", + "COS", + "COT", + "CURDATE", + "CURRENT_DATE", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURTIME", + "DATABASE", + "DATALENGTH", + "DATE", + "DATEADD", + "DATEDIFF", + "DATENAME", + "DATEPART", + "DAY", + "DAYNAME", + "DAYOFMONTH", + "DAYOFWEEK", + "DAYOFYEAR", + "DECODE", + "DEGREES", + "%EXACT", + "EXP", + "%EXTERNAL", + "$EXTRACT", + "$FIND", + "FLOOR", + "GETDATE", + "GETUTCDATE", + "GREATEST", + "HOUR", + "IFNULL", + "INSTR", + "%INTERNAL", + "ISNULL", + "ISNUMERIC", + "JSON_ARRAY", + "JSON_OBJECT", + "$JUSTIFY", + "LAST_DAY", + "LAST_IDENTITY", + "LCASE", + "LEAST", + "LEFT", + "LEN", + "LENGTH", + "$LENGTH", + "$LIST", + "$LISTBUILD", + "$LISTDATA", + "$LISTFIND", + "$LISTFROMSTRING", + "$LISTGET", + "$LISTLENGTH", + "$LISTSAME", + "$LISTTOSTRING", + "LOG", + "LOG10", + "LOWER", + "LPAD", + "LTRIM", + "%MINUS", + "MINUTE", + "MOD", + "MONTH", + "MONTHNAME", + "NOW", + "NULLIF", + "NVL", + "%OBJECT", + "%ODBCIN", + "%ODBCOUT", + "%OID", + "PI", + "$PIECE", + "%PLUS", + "POSITION", + "POWER", + "PREDICT", + "PROBABILITY", + "QUARTER", + "RADIANS", + "REPEAT", + "REPLACE", + "REPLICATE", + "REVERSE", + "RIGHT", + "ROUND", + "RPAD", + "RTRIM", + "SEARCH_INDEX", + "SECOND", + "SIGN", + "SIN", + "SPACE", + "%SQLSTRING", + "%SQLUPPER", + "SQRT", + "SQUARE", + "STR", + "STRING", + "STUFF", + "SUBSTR", + "SUBSTRING", + "SYSDATE", + "TAN", + "TIMESTAMPADD", + "TIMESTAMPDIFF", + "TO_CHAR", + "TO_DATE", + "TO_NUMBER", + "TO_POSIXTIME", + "TO_TIMESTAMP", + "$TRANSLATE", + "TRIM", + "TRUNCATE", + "%TRUNCATE", + "$TSQL_NEWID", + "UCASE", + "UNIX_TIMESTAMP", + "UPPER", + "USER", + "WEEK", + "XMLCONCAT", + "XMLELEMENT", + "XMLFOREST", + "YEAR", + ] + + variables = [ + "$HOROLOG", + "$JOB", + "$NAMESPACE", + "$TLEVEL", + "$USERNAME", + "$ZHOROLOG", + "$ZJOB", + "$ZPI", + "$ZTIMESTAMP", + "$ZTIMEZONE", + "$ZVERSION", + ] + + def __init__(self, supported_formats=(), keyword_casing="auto"): + super(self.__class__, self).__init__() + self.reserved_words = set() + for x in self.keywords: + self.reserved_words.update(x.split()) + self.name_pattern = compile(r"^[_a-z][_a-z0-9\$]*$") + + self.special_commands = [] + self.table_formats = supported_formats + if keyword_casing not in ("upper", "lower", "auto"): + keyword_casing = "auto" + self.keyword_casing = keyword_casing + self.reset_completions() + + def escape_name(self, name): + if name and ( + (not self.name_pattern.match(name)) + or (name.upper() in self.reserved_words) + or (name.upper() in self.functions) + ): + name = ".".join(['"%s"' % n for n in name.split(".")]) + + return name + + def unescape_name(self, name): + """Unquote a string.""" + if name and name[0] == '"' and name[-1] == '"': + name = name[1:-1] + + return name + + def escaped_names(self, names): + return [self.escape_name(name) for name in names] + + def extend_special_commands(self, special_commands): + # Special commands are not part of all_completions since they can only + # be at the beginning of a line. + self.special_commands.extend(special_commands) + + def extend_database_names(self, databases): + self.databases.extend(databases) + + def extend_keywords(self, additional_keywords): + self.keywords.extend(additional_keywords) + self.all_completions.update(additional_keywords) + + def extend_schemas(self, data, kind): + try: + data = [self.escaped_names(d) for d in data] + except Exception as ex: + logging.exception(ex) + data = [] + + metadata = self.dbmetadata[kind] + for [ + schema, + ] in data: + metadata[schema] = {} + self.all_completions.add(schema) + + def extend_relations(self, data, kind): + """Extend metadata for tables or views + + :param data: list of (rel_name, ) tuples + :param kind: either 'tables' or 'views' + :return: + """ + # 'data' is a generator object. It can throw an exception while being + # consumed. This could happen if the user has launched the app without + # specifying a database name. This exception must be handled to prevent + # crashing. + try: + data = [self.escaped_names(d) for d in data] + except Exception as ex: + logging.exception(ex) + data = [] + + # dbmetadata['tables'][$schema_name][$table_name] should be a list of + # column names. Default to an asterisk + metadata = self.dbmetadata[kind] + for [schema, relname] in data: + metadata[schema] = metadata[schema] if schema in metadata else {} + metadata[schema][relname] = ["*"] + self.all_completions.add(relname) + + def extend_columns(self, column_data, kind): + """Extend column metadata + + :param column_data: list of (rel_name, column_name) tuples + :param kind: either 'tables' or 'views' + :return: + """ + # 'column_data' is a generator object. It can throw an exception while + # being consumed. This could happen if the user has launched the app + # without specifying a database name. This exception must be handled to + # prevent crashing. + try: + column_data = [self.escaped_names(d) for d in column_data] + except Exception as ex: + logging.exception(ex) + column_data = [] + + metadata = self.dbmetadata[kind] + for schema, relname, column in column_data: + metadata[schema][relname].append(column) + self.all_completions.add(column) + + def extend_functions(self, func_data): + # 'func_data' is a generator object. It can throw an exception while + # being consumed. This could happen if the user has launched the app + # without specifying a database name. This exception must be handled to + # prevent crashing. + try: + func_data = [self.escaped_names(d) for d in func_data] + except Exception: + func_data = [] + + # dbmetadata['functions'][$schema_name][$function_name] should return + # function metadata. + metadata = self.dbmetadata["functions"] + + for func in func_data: + metadata[func[0]] = None + self.all_completions.add(func[0]) + + def reset_completions(self): + self.databases = [] + self.dbmetadata = {"tables": {}, "views": {}, "functions": {}} + self.all_completions = set( + self.keywords + self.agg_functions + self.functions + self.variables + ) + + @staticmethod + def find_matches( + text, + collection, + start_only=False, + fuzzy=True, + casing=None, + punctuations="most_punctuations", + ): + """Find completion matches for the given text. + + Given the user's input text and a collection of available + completions, find completions matching the last word of the + text. + + If `start_only` is True, the text will match an available + completion only at the beginning. Otherwise, a completion is + considered a match if the text appears anywhere within it. + + yields prompt_toolkit Completion instances for any matches found + in the collection of available completions. + """ + last = last_word(text, include=punctuations) + text = last.lower() + + completions = [] + + if fuzzy: + regex = ".*?".join(map(escape, text)) + pat = compile("(%s)" % regex) + for item in sorted(collection): + r = pat.search(item.lower()) + if r: + completions.append((len(r.group()), r.start(), item)) + else: + match_end_limit = len(text) if start_only else None + for item in sorted(collection): + match_point = item.lower().find(text, 0, match_end_limit) + if match_point >= 0: + completions.append((len(text), match_point, item)) + + if casing == "auto": + casing = "lower" if last and last[-1].islower() else "upper" + + def apply_case(kw): + if casing == "upper": + return kw.upper() + return kw.lower() + + _logger.debug( + "find_matches: %r; %r - %r/%r", + fuzzy, + text, + len(collection), + len(completions), + ) + return ( + Completion(z if casing is None else apply_case(z), -len(text)) + for x, y, z in sorted(completions) + ) + + def get_completions(self, document, complete_event): + word_before_cursor = document.get_word_before_cursor(WORD=True) + completions = [] + suggestions = [] + suggestions = suggest_type(document.text, document.text_before_cursor) + + for suggestion in suggestions: + + _logger.debug("Suggestion for: %r", document.text) + _logger.debug("Suggestion type: %r", suggestion["type"]) + + if suggestion["type"] == "column": + tables = suggestion["tables"] + _logger.debug("Completion column scope: %r", tables) + scoped_cols = self.populate_scoped_cols(tables) + if suggestion.get("drop_unique"): + # drop_unique is used for 'tb11 JOIN tbl2 USING (...' + # which should suggest only columns that appear in more than + # one table + scoped_cols = [ + col + for (col, count) in Counter(scoped_cols).items() + if count > 1 and col != "*" + ] + + cols = self.find_matches(word_before_cursor, scoped_cols) + completions.extend(cols) + + elif suggestion["type"] == "function": + # suggest user-defined functions using substring matching + funcs = self.populate_schema_objects(suggestion["schema"], "functions") + user_funcs = self.find_matches(word_before_cursor, funcs) + completions.extend(user_funcs) + + # suggest hardcoded functions using startswith matching only if + # there is no schema qualifier. If a schema qualifier is + # present it probably denotes a table. + # eg: SELECT * FROM users u WHERE u. + if not suggestion["schema"]: + predefined_funcs = self.find_matches( + word_before_cursor, + self.functions + self.agg_functions + self.variables, + start_only=True, + fuzzy=False, + casing=self.keyword_casing, + ) + completions.extend(predefined_funcs) + + elif suggestion["type"] == "schema": + schemas = self.populate_schema_objects(None, "schemas") + schemas = self.find_matches(word_before_cursor, schemas) + completions.extend(schemas) + + elif suggestion["type"] == "table": + tables = self.populate_schema_objects(suggestion["schema"], "tables") + tables = self.find_matches(word_before_cursor, tables) + completions.extend(tables) + + elif suggestion["type"] == "view": + views = self.populate_schema_objects(suggestion["schema"], "views") + views = self.find_matches(word_before_cursor, views) + completions.extend(views) + + elif suggestion["type"] == "alias": + aliases = suggestion["aliases"] + aliases = self.find_matches(word_before_cursor, aliases) + completions.extend(aliases) + + elif suggestion["type"] == "database": + dbs = self.find_matches(word_before_cursor, self.databases) + completions.extend(dbs) + + elif suggestion["type"] == "keyword": + keywords = self.find_matches( + word_before_cursor, + self.keywords, + start_only=True, + fuzzy=False, + casing=self.keyword_casing, + punctuations="many_punctuations", + ) + completions.extend(keywords) + + elif suggestion["type"] == "special": + special = self.find_matches( + word_before_cursor, + self.special_commands, + start_only=True, + fuzzy=False, + punctuations="many_punctuations", + ) + completions.extend(special) + # elif suggestion["type"] == "favoritequery": + # queries = self.find_matches( + # word_before_cursor, + # favoritequeries.list(), + # start_only=False, + # fuzzy=True, + # ) + # completions.extend(queries) + elif suggestion["type"] == "table_format": + formats = self.find_matches( + word_before_cursor, self.table_formats, start_only=True, fuzzy=False + ) + completions.extend(formats) + elif suggestion["type"] == "file_name": + file_names = self.find_files(word_before_cursor) + completions.extend(file_names) + + _logger.debug("Completions: %r", len(completions)) + return completions + + def find_files(self, word): + """Yield matching directory or file names. + + :param word: + :return: iterable + + """ + # base_path, last_path, position = parse_path(word) + # paths = suggest_path(word) + # for name in sorted(paths): + # suggestion = complete_path(name, last_path) + # if suggestion: + # yield Completion(suggestion, position) + + def populate_scoped_cols(self, scoped_tbls): + """Find all columns in a set of scoped_tables + :param scoped_tbls: list of (schema, table, alias) tuples + :return: list of column names + """ + columns = [] + meta = self.dbmetadata + + _logger.debug("populate_scoped_cols: %r", scoped_tbls) + + for (schema, relname, _) in scoped_tbls: + _logger.debug("populate_scoped_cols: %r.%r", schema, relname) + # A fully qualified schema.relname reference or default_schema + # DO NOT escape schema names. + schema = schema if schema is not None else "SQLUser" + + for obj_type in ["tables", "views"]: + if not obj_type in meta: + continue + for _schema in [schema, self.escape_name(schema)]: + if not _schema in meta[obj_type]: + continue + for _relname in [relname, self.escape_name(relname)]: + if not _relname in meta[obj_type][_schema]: + continue + columns.extend(meta[obj_type][_schema][_relname]) + return list(set(columns)) + + def populate_schema_objects(self, schema, obj_type): + """Returns list of tables or functions for a (optional) schema""" + objects = [] + if obj_type == "schemas": + obj_type = "tables" + schema = "SQLUser" + schemas = [] + schemas.extend(self.dbmetadata["tables"].keys()) + schemas.extend(self.dbmetadata["views"].keys()) + objects.extend([schema + "." for schema in schemas]) + metadata = self.dbmetadata[obj_type] + schema = ( + schema if not isinstance(schema, list) else schema[0] if schema else None + ) + if schema is None: + return objects + try: + if schema is None: + objects = metadata.keys() + elif schema in metadata: + objects.extend(metadata[schema].keys()) + elif self.escape_name(schema) in metadata: + objects.extend(metadata[self.escape_name(schema)].keys()) + except KeyError: + _logger.debug("populate_schema_objects error: %r - %r\n", schema, obj_type) + # schema doesn't exist + + return objects diff --git a/iterm/sqlexecute.py b/iterm/sqlexecute.py new file mode 100644 index 0000000..436a5ba --- /dev/null +++ b/iterm/sqlexecute.py @@ -0,0 +1,208 @@ +import logging +import intersystems_iris.dbapi._DBAPI as dbapi +import sqlparse +import traceback + +from .packages import special +from .utils import parse_uri + +_logger = logging.getLogger(__name__) + + +class SQLExecute: + schemas_query = """ + SELECT + SCHEMA_NAME + FROM INFORMATION_SCHEMA.SCHEMATA + WHERE + NOT SCHEMA_NAME %STARTSWITH '%' + AND NOT SCHEMA_NAME %STARTSWITH 'Ens' + AND SCHEMA_NAME <> 'INFORMATION_SCHEMA' + ORDER BY SCHEMA_NAME + """ + + tables_query = """ + SELECT TABLE_SCHEMA, TABLE_NAME + FROM INFORMATION_SCHEMA.TABLES + WHERE + NOT TABLE_SCHEMA %STARTSWITH '%' + AND NOT TABLE_SCHEMA %STARTSWITH 'Ens' + AND TABLE_SCHEMA <> 'INFORMATION_SCHEMA' + ORDER BY TABLE_SCHEMA, TABLE_NAME + """ + + table_columns_query = """ + SELECT + TABLE_SCHEMA, + TABLE_NAME, + COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE + NOT TABLE_SCHEMA %STARTSWITH '%' + AND NOT TABLE_SCHEMA %STARTSWITH 'Ens' + AND TABLE_SCHEMA <> 'INFORMATION_SCHEMA' + ORDER BY TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME + """ + + def __init__( + self, + hostname, + port, + namespace, + username, + password, + embedded=False, + sslcontext=None, + **kw, + ) -> None: + self.hostname = hostname + self.port = port + self.namespace = namespace + self.username = username + self.password = password + self.embedded = embedded + self.sslcontext = sslcontext + self.extra_params = kw + + self.server_version = None + + self.connect() + + def from_uri(uri): + hostname, port, namespace, username, password, embedded = parse_uri(uri) + return SQLExecute( + hostname=hostname, + port=port, + namespace=namespace, + username=username, + password=password, + embedded=embedded, + ) + + def connect(self): + conn_params = { + "hostname": self.hostname, + "port": self.port, + "namespace": self.namespace, + "username": self.username, + "password": self.password, + "sslcontext": self.sslcontext, + } + conn_params["embedded"] = self.embedded + conn_params.update(self.extra_params) + + conn = dbapi.connect(**conn_params) + self.conn = conn + self.conn.setAutoCommit(True) + if self.embedded: + self.server_version = self.conn.iris.system.Version.GetVersion() + self.username = self.conn.iris.system.Process.UserName() + self.namespace = self.conn.iris.system.Process.NameSpace() + self.hostname = self.conn.iris.system.Util.InstallDirectory() + else: + self.server_version = self.conn._connection_info._server_version + + def run( + self, + statement, + ): + statement = statement.strip() + if not statement: # Empty string + yield None, None, None, None, statement, False, False + + sqltemp = [] + sqlarr = [] + + statement = "\n".join( + [line for line in statement.split("\n") if not line.startswith("--")] + ) + if statement.startswith("--"): + sqltemp = statement.split("\n") + sqlarr.append(sqltemp[0]) + for i in sqlparse.split(sqltemp[1]): + sqlarr.append(i) + elif statement.startswith("/*"): + sqltemp = statement.split("*/") + sqltemp[0] = sqltemp[0] + "*/" + for i in sqlparse.split(sqltemp[1]): + sqlarr.append(i) + else: + sqlarr = sqlparse.split(statement) + + # run each sql query + for sql in sqlarr: + # Remove spaces, eol and semi-colons. + sql = sql.rstrip(";") + sql = sqlparse.format(sql, strip_comments=False).strip() + if not sql: + continue + + try: + try: + cur = self.conn.cursor() + except dbapi.InterfaceError: + cur = None + try: + _logger.debug("Trying a dbspecial command. sql: %r", sql) + for result in special.execute(cur, sql): + yield result + (sql, True, True) + except special.CommandNotFound: + yield self.execute_normal_sql(sql) + (sql, True, False) + + except dbapi.OperationalError as e: + _logger.error("sql: %r, error: %r", sql, e) + _logger.error("traceback: %r", traceback.format_exc()) + + yield None, None, None, e, sql, False, False + + def execute_normal_sql(self, split_sql): + """Returns tuple (title, rows, headers, status)""" + _logger.debug("Regular sql statement. sql: %r", split_sql) + + title = headers = None + + cursor = self.conn.cursor() + cursor.execute(split_sql) + + # cur.description will be None for operations that do not return + # rows. + if cursor.description: + headers = [x[0] for x in cursor.description] + status = "{0} row{1} in set" + cursor = list(cursor) + rowcount = len(cursor) + else: + _logger.debug("No rows in result.") + status = "Query OK, {0} row{1} affected" + rowcount = 0 if cursor.rowcount == -1 else cursor.rowcount + cursor = None + + status = status.format(rowcount, "" if rowcount == 1 else "s") + + return (title, cursor, headers, status) + + def schemas(self): + """Yields schema names""" + + with self.conn.cursor() as cur: + _logger.debug("Schemas Query. sql: %r", self.schemas_query) + cur.execute(self.schemas_query) + for row in cur: + yield row + + def tables(self): + """Yields table names""" + + with self.conn.cursor() as cur: + _logger.debug("Tables Query. sql: %r", self.tables_query) + cur.execute(self.tables_query) + for row in cur: + yield row + + def table_columns(self): + """Yields column names""" + with self.conn.cursor() as cur: + _logger.debug("Columns Query. sql: %r", self.table_columns_query) + cur.execute(self.table_columns_query) + for row in cur: + yield row diff --git a/iterm/style.py b/iterm/style.py new file mode 100644 index 0000000..7527315 --- /dev/null +++ b/iterm/style.py @@ -0,0 +1,114 @@ +from __future__ import unicode_literals + +import logging + +import pygments.styles +from pygments.token import string_to_tokentype, Token +from pygments.style import Style as PygmentsStyle +from pygments.util import ClassNotFound +from prompt_toolkit.styles.pygments import style_from_pygments_cls +from prompt_toolkit.styles import merge_styles, Style + +logger = logging.getLogger(__name__) + +# map Pygments tokens (ptk 1.0) to class names (ptk 2.0). +TOKEN_TO_PROMPT_STYLE = { + Token.Menu.Completions.Completion.Current: "completion-menu.completion.current", + Token.Menu.Completions.Completion: "completion-menu.completion", + Token.Menu.Completions.Meta.Current: "completion-menu.meta.completion.current", + Token.Menu.Completions.Meta: "completion-menu.meta.completion", + Token.Menu.Completions.MultiColumnMeta: "completion-menu.multi-column-meta", + Token.Menu.Completions.ProgressButton: "scrollbar.arrow", # best guess + Token.Menu.Completions.ProgressBar: "scrollbar", # best guess + Token.SelectedText: "selected", + Token.SearchMatch: "search", + Token.SearchMatch.Current: "search.current", + Token.Toolbar: "bottom-toolbar", + Token.Toolbar.Off: "bottom-toolbar.off", + Token.Toolbar.On: "bottom-toolbar.on", + Token.Toolbar.Search: "search-toolbar", + Token.Toolbar.Search.Text: "search-toolbar.text", + Token.Toolbar.System: "system-toolbar", + Token.Toolbar.Arg: "arg-toolbar", + Token.Toolbar.Arg.Text: "arg-toolbar.text", + Token.Toolbar.Transaction.Valid: "bottom-toolbar.transaction.valid", + Token.Toolbar.Transaction.Failed: "bottom-toolbar.transaction.failed", + Token.Output.Header: "output.header", + Token.Output.OddRow: "output.odd-row", + Token.Output.EvenRow: "output.even-row", + Token.Prompt: "prompt", + Token.Continuation: "continuation", +} + +# reverse dict for cli_helpers, because they still expect Pygments tokens. +PROMPT_STYLE_TO_TOKEN = {v: k for k, v in TOKEN_TO_PROMPT_STYLE.items()} + + +def parse_pygments_style(token_name, style_object, style_dict): + """Parse token type and style string. + + :param token_name: str name of Pygments token. Example: "Token.String" + :param style_object: pygments.style.Style instance to use as base + :param style_dict: dict of token names and their styles, customized to this cli + + """ + token_type = string_to_tokentype(token_name) + try: + other_token_type = string_to_tokentype(style_dict[token_name]) + return token_type, style_object.styles[other_token_type] + except AttributeError as err: + return token_type, style_dict[token_name] + + +def style_factory(name, cli_style): + try: + style = pygments.styles.get_style_by_name(name) + except ClassNotFound: + style = pygments.styles.get_style_by_name("native") + + prompt_styles = [] + # prompt-toolkit used pygments tokens for styling before, switched to style + # names in 2.0. Convert old token types to new style names, for backwards compatibility. + for token in cli_style: + if token.startswith("Token."): + # treat as pygments token (1.0) + token_type, style_value = parse_pygments_style(token, style, cli_style) + if token_type in TOKEN_TO_PROMPT_STYLE: + prompt_style = TOKEN_TO_PROMPT_STYLE[token_type] + prompt_styles.append((prompt_style, style_value)) + else: + # we don't want to support tokens anymore + logger.error("Unhandled style / class name: %s", token) + else: + # treat as prompt style name (2.0). See default style names here: + # https://github.com/jonathanslenders/python-prompt-toolkit/blob/master/prompt_toolkit/styles/defaults.py + prompt_styles.append((token, cli_style[token])) + + override_style = Style([("bottom-toolbar", "noreverse")]) + return merge_styles( + [style_from_pygments_cls(style), override_style, Style(prompt_styles)] + ) + + +def style_factory_output(name, cli_style): + try: + style = pygments.styles.get_style_by_name(name).styles + except ClassNotFound: + style = pygments.styles.get_style_by_name("native").styles + + for token in cli_style: + if token.startswith("Token."): + token_type, style_value = parse_pygments_style(token, style, cli_style) + style.update({token_type: style_value}) + elif token in PROMPT_STYLE_TO_TOKEN: + token_type = PROMPT_STYLE_TO_TOKEN[token] + style.update({token_type: cli_style[token]}) + else: + # TODO: cli helpers will have to switch to ptk.Style + logger.error("Unhandled style / class name: %s", token) + + class OutputStyle(PygmentsStyle): + default_style = "" + styles = style + + return OutputStyle diff --git a/iterm/utils.py b/iterm/utils.py new file mode 100644 index 0000000..f9b7985 --- /dev/null +++ b/iterm/utils.py @@ -0,0 +1,15 @@ +from urllib.parse import urlparse + + +def parse_uri(uri, hostname=None, port=None, namespace=None, username=None): + parsed = urlparse(uri) + embedded = False + if str(parsed.scheme).startswith("iris"): + namespace = parsed.path.split("/")[1] if parsed.path else None or namespace + username = parsed.username or username + password = parsed.password or None + hostname = parsed.hostname or hostname + port = parsed.port or port + if parsed.scheme == "iris+emb": + embedded = True + return hostname, port, namespace, username, password, embedded \ No newline at end of file diff --git a/iterm/xterm/__init__.py b/iterm/xterm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iterm/xterm/admin.py b/iterm/xterm/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/iterm/xterm/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/iterm/xterm/apps.py b/iterm/xterm/apps.py new file mode 100644 index 0000000..57407ff --- /dev/null +++ b/iterm/xterm/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class XtermConfig(AppConfig): + name = 'xterm' diff --git a/iterm/xterm/migrations/__init__.py b/iterm/xterm/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/iterm/xterm/models.py b/iterm/xterm/models.py new file mode 100644 index 0000000..71a8362 --- /dev/null +++ b/iterm/xterm/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/iterm/xterm/templates/index.html b/iterm/xterm/templates/index.html new file mode 100644 index 0000000..479a802 --- /dev/null +++ b/iterm/xterm/templates/index.html @@ -0,0 +1,34 @@ + + + + + + + + +
+ + + diff --git a/iterm/xterm/tests.py b/iterm/xterm/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/iterm/xterm/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/iterm/xterm/views.py b/iterm/xterm/views.py new file mode 100644 index 0000000..40a4e16 --- /dev/null +++ b/iterm/xterm/views.py @@ -0,0 +1,103 @@ +import os +from django.shortcuts import render +import socketio +import pty +import select +import subprocess +import struct +import fcntl +import termios +import signal +import eventlet + + +async_mode = "eventlet" +sio = socketio.Server(async_mode=async_mode) + +# will be used as global variables +fd = None +child_pid = None + + +def index(request): + return render(request, "index.html") + + +# changes the size reported to TTY-aware applications like vim +def set_winsize(fd, row, col, xpix=0, ypix=0): + winsize = struct.pack("HHHH", row, col, xpix, ypix) + fcntl.ioctl(fd, termios.TIOCSWINSZ, winsize) + + +def read_and_forward_pty_output(): + global fd + max_read_bytes = 1024 * 20 + while True: + sio.sleep(0.01) + if fd: + timeout_sec = 0 + (data_ready, _, _) = select.select([fd], [], [], timeout_sec) + if data_ready: + output = os.read(fd, max_read_bytes).decode() + sio.emit("pty_output", {"output": output}) + else: + print("process killed") + return + + +@sio.event +def resize(sid, message): + if fd: + set_winsize(fd, message["rows"], message["cols"]) + + +@sio.event +def pty_input(sid, message): + if fd: + os.write(fd, message["input"].encode()) + + +@sio.event +def disconnect_request(sid): + sio.disconnect(sid) + + +@sio.event +def connect(sid, environ): + global fd + global child_pid + + if child_pid: + # already started child process, don't start another + # write a new line so that when a client refresh the shell prompt is printed + os.write(fd, "\n".encode()) + return + + # create child process attached to a pty we can read from and write to + (child_pid, fd) = pty.fork() + + if child_pid == 0: + # this is the child process fork. + # anything printed here will show up in the pty, including the output + # of this subprocess + subprocess.run(["docker", "exec", "-it", "iris", "iris", "session", "iris"]) + + else: + # this is the parent process fork. + sio.start_background_task(target=read_and_forward_pty_output) + + +@sio.event +def disconnect(sid): + + global fd + global child_pid + + # kill pty process + os.kill(child_pid, signal.SIGKILL) + os.wait() + + # reset the variables + fd = None + child_pid = None + print("Client disconnected") diff --git a/module.xml b/module.xml new file mode 100644 index 0000000..3e6ba4d --- /dev/null +++ b/module.xml @@ -0,0 +1,28 @@ + + + + + iterm + 0.1.0 + terminal + iTerm + module + src + + + + + + + \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..17c35e9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +click>=4.1 +Pygments>=2.0 +prompt_toolkit>=3.0.3,<4.0.0 +configobj>=5.0.6 +pendulum~=3.0.0 +cli_helpers[styles]>=2.2.1 diff --git a/setup.cfg b/setup.cfg new file mode 100644 index 0000000..4c4d9e8 --- /dev/null +++ b/setup.cfg @@ -0,0 +1,27 @@ +[options] +python_requires = >=3.7 +packages = + iterm + +[tool:pytest] +testpaths = + tests +addopts = -ra + --capture=sys + --showlocals + --doctest-modules + --doctest-ignore-import-errors + --ignore=setup.py + --ignore=test/features + +[pep8] +rev = master +docformatter = True +diff = True +error-status = True + +[autoflake] +check=true +quiet=true +recursive=true +files={iterm,tests} diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..ed981ed --- /dev/null +++ b/setup.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import ast +from io import open +import re +import os +from setuptools import setup, find_packages + +_version_re = re.compile(r"__version__\s+=\s+(.*)") + +with open("iterm/__init__.py", "rb") as f: + version = str( + ast.literal_eval(_version_re.search(f.read().decode("utf-8")).group(1)) + ) + + +def open_file(filename): + """Open and read the file *filename*.""" + with open(filename) as f: + return f.read() + + +readme = open_file("README.md") + +install_requirements = [ + "click >= 4.1", + "Pygments>=2.0", + "prompt_toolkit>=3.0.3,<4.0.0", + "configobj >= 5.0.6", + "pendulum ~= 3.0.0", + "cli_helpers[styles] >= 2.2.1", +] + +setup( + name="iterm", + author="CaretDev", + author_email="iterm@caretdev.com", + license="MIT", + version=version, + url="https://github.com/caretdev/iterm", + packages=find_packages(), + package_data={"iterm": ["itermrc", "AUTHORS"]}, + description="Terminal for InterSystems IRIS Databases with " + "auto-completion and syntax highlighting.", + long_description=readme, + long_description_content_type="text/markdown", + install_requires=install_requirements, + entry_points={ + "console_scripts": [ + "iterm = iterm.main:cli", + ], + "distutils.commands": ["lint = tasks:lint", "test = tasks:test"], + }, + classifiers=[ + "Intended Audience :: Developers", + "License :: OSI Approved :: MIT License", + "Operating System :: Unix", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Topic :: Database", + "Topic :: Database :: Front-Ends", + "Topic :: Software Development", + "Topic :: Software Development :: Libraries :: Python Modules", + ], +) diff --git a/src/clock.mac b/src/clock.mac new file mode 100644 index 0000000..6f380ae --- /dev/null +++ b/src/clock.mac @@ -0,0 +1,10 @@ +ROUTINE clock +Q N R,Q,C,D,E,W,B,G,H,S,T,U,V,F,L,P,N,J,A S N=$G(N),Q='N,F=Q+Q,P=F+F,W=$L($T(Q)) + S W=$E(W,Q),S='N_+N,W=W-F*S,L=$G(L),R=$C(Q_F_P),R(F)=$C(F+Q_F),R(P)=$C(W-F) W # + S T=$E($T(Q+F),F,W\S)_$C(W+S+F) X T S B=$P(T,$C(P_P),F),C=B\(W*W),D=B-(C*W*W)\W + F G=S-Q:F:S+F+Q S E=B-(C*W*W+(D*W)),H=$E($T(Q),G),@H=$S(@H(F+Q)&(J<(S-F)):Q,Q:+N),C(P)=$S(J#F:Q,Q:+N) D + .S C(Q)=$S(J<(S-F):+N,Q:Q),C(F+Q)=$S(J>Q&(J<(S-F))&(J'=(P+'L))&(J'=(P)):Q,Q:+N) + .S H('L)=L F S H(N?.E)=$O(C(H('$G(N)))) Q:H('+L)=L S F(A,H('L))=C(H(W[(W\S))) + F U=Q:Q:P W !,R F V=Q:Q:P+F W $S(F(V,U):'Q,Q:$C(P_(W\S))) W:'(V#F) $C('N_F_F+F) + W !!,R(F)_C_R(P)_D_R(P)_E_R(F) X $RE($E($T(Q),Q+F,P+Q))_R(P)_'N W # G:N=L Q+F Q \ No newline at end of file diff --git a/src/iTerm/Engine.cls b/src/iTerm/Engine.cls new file mode 100644 index 0000000..3d7ecdf --- /dev/null +++ b/src/iTerm/Engine.cls @@ -0,0 +1,183 @@ +Class iTerm.Engine Extends %CSP.WebSocket +{ + +Parameter UseSession = 1; + +Property sid As %String; + +Property connected As %Boolean; + +Property fd As %Integer; + +Property pingInterval As %Integer [ InitialExpression = 25000 ]; + +Property pingTimeout As %Integer [ InitialExpression = 20000 ]; + +Property lastSend As %Integer; + +Method Server() As %Status +{ + try { + set username = $username + set ..sid = %session.SessionId + + do ..connect() + + set init = 0 + + set result = ..start() + set ..fd = result."__getitem__"(0) + set proc = result."__getitem__"(1) + set timeout = 0 + for { + set len = 32656 + set data = ..Read(.len, .sc, timeout) + if $$$GETERRORCODE(sc) = $$$CSPWebSocketClosed { + quit + } + if $$$GETERRORCODE(sc) '= $$$CSPWebSocketTimeout { + do ..onReceive(data) + } + + if ..connected { + if proc.poll() = 0 { + #; process finished + quit + } + set output = ..readfd() + if output'="" { + do ..emit("pty-output", {"output": (output)}) + if 'init { + set init = 1 + do ..init(username) + } + } + } + if (($zhorolog * 1000) - ..pingInterval) > ..lastSend { + do ..Write(2) + set data = ..Read(1, .sc, ..pingTimeout) + if data'=3 { + quit + } + } + hang 0.01 + } + } catch ex { + do ..Write("oops: " _ ex.DisplayString()) + } + quit ..EndServer() +} + +Method init(username) [ Language = python ] +{ + import iris + if "USE" in iris.system.Security.CheckUserPermission(username, "%Service_Login"): + self.writefd(f'write $system.Security.Login("{username}")\n') + self.writefd(f':clear\n') + + #; hide init input/output + while self.readfd(1): + pass +} + +Method onReceive(data) [ Language = python ] +{ + import json + if data[0:2] == "40": + self.send(40, {"sid": self.sid}) + self.connected = 1 + + if not self.connected: + return + + if data[0:2] == "42": + [event, payload] = json.loads(data[2:]) + if event == "pty-input": + self.writefd(payload["input"]) +} + +Method emit(event, data) [ Language = python ] +{ + if not isinstance(data, dict): + import json + data = json.loads(data._ToJSON()) + self.send(42, [event, data]) +} + +Method Write(data As %String) As %Status +{ + set ..lastSend = $zhorolog * 1000 + quit ##super(data) +} + +Method send(type, payload) [ Language = python ] +{ + import json + if isinstance(payload, dict) or isinstance(payload, list): + payload = json.dumps(payload) + self.Write(str(type) + payload) +} + +Method connect() [ Language = python ] +{ + msg = { + "sid": self.sid, + "upgrades": [], + "pingTimeout": (self.pingTimeout), + "pingInterval": (self.pingInterval), + } + self.send(0, msg) +} + +Method readfd(timeout = 0) [ Language = python ] +{ +import os +import select +max_read_bytes = 1024 * 20 + +(data_ready, _, _) = select.select([self.fd], [], [], timeout) +if not data_ready: + return + +output = os.read(self.fd, max_read_bytes).decode( + errors="ignore" +) +return output +} + +Method writefd(input) [ Language = python ] +{ +import os +os.write(self.fd, input.encode()) +} + +Method start() [ Language = python ] +{ +import iris +import pty +import subprocess + +bin = iris.system.Util.BinaryDirectory() + "irisdb" +mgr = iris.system.Util.ManagerDirectory() +username = iris.system.Process.UserName() +namespace = iris.system.Process.NameSpace() +cmd = [bin, "-s", mgr] +#; cmd = ["sh", "-c", " ".join(cmd)] +#; cmd = ["bash"] + +master_fd, slave_fd = pty.openpty() + +proc = subprocess.Popen( + cmd, + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + close_fds=True, + shell=True, + start_new_session=True, +) + +return master_fd, proc +} + +} diff --git a/src/iTerm/Router.cls b/src/iTerm/Router.cls new file mode 100644 index 0000000..a74d484 --- /dev/null +++ b/src/iTerm/Router.cls @@ -0,0 +1,260 @@ +Class iTerm.Router Extends %CSP.REST +{ + +Parameter UseSession = 1; + +Parameter Debug = 1; + +ClassMethod OnPreDispatch(pUrl As %String, pMethod As %String, ByRef pContinue As %Boolean) As %Status [ Internal ] +{ + if $extract(pUrl, 1, 4) = "/pty" { + do ##class(%Library.Device).ReDirectIO(0) + quit ##class(iTerm.Engine).Page(0) + } + #if $piece($system.Version.GetNumber(),".",1,2)]]"2024.1" + set debug = $$$GetSecurityApplicationsWSGIDebug(%request.AppData) + if debug '= ..#Debug { + do ..SetDebug(%request.Application, ..#Debug) + } + do %response.SetHeader("x-wsgidebug", debug) + #endIf + + set staticFileDirectory = $$$GetSecurityApplicationsPath(%request.AppData) + set serveStaticEnabled = 1 + set pContinue = 0 + if '$match(pUrl, "^/api(/.*)*$") { + if pUrl = "/" set pUrl = "/index.html" + quit ..StaticFiles(pUrl) + } + quit $$$OK + + if $piece(pUrl, "/", 3) '= "" { + try { + set namespace = $piece(pUrl, "/", 3) + set $namespace = namespace + } + catch { + set %response.Status = 403 + return $$$OK + } + } + set params = 0 + + #if $system.Version.GetNumber()]]"2024.1" + set params = 1 + set params(1) = 1 + #endIf + + do ##class(%SYS.Python.WSGI).DispatchREST(pUrl, "", "terminal.app", "app", params...) + quit $$$OK +} + +ClassMethod AccessCheck(Output pAuthorized As %Boolean = 0) As %Status +{ + $$$QuitOnError(##super(.pAuthorized)) + if pAuthorized, $username'="UnknownUser" { + if %request.Method="POST" { + set %response.Redirect = %request.URL + } + quit $$$OK + } + quit $$$OK +} + +ClassMethod Login(skipheader As %Boolean = 1) As %Status +{ + set tUrl = %request.URL + set tUrl = "/"_$extract(tUrl,$length(%request.Application)+1,*) + if $piece(tUrl, "/", 2) = "portal" { + set %response.ServerSideRedirect = "/csp/sys" _ tUrl + quit $$$OK + } + kill %request.Data("Error:ErrorCode") + quit ##class(%CSP.Login).Page(skipheader) +} + +ClassMethod SetDebug(app As %String = "/iterm", debug = 0) +{ + new $namespace + set $namespace = "%SYS" + set p("WSGIDebug") = 1 + quit ##class(Security.Applications).Modify(app, .p) +} + +ClassMethod StaticFiles(pUrl) As %Status +{ + set name = $translate($piece(pUrl, "/", 2, *), "/.", "__") + if ##class(%Dictionary.XDataDefinition).IDKEYExists($classname(), name, .id) { + set obj = ##class(%Dictionary.XDataDefinition).%OpenId(id) + set %response.ContentType = obj.MimeType + quit obj.Data.OutputToDevice() + } + elseif ##class(%Dictionary.MethodDefinition).IDKEYExists($classname(), name, .id) { + set obj = ##class(%Dictionary.MethodDefinition).%OpenId(id) + set %response.ContentType = "application/javascript" + quit obj.Implementation.OutputToDevice() + } + quit $$$OK +} + +XData "index_html" [ MimeType = text/html ] +{ + + + + + + + +
+ + + + + + +} + +XData "terminal_css" [ MimeType = text/css ] +{ + html, body, #terminal, .terminal { + width: 100vw; + height: 100vh; + padding: 0; + margin: 0; + } + + .terminal { + padding: 0 1em; + } +} + +ClientMethod "terminal_js"() [ Language = javascript ] +{ + var term = new Terminal({ + convertEol: true, + fontFamily: "Menlo, Monaco, Courier New, monospace", + bellStyle: "sound", + fontSize: 15, + fontWeight: 400, + cursorBlink: true, + }); + const fitAddon = new FitAddon.FitAddon(); + term.loadAddon(fitAddon); + term.open(document.getElementById("terminal")); + fitAddon.fit(); + document.body.onresize = function(){ fitAddon.fit(); }; + const params = new URL(document.location.toString()).searchParams; + const ns = params.get("ns"); + var socket = io.connect({ + transports: ["websocket"], + path: document.location.pathname + "pty" + ( ns ? "/" + encodeURIComponent(ns) : ""), + }); + socket.on("connect", () => { + }); + term.onData((key) => { + socket.emit("pty-input", { input: key }); + }); + + socket.on("pty-output", function (output) { + term.write(output["output"]); + }); +} + +ClientMethod "addonfit_js"() [ Language = javascript ] +{ + /** + * Copyright (c) 2017 The xterm.js authors. All rights reserved. + * @license MIT + */ + + import type { Terminal, ITerminalAddon } from '@xterm/xterm'; + import type { FitAddon as IFitApi } from '@xterm/addon-fit'; + import { IRenderDimensions } from 'browser/renderer/shared/Types'; + import { ViewportConstants } from 'browser/shared/Constants'; + + interface ITerminalDimensions { + /** + * The number of rows in the terminal. + */ + rows: number; + + /** + * The number of columns in the terminal. + */ + cols: number; + } + + const MINIMUM_COLS = 2; + const MINIMUM_ROWS = 1; + + export class FitAddon implements ITerminalAddon , IFitApi { + private _terminal: Terminal | undefined; + + public activate(terminal: Terminal): void { + this._terminal = terminal; + } + + public dispose(): void {} + + public fit(): void { + const dims = this.proposeDimensions(); + if (!dims || !this._terminal || isNaN(dims.cols) || isNaN(dims.rows)) { + return; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + + // Force a full render + if (this._terminal.rows !== dims.rows || this._terminal.cols !== dims.cols) { + core._renderService.clear(); + this._terminal.resize(dims.cols, dims.rows); + } + } + + public proposeDimensions(): ITerminalDimensions | undefined { + if (!this._terminal) { + return undefined; + } + + if (!this._terminal.element || !this._terminal.element.parentElement) { + return undefined; + } + + // TODO: Remove reliance on private API + const core = (this._terminal as any)._core; + const dims: IRenderDimensions = core._renderService.dimensions; + + if (dims.css.cell.width === 0 || dims.css.cell.height === 0) { + return undefined; + } + + const scrollbarWidth = (this._terminal.options.scrollback === 0 + ? 0 + : (this._terminal.options.overviewRuler?.width || ViewportConstants.DEFAULT_SCROLL_BAR_WIDTH)); + + const parentElementStyle = window.getComputedStyle(this._terminal.element.parentElement); + const parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')); + const parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width'))); + const elementStyle = window.getComputedStyle(this._terminal.element); + const elementPadding = { + top: parseInt(elementStyle.getPropertyValue('padding-top')), + bottom: parseInt(elementStyle.getPropertyValue('padding-bottom')), + right: parseInt(elementStyle.getPropertyValue('padding-right')), + left: parseInt(elementStyle.getPropertyValue('padding-left')) + }; + const elementPaddingVer = elementPadding.top + elementPadding.bottom; + const elementPaddingHor = elementPadding.right + elementPadding.left; + const availableHeight = parentElementHeight - elementPaddingVer; + const availableWidth = parentElementWidth - elementPaddingHor - scrollbarWidth; + const geometry = { + cols: Math.max(MINIMUM_COLS, Math.floor(availableWidth / dims.css.cell.width)), + rows: Math.max(MINIMUM_ROWS, Math.floor(availableHeight / dims.css.cell.height)) + }; + return geometry; + } + } +} + +} diff --git a/src/term.mac b/src/term.mac new file mode 100644 index 0000000..6b4abba --- /dev/null +++ b/src/term.mac @@ -0,0 +1,81 @@ +ROUTINE term +term() public { + set pad = 20 + for i=1:1 { + set line = $text(lines+i) + quit:line="" + set $listbuild(, left, right) = $listfromstring(line, ";") + set right = $zconvert(right, "I", "JS") + do write(pad, left, right) + } + do write(pad, "16 color -", $$colors16()) + do write(pad, "256 color ┬", $$colors256(0)) + do write(pad, " │", $$colors256(1)) + do write(pad, " │", $$colors256(2)) + do write(pad, " │", $$colors256(3)) + do write(pad, " │", $$colors256(4)) + do write(pad, " │", $$colors256(5)) + do write(pad, " └", $$colors256(6)) + do write(pad, "True color ┬", $$colorsTrue(1, 0)) + do write(pad, " Red │", $$colorsTrue(1, 1)) + do write(pad, " │", $$colorsTrue(1, 2)) + do write(pad, " └", $$colorsTrue(1, 3)) + do write(pad, " Green ┌", $$colorsTrue(2, 0)) + do write(pad, " │", $$colorsTrue(2, 1)) + do write(pad, " │", $$colorsTrue(2, 2)) + do write(pad, " └", $$colorsTrue(2, 3)) + do write(pad, " Blue ┌", $$colorsTrue(3, 0)) + do write(pad, " │", $$colorsTrue(3, 1)) + do write(pad, " │", $$colorsTrue(3, 2)) + do write(pad, " └", $$colorsTrue(3, 3)) +} +write(pad, left, right) { + write !,$justify(left, pad), right + write *27, "[0m" +} +colors16() public { + set colors = "" + for i=0:1:7 { + set colors = colors _ $char(27) _ "[4" _ i _ "m " + } + for i=0:1:7 { + set colors = colors _ $char(27) _ "[1;4" _ i _ "m " + } + quit colors +} +colors256(part, chunk = 36) public { + set colors = "" + set from = 16 + (part * chunk) + set to = from + chunk - 1 + for i=from:1:to { + set colors = colors _ $char(27) _ "[48;5;" _ i _ "m " + quit:i=255 + } + quit colors +} +colorsTrue(pos = 1, part, chunk = 64) public { + set colors = "" + set from = part * chunk + set to = from + chunk - 1 + for i=from:1:to { + set color = "0;0;0" + set $piece(color, ";", pos) = i + set colors = colors _ $char(27) _ "[48;2;" _ color _ "m " + quit:i=255 + } + quit colors +} +lines + ; Ascii ─; abc123 + ; CJK ─; 汉语, 漢語, 日本語, 한국어 + ; Powerline ─; \ue0b2\ue0b0\ue0b3\ue0b1\ue0b6\ue0b4\ue0b7\ue0b5\ue0ba\ue0b8\ue0bd\ue0b9\ue0be\ue0bc + ; Box drawing ┬; ┌─┬─┐ ┏━┳━┓ ╔═╦═╗ ┌─┲━┓ ╲ ╱ + ; │; │ │ │ ┃ ┃ ┃ ║ ║ ║ │ ┃ ┃ ╲ ╱ + ; │; ├─┼─┤ ┣━╋━┫ ╠═╬═╣ ├─╄━┩ ╳ + ; │; │ │ │ ┃ ┃ ┃ ║ ║ ║ │ │ │ ╱ ╲ + ; └; └─┴─┘ ┗━┻━┛ ╚═╩═╝ └─┴─┘ ╱ ╲ + ; Block elem ─; ░▒▓█ ▁▂▃▄▅▆▇█ ▏▎▍▌▋▊▉ + ; Emoji ─; 😉👋 + ; Styles ─; \x1b[1mBold\x1b[0m, \x1b[2mFaint\x1b[0m, \x1b[3mItalics\x1b[0m, \x1b[7mInverse\x1b[0m, \x1b[9mStrikethrough\x1b[0m, \x1b[8mInvisible\x1b[0m + ; Underlines ─; \x1b[4:1mStraight\x1b[0m, \x1b[4:2mDouble\x1b[0m, \x1b[4:3mCurly\x1b[0m, \x1b[4:4mDotted\x1b[0m, \x1b[4:5mDashed\x1b[0m + ;; \ No newline at end of file