Skip to content

Commit

Permalink
Add NaughTTY class (#2)
Browse files Browse the repository at this point in the history
  • Loading branch information
Cariad Eccleston authored Sep 19, 2021
1 parent c75ad68 commit 93f7924
Show file tree
Hide file tree
Showing 15 changed files with 291 additions and 17 deletions.
8 changes: 7 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
{
"cSpell.words": [
"naughtty"
"epilog",
"execvpe",
"fcntl",
"ioctl",
"naughtty",
"struct",
"TIOCSWINSZ"
]
}
1 change: 1 addition & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ name = "pypi"
[packages]

[dev-packages]
ansiscape = "==1.0.0a1"
black = "==21.9b0"
flake8 = "*"
isort = "*"
Expand Down
15 changes: 11 additions & 4 deletions Pipfile.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
# naughtty
# naughtty

`naughtty` is a Python package and CLI tool for executing shell commands in a pseudo-terminal.

NOTE: This package will probably only work in GNU/Linux due to reliance on pseudo-terminals. I haven't tested it in any other operating system.
6 changes: 4 additions & 2 deletions naughtty/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from naughtty.version import get_version
from naughtty.naughtty import NaughTTY

__all__ = ["get_version"]
__all__ = [
"NaughTTY",
]
8 changes: 4 additions & 4 deletions naughtty/__main__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
from naughtty import get_version


def cli_entry() -> None:
print(f"naughtty v{get_version()}")
from naughtty.cli import make_response

print(make_response())


if __name__ == "__main__":

cli_entry()
75 changes: 75 additions & 0 deletions naughtty/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
from argparse import ArgumentParser, Namespace
from typing import List, Optional, Tuple

from naughtty.constants import DEFAULT_CHARACTER_PIXELS, DEFAULT_TERMINAL_SIZE
from naughtty.naughtty import NaughTTY
from naughtty.version import get_version


def make_naughtty(ns: Namespace) -> NaughTTY:
"""Makes a `NaughTTY` instance based on the given command line arguments."""

character_pixels: Optional[Tuple[int, int]] = None

if ns.char:
parts = str(ns.char).split(",")
character_pixels = (int(parts[0]), int(parts[1]))

return NaughTTY(
columns=int(ns.cols) if ns.cols else None,
command=ns.command,
rows=int(ns.rows) if ns.rows else None,
character_pixels=character_pixels,
)


def make_response(cli_args: Optional[List[str]] = None) -> str:
"""Makes a response to the given command line arguments."""

parser = ArgumentParser(
# We don't want ArgumentParser to pick up on "--help" in the child
# command's arguments:
add_help=False,
description="Executes a shell command in a pseudo-terminal and prints its output to stdout.",
epilog="For example: naughtty pipenv --help > out.txt",
)

parser.add_argument("command", help="command", nargs="*")

parser.add_argument(
"--char",
help=f"character size in pixels (default={DEFAULT_CHARACTER_PIXELS[0]},{DEFAULT_CHARACTER_PIXELS[1]})",
metavar="WIDTH,HEIGHT",
)

parser.add_argument(
"--cols",
help=f"columns (default=system default or {DEFAULT_TERMINAL_SIZE[0]})",
)

parser.add_argument("--help", action="store_true", help="print this help")

parser.add_argument(
"--rows",
help=f"rows (default=system default or {DEFAULT_TERMINAL_SIZE[1]})",
)

parser.add_argument(
"--version",
action="store_true",
help="print the version",
)

args = parser.parse_args(cli_args)

if args.version:
return get_version()

# If we discover "--help" AND a command then that "--help" is intended for
# the command and not us:
if not args.command or (args.help and not args.command):
return parser.format_help()

n = make_naughtty(args)
n.execute()
return n.output
2 changes: 2 additions & 0 deletions naughtty/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
DEFAULT_CHARACTER_PIXELS = (9, 18)
DEFAULT_TERMINAL_SIZE = (80, 24)
94 changes: 94 additions & 0 deletions naughtty/naughtty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
from fcntl import ioctl
from os import environ, execvpe, read
from pty import CHILD, fork
from shutil import get_terminal_size
from struct import pack
from termios import TIOCSWINSZ
from typing import List, Optional, Tuple

from naughtty.constants import DEFAULT_CHARACTER_PIXELS, DEFAULT_TERMINAL_SIZE


class NaughTTY:
"""
Executes a shell command in a pseudo-terminal.
Arguments:
command: Command with arguments to execute
character_pixels: Pixel width and height of pseudo-terminal characters
(default=naughtty.constants.DEFAULT_CHARACTER_PIXELS)
columns: Pseudo-terminal column count (default=current or
naughtty.constants.DEFAULT_TERMINAL_COLUMNS)
rows: Pseudo-terminal row count (default=current or
naughtty.constants.DEFAULT_TERMINAL_ROWS)
"""

def __init__(
self,
command: List[str],
character_pixels: Optional[Tuple[int, int]] = None,
columns: Optional[int] = None,
rows: Optional[int] = None,
) -> None:

current_terminal_size = get_terminal_size(DEFAULT_TERMINAL_SIZE)

self._child_output = ""

self.character_pixels = character_pixels or DEFAULT_CHARACTER_PIXELS
self.command = command
self.terminal_size = (
columns or current_terminal_size.columns,
rows or current_terminal_size.lines,
)

def execute(self) -> None:
"""Executes the command in a pseudo-terminal."""

pid, fd = fork()

# We intentionally exclude this `if` from coverage reports because the
# case where `pid == CHILD` only ever occurs in the fork and not _this_
# process, so the watcher doesn't know it gets touched.
if pid != CHILD: # pragma: no cover
# Packed structure documentation:
# https://www.delorie.com/djgpp/doc/libc/libc_495.html
child_terminal_size = pack(
"HHHH",
self.terminal_size[1],
self.terminal_size[0],
self.terminal_size[0] * self.character_pixels[0],
self.terminal_size[1] * self.character_pixels[1],
)
# TIOCSWINSZ = Terminal Input/Output Control Set WINdow SiZe
ioctl(fd, TIOCSWINSZ, child_terminal_size)

if pid == CHILD:
# The first argument is the name, not the executable.
#
# `execvpe` will terminate the child when its process is complete.
#
# Also note that we intentionally exclude this `if` branch from
# coverage reports because it only ever runs in the fork and not
# _this_ process, so the watcher doesn't know it gets touched.
execvpe(self.command[0], self.command, environ) # pragma: no cover

child_output = bytes()

try:
while True:
child_output += read(fd, 1024)
except OSError:
# The child has terminated and there's nothing more to read.
pass

self._child_output = child_output.decode("UTF-8")

@property
def output(self) -> str:
"""Gets the execution's output."""

return self._child_output
6 changes: 3 additions & 3 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from setuptools import setup # pyright: reportMissingTypeStubs=false

from naughtty import get_version
from naughtty.version import get_version

readme_path = Path(__file__).parent / "README.md"

Expand All @@ -13,7 +13,7 @@
"Environment :: Console",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: OS Independent",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.8",
"Programming Language :: Python :: 3.9",
Expand All @@ -36,7 +36,7 @@
author="Cariad Eccleston",
author_email="cariad@cariad.io",
classifiers=classifiers,
description="CLI tool and Python package for running a pseudo terminal",
description="Python package and CLI tool for executing shell commands in a pseudo-terminal",
entry_points={
"console_scripts": [
"naughtty=naughtty.__main__:cli_entry",
Expand Down
2 changes: 1 addition & 1 deletion test-cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ assert() {
exit 1
}

assert "$(naughtty --version)" "naughtty v${CIRCLE_TAG:-"-1.-1.-1"}"
assert "$(naughtty --version)" "${CIRCLE_TAG:-"-1.-1.-1"}"
9 changes: 9 additions & 0 deletions tests/out-color.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
from shutil import get_terminal_size

from ansiscape import green, sequence, yellow

print(green("Hello, world!"))

ts = get_terminal_size((-1, -1))
print(sequence("Terminal width: ", yellow(str(ts[0]))))
print(sequence("Terminal height: ", yellow(str(ts[1]))))
61 changes: 61 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
from argparse import Namespace

from naughtty.cli import make_naughtty, make_response


def test_make_naughtty__custom() -> None:
naughtty = make_naughtty(
Namespace(
char="5,6",
cols="3",
command=["python", "tests/out-color.py"],
rows="4",
)
)

assert naughtty.character_pixels == (5, 6)
assert naughtty.command == ["python", "tests/out-color.py"]
assert naughtty.terminal_size == (3, 4)


def test_make_naughtty__defaults() -> None:
naughtty = make_naughtty(
Namespace(
char=None,
cols=None,
command=["python", "tests/out-color.py"],
rows=None,
)
)

assert naughtty.character_pixels == (9, 18)
assert naughtty.command == ["python", "tests/out-color.py"]
assert naughtty.terminal_size == (80, 24)


def test_make_response__for_execution() -> None:
response = make_response(["python", "tests/out-color.py"])

assert (
response
== """\x1b[32mHello, world!\x1b[39m\r
Terminal width: \x1b[33m80\x1b[39m\r
Terminal height: \x1b[33m24\x1b[39m\r
"""
)


def test_make_response__for_execution__with_help() -> None:
response = make_response(["pipenv", "--help"])
assert "naughtty" not in response


def test_make_response__for_help() -> None:
response = make_response(["--help"])
assert response.startswith("usage: ")
assert response.endswith("naughtty pipenv --help > out.txt\n")
assert "naughtty" in response


def test_make_response__for_version() -> None:
assert make_response(["--version"]) == "-1.-1.-1"
13 changes: 13 additions & 0 deletions tests/test_naughtty.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from naughtty import NaughTTY


def test_naughtty() -> None:
n = NaughTTY(["python", "tests/out-color.py"])
n.execute()
assert (
n.output
== """\x1b[32mHello, world!\x1b[39m\r
Terminal width: \x1b[33m80\x1b[39m\r
Terminal height: \x1b[33m24\x1b[39m\r
"""
)
2 changes: 1 addition & 1 deletion tests/test_version.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from naughtty import get_version
from naughtty.version import get_version


def test_get_version() -> None:
Expand Down

0 comments on commit 93f7924

Please sign in to comment.