diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5b87e5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__ +/result* + diff --git a/pyansi/__init__.py b/pyansi/__init__.py index 6e854da..367d15c 100644 --- a/pyansi/__init__.py +++ b/pyansi/__init__.py @@ -1,11 +1,15 @@ +from base64 import b64encode from dataclasses import dataclass from enum import Enum from enum import IntEnum from enum import StrEnum from enum import auto from functools import wraps +import re from typing import Annotated from typing import Callable +from typing import Concatenate +from typing import ParamSpec from typing import Self from typing import get_args from typing import get_origin @@ -18,6 +22,11 @@ class ValueRange: high: int +@dataclass +class RegexMatch: + pattern: re.Pattern + + def check_annotations(func: Callable) -> Callable: @wraps(func) def wrapped(*args, **kwargs): @@ -28,12 +37,20 @@ def check_annotations(func: Callable) -> Callable: 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}" - ) + match arg: + case ValueRange(): + value = kwargs[param] + if value < arg.low or value > arg.high: + raise ValueError( + f"Parameter '{param}' must be >= {arg.low} and <= {arg.high}" + ) + case RegexMatch(): + value = kwargs[param] + if not arg.pattern.match(value): + raise ValueError( + f"Parameter '{param}' must match pattern '{arg.pattern.pattern}'" + ) + return func(*args, **kwargs) return wrapped @@ -172,22 +189,49 @@ class DesktopNotificationDone(IntEnum): DONE = 1 +class ClipboardType(StrEnum): + CLIPBOARD = "c" + PRIMARY = "p" + SELECT = "s" + CUT_BUFFER_0 = "0" + CUT_BUFFER_1 = "1" + CUT_BUFFER_2 = "2" + CUT_BUFFER_3 = "3" + CUT_BUFFER_4 = "4" + CUT_BUFFER_5 = "5" + CUT_BUFFER_6 = "6" + CUT_BUFFER_7 = "7" + CUT_BUFFER_8 = "8" + + +class CursorShape(IntEnum): + BLOCK = 0 + VERTICAL_BAR = 1 + UNDERLINE = 2 + + +P = ParamSpec("P") + + class ANSI: mode: Mode wrap: bool @staticmethod - def readline_wrap(func: Callable) -> Callable: + def readline_wrap( + func: Callable[Concatenate["ANSI", P], str] + ) -> Callable[Concatenate["ANSI", P], str]: """wrap output in SOH/STX so that readline handles prompts properly""" @wraps(func) - def wrapped(ansi: Self, *args, **kwargs): + def wrapped(ansi: ANSI, *args: P.args, **kwargs: P.kwargs) -> str: result = "" if ansi.wrap: result += ansi.SOH result += func(ansi, *args, **kwargs) if ansi.wrap: result += ansi.STX + return result return wrapped @@ -195,8 +239,16 @@ class ANSI: self.mode = mode self.wrap = wrap + def _wrap(self: Self, data: str) -> str: + if self.wrap: + return f"{self.SOH}{data}{self.STX}" + return data + + NUL: Annotated[str, "null"] = "\x00" + @property def SOH(self: Self) -> str: + """start of heading""" match self.mode: case Mode.C0 | Mode.C1: return "\x01" @@ -205,17 +257,13 @@ class ANSI: @property def STX(self: Self) -> str: + """start of text""" 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: @@ -228,6 +276,11 @@ class ANSI: return self.STX return "" + ETX: Annotated[str, "end of text"] = "\x03" + EOT: Annotated[str, "end of transmission"] = "\x04" + ENQ: Annotated[str, "enquiry"] = "\x05" + ACK: Annotated[str, "acknowlege"] = "\x06" + @property def BEL(self: Self) -> str: match self.mode: @@ -236,23 +289,43 @@ class ANSI: case Mode.Bash: return "\\a" - BS = "\x08" - HT = "\x09" - LF = "\x0a" - VT = "\x0b" - FF = "\x0c" - CR = "\x0d" + BS: Annotated[str, "backspace"] = "\x08" + HT: Annotated[str, "horizontal tab"] = "\x09" + LF: Annotated[str, "linefeed"] = "\x0a" + VT: Annotated[str, "vertical tab"] = "\x0b" + FF: Annotated[str, "form feed"] = "\x0c" + CR: Annotated[str, "carriage return"] = "\x0d" + SO: Annotated[str, "shift out"] = "\x0e" + SI: Annotated[str, "shift in"] = "\x0f" + DLE: Annotated[str, "data link escape"] = "\x10" + DC1: Annotated[str, "device control one"] = "\x11" + XON = DC1 + DC2: Annotated[str, "device control two"] = "\x12" + DC3: Annotated[str, "device control three"] = "\x13" + XOFF = DC3 + DC4: Annotated[str, "device control four"] = "\x14" + NAK: Annotated[str, "negative acknowledge"] = "\x15" + SYN: Annotated[str, "synchronous idle"] = "\x16" + ETB: Annotated[str, "end of transmission block"] = "\x17" + CAN: Annotated[str, "cancel"] = "\x18" + EM: Annotated[str, "end of medium"] = "\x19" + SUB: Annotated[str, "substitute"] = "\x1a" @property - def ESC(self) -> str: - """Escape""" + def ESC(self: Self) -> str: + """escape""" match self.mode: case Mode.C0 | Mode.C1: return "\x1b" case Mode.Bash: return "\\e" - DEL = "\x7f" + FS: Annotated[str, "file separator"] = "\x1c" + GS: Annotated[str, "group separator"] = "\x1d" + RS: Annotated[str, "record separator"] = "\x1e" + US: Annotated[str, "unit separator"] = "\x1f" + + DEL: Annotated[str, "delete"] = "\x7f" @property def DCS(self: Self) -> str: @@ -401,7 +474,6 @@ class ANSI: 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: @@ -415,7 +487,7 @@ class ANSI: code = 1 case (True, True): code = 0 - return f"{self.OSC}{code};{text}{self.ST}" + return self._wrap(f"{self.OSC}{code};{text}{self.ST}") def Hyperlink(self: Self, link: str, text: str) -> str: return ( @@ -426,12 +498,47 @@ class ANSI: @readline_wrap def Notification(self: Self, text: str) -> str: + """https://iterm2.com/documentation-escape-codes.html""" return f"{self.OSC}9;{text}{self.ST}" + @readline_wrap + def SetMark(self: Self) -> str: + """https://iterm2.com/documentation-escape-codes.html""" + return f"{self.OSC}1337;SetMark{self.ST}" + + @readline_wrap + def StealFocus(self: Self) -> str: + """https://iterm2.com/documentation-escape-codes.html""" + return f"{self.OSC}1337;StealFocus{self.ST}" + @readline_wrap def CurrentDirectory(self: Self, text: str) -> str: + """ + Set the current directory + + https://iterm2.com/documentation-escape-codes.html + """ return f"{self.OSC}1337;CurrentDir={text}{self.ST}" + @readline_wrap + def CopyToClipboard(self: Self, text: str) -> str: + """https://iterm2.com/documentation-escape-codes.html""" + data = b64encode(text.encode("utf-8")).decode("utf-8") + return f"{self.OSC}52;Pc;{data}{self.ST}" + + @readline_wrap + def ChangeProfile(self: Self, profile: str) -> str: + """https://iterm2.com/documentation-escape-codes.html""" + return f"{self.OSC}1337;SetProfile={profile}{self.ST}" + + @readline_wrap + def CursorShape(self: Self, shape: CursorShape) -> str: + """Set the cursor shape + + https://iterm2.com/documentation-escape-codes.html + """ + return f"{self.OSC}1337;CursorShape={shape}{self.ST}" + @readline_wrap def ActiveStatusDisplay(self: Self, mode: ActiveStatusDisplayMode) -> str: return f"{self.CSI}{mode}$}}" @@ -440,8 +547,14 @@ class ANSI: def StatusLineType(self: Self, mode: StatusLineType) -> str: return f"{self.CSI}{mode}$~" + @check_annotations @readline_wrap - def DesktopNotification(self: Self, identifier: str, title: str, body: str) -> str: + def DesktopNotification( + self: Self, + identifier: Annotated[str, RegexMatch(re.compile(r"\A[a-zA-Z0-9-_+.]\z"))], + title: str, + body: str, + ) -> str: def format( part: DesktopNotificationPart, text: str, done: DesktopNotificationDone ) -> str: diff --git a/pyproject.toml b/pyproject.toml index aa17838..7d6a001 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,9 @@ force_sort_within_sections = true [tool.ruff] line-length = 120 +[tool.pyright] +pythonVersion = "3.12" + [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api"