This commit is contained in:
Jeffrey C. Ollie 2023-10-29 16:46:47 -05:00
commit 9c5abb66c5
Signed by: jeff
GPG key ID: 6F86035A6D97044E
5 changed files with 628 additions and 0 deletions

77
flake.lock Normal file
View file

@ -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
}

51
flake.nix Normal file
View file

@ -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;
};
};
}
);
}

7
poetry.lock generated Normal file
View file

@ -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"

467
pyansi/__init__.py Normal file
View file

@ -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

26
pyproject.toml Normal file
View file

@ -0,0 +1,26 @@
[tool.poetry]
name = "pyansi"
version = "0.1.0"
description = ""
authors = ["Jeffrey C. Ollie <jeff@ocjtech.us>"]
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"