From 9c5abb66c569d24a7987106ec6be6af040a3368c Mon Sep 17 00:00:00 2001 From: "Jeffrey C. Ollie" Date: Sun, 29 Oct 2023 16:46:47 -0500 Subject: [PATCH] first --- flake.lock | 77 ++++++++ flake.nix | 51 +++++ poetry.lock | 7 + pyansi/__init__.py | 467 +++++++++++++++++++++++++++++++++++++++++++++ pyproject.toml | 26 +++ 5 files changed, 628 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 poetry.lock create mode 100644 pyansi/__init__.py create mode 100644 pyproject.toml diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..40dbe30 --- /dev/null +++ b/flake.lock @@ -0,0 +1,77 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1694529238, + "narHash": "sha256-zsNZZGTGnMOf9YpHKJqMSsa0dXbfmxeoJ7xHlrt+xmY=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "ff7b65b44d01cf9ba6a71320833626af21126384", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "make-shell": { + "locked": { + "lastModified": 1634940815, + "narHash": "sha256-P69OmveboXzS+es1vQGS4bt+ckwbeIExqxfGLjGuJqA=", + "owner": "ursi", + "repo": "nix-make-shell", + "rev": "8add91681170924e4d0591b22f294aee3f5516f9", + "type": "github" + }, + "original": { + "owner": "ursi", + "repo": "nix-make-shell", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1698318101, + "narHash": "sha256-gUihHt3yPD7bVqg+k/UVHgngyaJ3DMEBchbymBMvK1E=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "63678e9f3d3afecfeafa0acead6239cdb447574c", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "make-shell": "make-shell", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..4e010b5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,51 @@ +{ + description = "pyansi"; + inputs = { + nixpkgs = { + url = "github:nixos/nixpkgs/nixos-unstable"; + }; + flake-utils = { + url = "github:numtide/flake-utils"; + }; + make-shell = { + url = "github:ursi/nix-make-shell"; + }; + }; + outputs = { + self, + nixpkgs, + flake-utils, + make-shell, + } @ inputs: + flake-utils.lib.eachDefaultSystem + ( + system: let + version = self.lastModifiedDate; + pkgs = import nixpkgs { + inherit system; + }; + make-shell = import inputs.make-shell { + inherit system pkgs; + }; + in { + devShells.default = let + python = pkgs.python312.withPackages ( + ps: + with ps; [ + # poetry-core + ] + ); + project = "pyansi"; + in + make-shell { + packages = [ + python + pkgs.poetry + ]; + env = { + NIX_PROJECT = project; + }; + }; + } + ); +} diff --git a/poetry.lock b/poetry.lock new file mode 100644 index 0000000..58d06a4 --- /dev/null +++ b/poetry.lock @@ -0,0 +1,7 @@ +# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand. +package = [] + +[metadata] +lock-version = "2.0" +python-versions = "^3.12" +content-hash = "34e39677d8527182346093002688d17a5d2fc204b9eb3e094b2e6ac519028228" diff --git a/pyansi/__init__.py b/pyansi/__init__.py new file mode 100644 index 0000000..4dcf924 --- /dev/null +++ b/pyansi/__init__.py @@ -0,0 +1,467 @@ +from dataclasses import dataclass +from enum import Enum +from enum import IntEnum +from enum import StrEnum +from enum import auto +from functools import wraps +from typing import Annotated +from typing import Callable +from typing import Self +from typing import get_args +from typing import get_origin +from typing import get_type_hints + + +@dataclass +class ValueRange: + low: int + high: int + + +def check_annotations(func: Callable) -> Callable: + @wraps(func) + def wrapped(*args, **kwargs): + type_hints = get_type_hints(func, include_extras=True) + for param, hint in type_hints.items(): + if get_origin(hint) is not Annotated: + continue + hint_type, *hint_args = get_args(hint) + if hint_type is list or get_origin(hint_type) is list: + for arg in hint_args: + if isinstance(arg, ValueRange): + value = kwargs[param] + if value < arg.low or value > arg.high: + raise ValueError( + f"Parameter '{param}' must be >= {arg.low} and <= {arg.high}" + ) + return func(*args, **kwargs) + + return wrapped + + +def readline_wrap(func: Callable) -> Callable: + """wrap output in SOH/STX so that readline handles prompts properly""" + + @wraps(func) + def wrapped(ansi: ANSI, *args, **kwargs): + result = "" + if ansi.readline_wrap: + result += ansi.SOH + result += func(ansi, *args, **kwargs) + if ansi.readline_wrap: + result += ansi.STX + + return wrapped + + +class Mode(Enum): + C0 = auto() + C1 = auto() + Bash = auto() + + +class Reset: + """reset all graphics renditions to the default""" + + def __new__(cls: type[Self]) -> Self: + if not hasattr(cls, "instance"): + cls.instance = super(Reset, cls).__new__(cls) + return cls.instance + + +class Color8(IntEnum): + """8 color palette""" + + BLACK = 0 + RED = 1 + GREEN = 2 + YELLOW = 3 + BLUE = 4 + MAGENTA = 5 + CYAN = 6 + WHITE = 7 + + +class Style(IntEnum): + """enable graphics rendition style""" + + BOLD = 1 + DIM = 2 + FAINT = 2 + ITALIC = 3 + UNDERLINE = 4 + BLINKING = 5 + INVERSE = 7 + REVERSE = 7 + HIDDEN = 8 + INVISIBLE = 8 + STRIKETHROUGH = 9 + DOUBLE_UNDERLINE = 21 + + +class ResetStyle(IntEnum): + """reset graphics rendition style""" + + BOLD = 22 + DIM = 22 + FAINT = 22 + ITALIC = 23 + UNDERLINE = 24 + BLINKING = 25 + INVERSE = 27 + REVERSE = 27 + HIDDEN = 28 + INVISIBLE = 28 + STRIKETHROUGH = 29 + DOUBLE_UNDERLINE = 24 + + +class UnderlineStyle(IntEnum): + NO_UNDERLINE = 0 + STRAIGHT_UNDERLINE = 1 + DOUBLE_UNDERLINE = 2 + CURLY_UNDERLINE = 3 + DOTTED_UNDERLINE = 4 + DASHED_UNDERLINE = 5 + + +@dataclass +class Color256: + color: int + + +@dataclass +class ColorRGB: + red: int + green: int + blue: int + + +class Layer(Enum): + FOREGROUND = auto() + BACKGROUND = auto() + UNDERLINE = auto() + + +@dataclass +class Color: + color: Color8 | Color256 | ColorRGB + layer: Layer = Layer.FOREGROUND + bright: bool = False + + +GraphicsRendtion = Reset | Style | ResetStyle | UnderlineStyle | Color + + +class EraseDisplayMode(IntEnum): + TO_END_OF_SCREEN = 0 + TO_CURSOR = 1 + ENTIRE_SCREEN = 2 + SAVED_LINES = 3 + + +class EraseLineMode(IntEnum): + TO_END_OF_LINE = 0 + TO_CURSOR = 1 + ENTIRE_LINE = 2 + + +class ActiveStatusDisplayMode(IntEnum): + MAIN_DISPLAY = 0 + STATUS_LINE = 1 + + +class StatusLineType(IntEnum): + NONE = 0 + INIDICATOR = 1 + HOST_WRITABLE = 2 + + +class DesktopNotificationPart(StrEnum): + TITLE = "title" + BODY = "body" + + +class DesktopNotificationDone(IntEnum): + NOT_DONE = 0 + DONE = 1 + + +class ANSI: + mode: Mode + wrap: bool + + def __init__(self: Self, mode: Mode, wrap: bool = False) -> None: + self.mode = mode + self.wrap = wrap + + @property + def SOH(self: Self) -> str: + match self.mode: + case Mode.C0 | Mode.C1: + return "\x01" + case Mode.Bash: + return "\\[" + + @property + def STX(self: Self) -> str: + match self.mode: + case Mode.C0 | Mode.C1: + return "\x02" + case Mode.Bash: + return "\\]" + + def _wrap(self: Self, data: str) -> str: + if self.wrap: + return f"{self.SOH}{data}{self.STX}" + return data + + @property + def RL_PROMPT_START_IGNORE(self: Self) -> str: + if self.wrap: + return self.SOH + return "" + + @property + def RL_PROMPT_END_IGNORE(self: Self) -> str: + if self.wrap: + return self.STX + return "" + + @property + def BEL(self: Self) -> str: + match self.mode: + case Mode.C0 | Mode.C1: + return "\x07" + case Mode.Bash: + return "\\a" + + BS = "\x08" + HT = "\x09" + LF = "\x0a" + VT = "\x0b" + FF = "\x0c" + CR = "\x0d" + + @property + def ESC(self) -> str: + """Escape""" + match self.mode: + case Mode.C0 | Mode.C1: + return "\x1b" + case Mode.Bash: + return "\\e" + + DEL = "\x7f" + + @property + def DCS(self: Self) -> str: + """Device Control String""" + match self.mode: + case Mode.C0 | Mode.Bash: + return f"{self.ESC}P" + case Mode.C1: + return "\x8d" + + @property + def CSI(self) -> str: + """Control Sequence Introducer""" + match self.mode: + case Mode.C0 | Mode.Bash: + return f"{self.ESC}[" + case Mode.C1: + return "\x9b" + + @property + def ST(self) -> str: + match self.mode: + case Mode.C0: + return f"{self.ESC}\\" + case Mode.C1: + return "\x9c" + case Mode.Bash: + return self.BEL + + @property + def OSC(self: Self) -> str: + """Operating System Command""" + match self.mode: + case Mode.C0 | Mode.Bash: + return f"{self.ESC}]" + case Mode.C1: + return "\x9d" + + @readline_wrap + def GraphicsRendition(self: Self, *args: GraphicsRendtion) -> str: + result = self.CSI + for index, g in enumerate(args): + if index > 0: + result += ";" + match g: + case Reset(): + result += "0" + + case Style(): + result += "{g}" + + case ResetStyle(): + result += "{g}" + + case UnderlineStyle(): + result += "4:{g}" + + case Color(): + match g.color: + case Color8(): + match g.layer: + case Layer.FOREGROUND: + if g.bright: + offset = 90 + else: + offset = 30 + case Layer.BACKGROUND: + if g.bright: + offset = 100 + else: + offset = 40 + case Layer.UNDERLINE: + offset = 50 + result += f"{g.color + offset}" + + case Color256(): + match g.layer: + case Layer.FOREGROUND: + code = 38 + case Layer.BACKGROUND: + code = 48 + case Layer.UNDERLINE: + code = 58 + result += f"{code};5;{g.color.color}" + + case ColorRGB(): + match g.layer: + case Layer.FOREGROUND: + code = 38 + case Layer.BACKGROUND: + code = 48 + case Layer.UNDERLINE: + code = 58 + result += ( + f"{code};2;{g.color.red};{g.color.green};{g.color.blue}" + ) + + result += "m" + return result + + def CursorUp(self: Self, n: int) -> str: + return f"{self.CSI}{n}A" + + def CursorDown(self: Self, n: int) -> str: + return f"{self.CSI}{n}B" + + def CursorForward(self: Self, n: int) -> str: + return f"{self.CSI}{n}C" + + def CursorBack(self: Self, n: int) -> str: + return f"{self.CSI}{n}D" + + def CursorNextLine(self: Self, n: int) -> str: + return f"{self.CSI}{n}E" + + def CursorPreviousLine(self: Self, n: int) -> str: + return f"{self.CSI}{n}F" + + def CursorHorizontalAbsolute(self: Self, n: int, alternate: bool = False) -> str: + if alternate: + return f"{self.CSI}{n}f" + return f"{self.CSI}{n}G" + + def CursorPosition(self: Self, n: int, m: int, alternate: bool = False) -> str: + if alternate: + return f"{self.CSI}{n};{m}f" + return f"{self.CSI}{n};{m}H" + + def EraseInDisplay(self: Self, mode: EraseDisplayMode) -> str: + return f"{self.CSI}{mode}J" + + def EraseInLine(self: Self, mode: EraseLineMode) -> str: + return f"{self.CSI}{mode}K" + + def ScrollUp(self: Self, n: int) -> str: + return f"{self.CSI}{n}S" + + def ScrollDown(self: Self, n: int) -> str: + return f"{self.CSI}{n}T" + + @property + def SaveCursorPosition(self: Self) -> str: + return f"{self.CSI}s" + + @property + def RestoreCursorPosition(self: Self) -> str: + return f"{self.CSI}u" + + @readline_wrap + def Title( + self: Self, text: str, icon_name: bool = True, window_title: bool = True + ) -> str: + code = 0 + match (icon_name, window_title): + case (False, False): + raise ValueError("icon_name and window_tile can't both be false") + case (False, True): + code = 2 + case (True, False): + code = 1 + case (True, True): + code = 0 + return f"{self.OSC}{code};{text}{self.ST}" + + def Hyperlink(self: Self, link: str, text: str) -> str: + return ( + self._wrap(f"{self.OSC}8;;{link}{self.ST}") + + text + + self._wrap(f"{self.OSC}8;;{self.ST}") + ) + + @readline_wrap + def Notification(self: Self, text: str) -> str: + return f"{self.OSC}9;{text}{self.ST}" + + @readline_wrap + def CurrentDirectory(self: Self, text: str) -> str: + return f"{self.OSC}1337;CurrentDir={text}{self.ST}" + + @readline_wrap + def ActiveStatusDisplay(self: Self, mode: ActiveStatusDisplayMode) -> str: + return f"{self.CSI}{mode}$}}" + + @readline_wrap + def StatusLineType(self: Self, mode: StatusLineType) -> str: + return f"{self.CSI}{mode}$~" + + @readline_wrap + def DesktopNotification(self: Self, identifier: str, title: str, body: str) -> str: + def format( + part: DesktopNotificationPart, text: str, done: DesktopNotificationDone + ) -> str: + return f"{self.OSC}99;i={identifier};e=0;p={part};d={done};{text}{self.ST}" + + result = "" + stride = 256 + + for part in [title[i : i + stride] for i in range(0, len(title), stride)]: + done = DesktopNotificationDone.NOT_DONE + if len(part) < stride and len(body) == 0: + done = DesktopNotificationDone.DONE + + result += format(DesktopNotificationPart.TITLE, part, done) + + for part in [body[i : i + stride] for i in range(0, len(body), stride)]: + done = DesktopNotificationDone.NOT_DONE + if len(part) < stride: + done = DesktopNotificationDone.DONE + + result += format(DesktopNotificationPart.TITLE, part, done) + + return result diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..aa17838 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,26 @@ +[tool.poetry] +name = "pyansi" +version = "0.1.0" +description = "" +authors = ["Jeffrey C. Ollie "] +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.12" + +[tool.black] +max-line-length = 120 + +[tool.isort] +profile = "black" +line_length = 120 +force_single_line = true +from_first = false +force_sort_within_sections = true + +[tool.ruff] +line-length = 120 + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api"