Team Ai
Datasetpublic

codekingpro/portable-devtools

sourceHugging Faceupdated 5mo agoView on Hugging Face
1likes14kdownloads
web.py627 linesDownload Raw Back to display
1# Urwid web (CGI/Asynchronous Javascript) display module2#    Copyright (C) 2004-2007  Ian Ward3#4#    This library is free software; you can redistribute it and/or5#    modify it under the terms of the GNU Lesser General Public6#    License as published by the Free Software Foundation; either7#    version 2.1 of the License, or (at your option) any later version.8#9#    This library is distributed in the hope that it will be useful,10#    but WITHOUT ANY WARRANTY; without even the implied warranty of11#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU12#    Lesser General Public License for more details.13#14#    You should have received a copy of the GNU Lesser General Public15#    License along with this library; if not, write to the Free Software16#    Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA17#18# Urwid web site: https://urwid.org/19 20 21"""22Urwid web application display module23"""24 25from __future__ import annotations26 27import dataclasses28import glob29import html30import os31import pathlib32import random33import selectors34import signal35import socket36import string37import sys38import tempfile39import typing40from contextlib import suppress41 42from urwid.str_util import calc_text_pos, calc_width, move_next_char43from urwid.util import StoppingContext, get_encoding44 45from .common import BaseScreen46 47if typing.TYPE_CHECKING:48    from typing_extensions import Literal49 50    from urwid import Canvas51 52TEMP_DIR = tempfile.gettempdir()53CURRENT_DIR = pathlib.Path(__file__).parent54 55_js_code = CURRENT_DIR.joinpath("_web.js").read_text("utf-8")56 57ALARM_DELAY = 6058POLL_CONNECT = 359MAX_COLS = 20060MAX_ROWS = 10061MAX_READ = 409662BUF_SZ = 1638463 64_code_colours = {65    "black": "0",66    "dark red": "1",67    "dark green": "2",68    "brown": "3",69    "dark blue": "4",70    "dark magenta": "5",71    "dark cyan": "6",72    "light gray": "7",73    "dark gray": "8",74    "light red": "9",75    "light green": "A",76    "yellow": "B",77    "light blue": "C",78    "light magenta": "D",79    "light cyan": "E",80    "white": "F",81}82 83# replace control characters with ?'s84_trans_table = "?" * 32 + "".join([chr(x) for x in range(32, 256)])85 86_css_style = CURRENT_DIR.joinpath("_web.css").read_text("utf-8")87 88# HTML Initial Page89_html_page = [90    """<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"91 "http://www.w3.org/TR/html4/loose.dtd">92<html>93<head>94<title>Urwid Web Display - """,95    """</title>96<style type="text/css">97"""98    + _css_style99    + r"""100</style>101</head>102<body id="body" onload="load_web_display()">103<div style="position:absolute; visibility:hidden;">104<br id="br"\>105<pre>The quick brown fox jumps over the lazy dog.<span id="testchar">X</span>106<span id="testchar2">Y</span></pre>107</div>108Urwid Web Display - <b>""",109    """</b> -110Status: <span id="status">Set up</span>111<script type="text/javascript">112//<![CDATA[113"""114    + _js_code115    + """116//]]>117</script>118<pre id="text"></pre>119</body>120</html>121""",122]123 124 125class Screen(BaseScreen):126    def __init__(self) -> None:127        super().__init__()128        self.palette = {}129        self.has_color = True130        self._started = False131 132    @property133    def started(self) -> bool:134        return self._started135 136    def register_palette(self, palette) -> None:137        """Register a list of palette entries.138 139        palette -- list of (name, foreground, background) or140                   (name, same_as_other_name) palette entries.141 142        calls self.register_palette_entry for each item in l143        """144 145        for item in palette:146            if len(item) in {3, 4}:147                self.register_palette_entry(*item)148                continue149            if len(item) != 2:150                raise ValueError(f"Invalid register_palette usage: {item!r}")151            name, like_name = item152            if like_name not in self.palette:153                raise KeyError(f"palette entry '{like_name}' doesn't exist")154            self.palette[name] = self.palette[like_name]155 156    def register_palette_entry(157        self,158        name: str | None,159        foreground: str,160        background: str,161        mono: str | None = None,162        foreground_high: str | None = None,163        background_high: str | None = None,164    ) -> None:165        """Register a single palette entry.166 167        name -- new entry/attribute name168        foreground -- foreground colour169        background -- background colour170        mono -- monochrome terminal attribute171 172        See curses_display.register_palette_entry for more info.173        """174        if foreground == "default":175            foreground = "black"176        if background == "default":177            background = "light gray"178        self.palette[name] = (foreground, background, mono)179 180    def set_mouse_tracking(self, enable: bool = True) -> None:181        """Not yet implemented"""182 183    def tty_signal_keys(self, *args, **vargs):184        """Do nothing."""185 186    def start(self) -> StoppingContext:187        """188        This function reads the initial screen size, generates a189        unique id and handles cleanup when fn exits.190 191        web_display.set_preferences(..) must be called before calling192        this function for the preferences to take effect193        """194        if self._started:195            return StoppingContext(self)196 197        client_init = sys.stdin.read(50)198        if not client_init.startswith("window resize "):199            raise ValueError(client_init)200        _ignore1, _ignore2, x, y = client_init.split(" ", 3)201        x = int(x)202        y = int(y)203        self._set_screen_size(x, y)204        self.last_screen = {}205        self.last_screen_width = 0206 207        self.update_method = os.environ["HTTP_X_URWID_METHOD"]208        if self.update_method not in {"multipart", "polling"}:209            raise ValueError(self.update_method)210 211        if self.update_method == "polling" and not _prefs.allow_polling:212            sys.stdout.write("Status: 403 Forbidden\r\n\r\n")213            sys.exit(0)214 215        clients = glob.glob(os.path.join(_prefs.pipe_dir, "urwid*.in"))216        if len(clients) >= _prefs.max_clients:217            sys.stdout.write("Status: 503 Sever Busy\r\n\r\n")218            sys.exit(0)219 220        urwid_id = f"{random.randrange(10**9):09d}{random.randrange(10**9):09d}"  # noqa: S311221        self.pipe_name = os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}")222        os.mkfifo(f"{self.pipe_name}.in", 0o600)223        signal.signal(signal.SIGTERM, self._cleanup_pipe)224 225        self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)226        self.input_tail = ""227        self.content_head = (228            f"Content-type: multipart/x-mixed-replace;boundary=ZZ\r\nX-Urwid-ID: {urwid_id}\r\n\r\n\r\n--ZZ\r\n"229        )230        if self.update_method == "polling":231            self.content_head = f"Content-type: text/plain\r\nX-Urwid-ID: {urwid_id}\r\n\r\n\r\n"232 233        signal.signal(signal.SIGALRM, self._handle_alarm)234        signal.alarm(ALARM_DELAY)235        self._started = True236 237        return StoppingContext(self)238 239    def stop(self) -> None:240        """241        Restore settings and clean up.242        """243        if not self._started:244            return245 246        # XXX which exceptions does this actually raise? EnvironmentError?247        with suppress(Exception):248            self._close_connection()249        signal.signal(signal.SIGTERM, signal.SIG_DFL)250        self._cleanup_pipe()251        self._started = False252 253    def set_input_timeouts(self, *args: typing.Any) -> None:254        pass255 256    def _close_connection(self) -> None:257        if self.update_method == "polling child":258            self.server_socket.settimeout(0)259            sock, _addr = self.server_socket.accept()260            sock.sendall(b"Z")261            sock.close()262 263        if self.update_method == "multipart":264            sys.stdout.write("\r\nZ\r\n--ZZ--\r\n")265            sys.stdout.flush()266 267    def _cleanup_pipe(self, *args) -> None:268        if not self.pipe_name:269            return270        # XXX which exceptions does this actually raise? EnvironmentError?271        with suppress(Exception):272            os.remove(f"{self.pipe_name}.in")273            os.remove(f"{self.pipe_name}.update")274 275    def _set_screen_size(self, cols: int, rows: int) -> None:276        """Set the screen size (within max size)."""277 278        cols = min(cols, MAX_COLS)279        rows = min(rows, MAX_ROWS)280        self.screen_size = cols, rows281 282    def draw_screen(self, size: tuple[int, int], canvas: Canvas) -> None:283        """Send a screen update to the client."""284 285        (cols, rows) = size286 287        if cols != self.last_screen_width:288            self.last_screen = {}289 290        sendq = [self.content_head]291 292        if self.update_method == "polling":293            send = sendq.append294        elif self.update_method == "polling child":295            signal.alarm(0)296            try:297                s, _addr = self.server_socket.accept()298            except socket.timeout:299                sys.exit(0)300            send = s.sendall301        else:302            signal.alarm(0)303            send = sendq.append304            send("\r\n")305            self.content_head = ""306 307        if canvas.rows() != rows:308            raise ValueError(rows)309 310        if canvas.cursor is not None:311            cx, cy = canvas.cursor312        else:313            cx = cy = None314 315        new_screen = {}316 317        y = -1318        for row in canvas.content():319            y += 1320            l_row = tuple((attr_, line.decode(get_encoding())) for attr_, _, line in row)321 322            line = []323 324            sig = l_row325            if y == cy:326                sig = (*sig, cx)327            new_screen[sig] = [*new_screen.get(sig, []), y]328 329            if (old_line_numbers := self.last_screen.get(sig, None)) is not None:330                if y in old_line_numbers:331                    old_line = y332                else:333                    old_line = old_line_numbers[0]334                send(f"<{old_line:d}\n")335                continue336 337            col = 0338            for a, run in l_row:339                t_run = run.translate(_trans_table)340                if a is None:341                    fg, bg, _mono = "black", "light gray", None342                else:343                    fg, bg, _mono = self.palette[a]344                if y == cy and col <= cx:345                    run_width = calc_width(t_run, 0, len(t_run))346                    if col + run_width > cx:347                        line.append(code_span(t_run, fg, bg, cx - col))348                    else:349                        line.append(code_span(t_run, fg, bg))350                    col += run_width351                else:352                    line.append(code_span(t_run, fg, bg))353 354            send(f"{''.join(line)}\n")355        self.last_screen = new_screen356        self.last_screen_width = cols357 358        if self.update_method == "polling":359            sys.stdout.write("".join(sendq))360            sys.stdout.flush()361            sys.stdout.close()362            self._fork_child()363        elif self.update_method == "polling child":364            s.close()365        else:  # update_method == "multipart"366            send("\r\n--ZZ\r\n")367            sys.stdout.write("".join(sendq))368            sys.stdout.flush()369 370        signal.alarm(ALARM_DELAY)371 372    def clear(self) -> None:373        """374        Force the screen to be completely repainted on the next375        call to draw_screen().376 377        (does nothing for web_display)378        """379 380    def _fork_child(self) -> None:381        """382        Fork a child to run CGI disconnected for polling update method.383        Force parent process to exit.384        """385        daemonize(f"{self.pipe_name}.err")386        self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)387        self.update_method = "polling child"388        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)389        s.bind(f"{self.pipe_name}.update")390        s.listen(1)391        s.settimeout(POLL_CONNECT)392        self.server_socket = s393 394    def _handle_alarm(self, sig, frame) -> None:395        if self.update_method not in {"multipart", "polling child"}:396            raise ValueError(self.update_method)397        if self.update_method == "polling child":398            # send empty update399            try:400                s, _addr = self.server_socket.accept()401                s.close()402            except socket.timeout:403                sys.exit(0)404        else:405            # send empty update406            sys.stdout.write("\r\n\r\n--ZZ\r\n")407            sys.stdout.flush()408        signal.alarm(ALARM_DELAY)409 410    def get_cols_rows(self) -> tuple[int, int]:411        """Return the screen size."""412        return self.screen_size413 414    @typing.overload415    def get_input(self, raw_keys: Literal[False]) -> list[str]: ...416 417    @typing.overload418    def get_input(self, raw_keys: Literal[True]) -> tuple[list[str], list[int]]: ...419 420    def get_input(self, raw_keys: bool = False) -> list[str] | tuple[list[str], list[int]]:421        """Return pending input as a list."""422        pending_input = []423        resized = False424        with selectors.DefaultSelector() as selector:425            selector.register(self.input_fd, selectors.EVENT_READ)426 427            iready = [event.fd for event, _ in selector.select(0.5)]428 429        if not iready:430            if raw_keys:431                return [], []432            return []433 434        keydata = os.read(self.input_fd, MAX_READ).decode(get_encoding())435        os.close(self.input_fd)436        self.input_fd = os.open(f"{self.pipe_name}.in", os.O_NONBLOCK | os.O_RDONLY)437        # sys.stderr.write( repr((keydata,self.input_tail))+"\n" )438        keys = keydata.split("\n")439        keys[0] = self.input_tail + keys[0]440        self.input_tail = keys[-1]441 442        for k in keys[:-1]:443            if k.startswith("window resize "):444                _ign1, _ign2, x, y = k.split(" ", 3)445                x = int(x)446                y = int(y)447                self._set_screen_size(x, y)448                resized = True449            else:450                pending_input.append(k)451        if resized:452            pending_input.append("window resize")453 454        if raw_keys:455            return pending_input, []456        return pending_input457 458 459def code_span(s: str, fg: str, bg: str, cursor: int = -1) -> str:460    code_fg = _code_colours[fg]461    code_bg = _code_colours[bg]462 463    if cursor >= 0:464        c_off, _ign = calc_text_pos(s, 0, len(s), cursor)465        c2_off = move_next_char(s, c_off, len(s))466 467        return (468            code_fg469            + code_bg470            + s[:c_off]471            + "\n"472            + code_bg473            + code_fg474            + s[c_off:c2_off]475            + "\n"476            + code_fg477            + code_bg478            + s[c2_off:]479            + "\n"480        )481 482    return f"{code_fg + code_bg + s}\n"483 484 485def is_web_request() -> bool:486    """487    Return True if this is a CGI web request.488    """489    return "REQUEST_METHOD" in os.environ490 491 492def handle_short_request() -> bool:493    """494    Handle short requests such as passing keystrokes to the application495    or sending the initial HTML page.  If returns True, then this496    function recognized and handled a short request, and the calling497    script should immediately exit.498 499    web_display.set_preferences(..) should be called before calling this500    function for the preferences to take effect501    """502    if not is_web_request():503        return False504 505    if os.environ["REQUEST_METHOD"] == "GET":506        # Initial request, send the HTML and javascript.507        sys.stdout.write("Content-type: text/html\r\n\r\n" + html.escape(_prefs.app_name).join(_html_page))508        return True509 510    if os.environ["REQUEST_METHOD"] != "POST":511        # Don't know what to do with head requests etc.512        return False513 514    if "HTTP_X_URWID_ID" not in os.environ:515        # If no urwid id, then the application should be started.516        return False517 518    urwid_id = os.environ["HTTP_X_URWID_ID"]519    if len(urwid_id) > 20:520        # invalid. handle by ignoring521        # assert 0, "urwid id too long!"522        sys.stdout.write("Status: 414 URI Too Long\r\n\r\n")523        return True524    for c in urwid_id:525        if c not in string.digits:526            # invald. handle by ignoring527            # assert 0, "invalid chars in id!"528            sys.stdout.write("Status: 403 Forbidden\r\n\r\n")529            return True530 531    if os.environ.get("HTTP_X_URWID_METHOD", None) == "polling":532        # this is a screen update request533        s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)534        try:535            s.connect(os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}.update"))536            data = f"Content-type: text/plain\r\n\r\n{s.recv(BUF_SZ)}"537            while data:538                sys.stdout.write(data)539                data = s.recv(BUF_SZ)540        except OSError:541            sys.stdout.write("Status: 404 Not Found\r\n\r\n")542            return True543        return True544 545    # this is a keyboard input request546    try:547        fd = os.open((os.path.join(_prefs.pipe_dir, f"urwid{urwid_id}.in")), os.O_WRONLY)548    except OSError:549        sys.stdout.write("Status: 404 Not Found\r\n\r\n")550        return True551 552    # FIXME: use the correct encoding based on the request553    keydata = sys.stdin.read(MAX_READ)554    os.write(fd, keydata.encode("ascii"))555    os.close(fd)556    sys.stdout.write("Content-type: text/plain\r\n\r\n")557 558    return True559 560 561@dataclasses.dataclass562class _Preferences:563    app_name: str = "Unnamed Application"564    pipe_dir: str = TEMP_DIR565    allow_polling: bool = True566    max_clients: int = 20567 568 569_prefs = _Preferences()570 571 572def set_preferences(573    app_name: str,574    pipe_dir: str = TEMP_DIR,575    allow_polling: bool = True,576    max_clients: int = 20,577) -> None:578    """579    Set web_display preferences.580 581    app_name -- application name to appear in html interface582    pipe_dir -- directory for input pipes, daemon update sockets583                and daemon error logs584    allow_polling -- allow creation of daemon processes for585                     browsers without multipart support586    max_clients -- maximum concurrent client connections. This587               pool is shared by all urwid applications588               using the same pipe_dir589    """590    _prefs.app_name = app_name591    _prefs.pipe_dir = pipe_dir592    _prefs.allow_polling = allow_polling593    _prefs.max_clients = max_clients594 595 596class ErrorLog:597    def __init__(self, errfile: str | pathlib.PurePath) -> None:598        self.errfile = errfile599 600    def write(self, err: str) -> None:601        with open(self.errfile, "a", encoding="utf-8") as f:602            f.write(err)603 604 605def daemonize(errfile: str) -> None:606    """607    Detach process and become a daemon.608    """609    if os.fork():610        os._exit(0)611 612    os.setsid()613    signal.signal(signal.SIGHUP, signal.SIG_IGN)614    os.umask(0)615 616    if os.fork():617        os._exit(0)618 619    os.chdir("/")620    for fd in range(20):621        with suppress(OSError):622            os.close(fd)623 624    sys.stdin = open("/dev/null", encoding="utf-8")  # noqa: SIM115  # pylint: disable=consider-using-with625    sys.stdout = open("/dev/null", "w", encoding="utf-8")  # noqa: SIM115  # pylint: disable=consider-using-with626    sys.stderr = ErrorLog(errfile)627 
codekingpro/portable-devtools · Team Ai