From 29c26d3b00cbb0581b78f9660c2f9ae35a3445b7 Mon Sep 17 00:00:00 2001 From: Pierre Marchand Date: Mon, 25 Dec 2023 15:21:54 +0100 Subject: [PATCH] refactoring --- .devcontainer/devcontainer.json | 5 +- .github/workflows/CI.yml | 16 ++- README.rst | 5 +- asciinema_automation/cli.py | 13 +-- asciinema_automation/instruction.py | 42 ++++---- asciinema_automation/parse.py | 110 +++++++++++++++++++++ asciinema_automation/script.py | 148 +++------------------------- pyproject.toml | 49 +++++---- 8 files changed, 192 insertions(+), 196 deletions(-) create mode 100644 asciinema_automation/parse.py diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a6a6181..6667842 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -19,8 +19,9 @@ "extensions": [ "ms-python.python", "ms-python.vscode-pylance", - "ms-python.black-formatter", - "ms-python.isort" + "charliermarsh.ruff", + "ms-python.mypy-type-checker", + "tamasfe.even-better-toml" ] } }, diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 3b91349..9225130 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -19,25 +19,23 @@ jobs: steps: - uses: actions/checkout@v3 - + - name: Set up Python package run: | python3 -m pip install --upgrade pip - python3 -m pip install .[test] + python3 -m pip install .[dev] - name: Run regression tests run: | set enable-bracketed-paste off python3 -m pytest -v - - name: Check module imports with isort - run: | - python3 -m pip install isort - python3 -m isort . --check-only --diff - # uses: isort/isort-action@master + - name: Check with ruff + uses: chartboost/ruff-action@v1 - - name: Check formatting with black - uses: psf/black@stable + - name: Check with mypy + run: | + mypy asciinema_automation/ tests/ - name: Check building a binary wheel and a source tarball run: | diff --git a/README.rst b/README.rst index 14de08b..a9d0f2f 100644 --- a/README.rst +++ b/README.rst @@ -4,12 +4,9 @@ asciinema-automation .. image:: https://badge.fury.io/py/asciinema-automation.svg :target: https://badge.fury.io/py/asciinema-automation -.. image:: https://github.com/PierreMarchand20/asciinema_automation/actions/workflows/CI.yml/badge.svg - :target: https://github.com/PierreMarchand20/asciinema_automation/actions/workflows/CI.yml - .. image:: https://img.shields.io/badge/code%20style-black-000000.svg :target: https://github.com/psf/black - + Asciinema-Automation is a Python package which provides a small CLI utility to automate `asciinema `_ recordings. The only dependencies are asciinema and `Pexpect `_. Example diff --git a/asciinema_automation/cli.py b/asciinema_automation/cli.py index bc0bd14..ec4d716 100644 --- a/asciinema_automation/cli.py +++ b/asciinema_automation/cli.py @@ -1,11 +1,13 @@ import argparse import logging import pathlib +from typing import Optional +from asciinema_automation import parse from asciinema_automation.script import Script -def cli(argv=None): +def cli(argv: Optional[str] = None) -> None: # Command line arguments parser = argparse.ArgumentParser() parser.add_argument( @@ -82,13 +84,12 @@ def cli(argv=None): # Script script = Script( - inputfile, outputfile, asciinema_arguments, - wait, - delay, - standard_deviation, - timeout, + wait / 1000, + delay / 1000, + standard_deviation / 1000, + parse.parse_script_file(inputfile, timeout), ) # diff --git a/asciinema_automation/instruction.py b/asciinema_automation/instruction.py index 36b0abf..6f364ca 100644 --- a/asciinema_automation/instruction.py +++ b/asciinema_automation/instruction.py @@ -2,32 +2,30 @@ import random import re import time +from typing import List -logger = logging.getLogger(__name__) - +from .script import Instruction, Script -class Instruction: - def run(self, script): - logger.info(self.__class__.__name__) +logger = logging.getLogger(__name__) class ChangeWaitInstruction(Instruction): - def __init__(self, wait): + def __init__(self, wait: float): super().__init__() self.wait = wait - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("%s->%s", script.wait, self.wait) script.wait = self.wait class ChangeDelayInstruction(Instruction): - def __init__(self, delay): + def __init__(self, delay: float): super().__init__() self.delay = delay - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("%s->%s", script.delay, self.delay) script.delay = self.delay @@ -39,24 +37,24 @@ def __init__(self, expect_value: str, timeout: int): self.expect_value = expect_value self.timeout = timeout - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Expect %s", repr(self.expect_value)) script.process.expect(self.expect_value, timeout=self.timeout) class SendInstruction(Instruction): - def __init__(self, send_value): + def __init__(self, send_value: str): super().__init__() self.send_value = send_value - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Send %s", repr(self.send_value)) - self.receive_value = self.send_value + # self.receive_value = self.send_value # Check for special character - self.receive_value = [re.escape(c) for c in list(self.send_value)] + self.receive_value: List[str] = [re.escape(c) for c in list(self.send_value)] # Write intruction for send_character, receive_character in zip( @@ -77,32 +75,32 @@ def run(self, script): class SendCharacterInstruction(Instruction): - def __init__(self, send_value): + def __init__(self, send_value: str): super().__init__() self.send_value = send_value - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Send '%s'", self.send_value) script.process.send(self.send_value) class SendShellInstruction(SendInstruction): - def __init__(self, command): + def __init__(self, command: str): super().__init__(command) - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Send '\\n'") script.process.send("\n") class SendControlInstruction(Instruction): - def __init__(self, control): + def __init__(self, control: str): super().__init__() self.control = control - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Send ctrl+%s", self.control) script.process.sendcontrol(self.control) @@ -114,7 +112,7 @@ class SendArrowInstruction(Instruction): KEY_RIGHT = "\x1b[C" KEY_LEFT = "\x1b[D" - def __init__(self, send, num, enter=False): + def __init__(self, send: str, num: int, enter: bool = False): super().__init__() self.mapping = dict() self.mapping["up"] = "\x1b[A" @@ -125,7 +123,7 @@ def __init__(self, send, num, enter=False): self.num = num self.enter = enter - def run(self, script): + def run(self, script: Script) -> None: super().run(script) logger.debug("Send %s arrow %i times", self.send, self.num) for _ in range(self.num): diff --git a/asciinema_automation/parse.py b/asciinema_automation/parse.py new file mode 100644 index 0000000..fdd98b4 --- /dev/null +++ b/asciinema_automation/parse.py @@ -0,0 +1,110 @@ +import codecs +import logging +import pathlib +import re + +from .instruction import ( + ChangeDelayInstruction, + ChangeWaitInstruction, + ExpectInstruction, + SendArrowInstruction, + SendCharacterInstruction, + SendControlInstruction, + SendInstruction, + SendShellInstruction, +) +from .script import Instruction + +logger = logging.getLogger(__name__) + +# To read escaped character from instructions +# https://stackoverflow.com/a/24519338/5913047 +ESCAPE_SEQUENCE_RE = re.compile( + r""" + ( \\U........ # 8-digit hex escapes + | \\u.... # 4-digit hex escapes + | \\x.. # 2-digit hex escapes + | \\[0-7]{1,3} # Octal escapes + | \\N\{[^}]+\} # Unicode characters by name + | \\[\\'"abfnrtv] # Single-character escapes + )""", + re.UNICODE | re.VERBOSE, +) + + +def decode_escapes(s: str) -> str: + def decode_match(match: re.Match[str]) -> str: + return codecs.decode(match.group(0), "unicode-escape") + + return ESCAPE_SEQUENCE_RE.sub(decode_match, s) + + +def parse_script_file(inputfile: pathlib.Path, timeout: int) -> list["Instruction"]: + # Compile regex + wait_time_regex = re.compile(r"^#\$ wait (\d*)(?!\S)") + delay_time_regex = re.compile(r"^#\$ delay (\d*)(?!\S)") + sendcontrol_command_regex = re.compile(r"^#\$ sendcontrol ([a-z])(?!\S)") + sendcharacter_command_regex = re.compile(r"^#\$ sendcharacter (.*)(?!\S)") + expect_regex = re.compile(r"^#\$ expect (.*)(?!\S)") + send_regex = re.compile(r"^#\$ send (.*)(?!\S)") + arrow_command_regex = re.compile( + r"^#\$ sendarrow (down|up|left|right)(?:\s([\d]+))?(?!\S)" + ) + arrow_sendline_command_regex = re.compile( + r"^#\$ sendlinearrow (down|up|left|right)(?:\s([\d]+))?(?!\S)" + ) + + instructions: list[Instruction] = [] + + with open(inputfile) as file: + previous_line = "" + for line in file: + if line.strip(): + line = line.rstrip() + + if match := wait_time_regex.search(line, 0): + wait_time = match.group(1) + instructions.append(ChangeWaitInstruction(int(wait_time) / 1000)) + elif match := delay_time_regex.search(line, 0): + delay_time = match.group(1) + instructions.append(ChangeDelayInstruction(int(delay_time) / 1000)) + elif match := sendcontrol_command_regex.search(line, 0): + sendcontrol_command = match.group(1) + instructions.append(SendControlInstruction(sendcontrol_command)) + elif match := sendcharacter_command_regex.search(line, 0): + sendcharacter_command = match.group(1) + instructions.append(SendCharacterInstruction(sendcharacter_command)) + elif match := arrow_command_regex.search(line, 0): + arrow_command = match.group(1) + arrow_num = match.group(2) + if arrow_num is None: + arrow_num = 1 + instructions.append( + SendArrowInstruction(arrow_command, int(arrow_num), False) + ) + elif match := arrow_sendline_command_regex.search(line, 0): + arrow_command = match.group(1) + arrow_num = match.group(2) + if arrow_num is None: + arrow_num = 1 + instructions.append( + SendArrowInstruction(arrow_command, int(arrow_num), True) + ) + elif match := expect_regex.search(line, 0): + expect_value = match.group(1) + expect_value = decode_escapes(expect_value) + instructions.append(ExpectInstruction(expect_value, timeout)) + elif match := send_regex.search(line, 0): + send_value = match.group(1) + send_value = decode_escapes(send_value) + instructions.append(SendInstruction(send_value)) + elif line.startswith("#"): + pass + else: + if line.endswith("\\"): + previous_line += line + "\n" + else: + instructions.append(SendShellInstruction(previous_line + line)) + previous_line = "" + + return instructions diff --git a/asciinema_automation/script.py b/asciinema_automation/script.py index 00233eb..7c02a0a 100644 --- a/asciinema_automation/script.py +++ b/asciinema_automation/script.py @@ -1,149 +1,29 @@ -import codecs import logging import pathlib -import re import time +from dataclasses import dataclass import pexpect -from asciinema_automation.instruction import ( - ChangeDelayInstruction, - ChangeWaitInstruction, - ExpectInstruction, - SendArrowInstruction, - SendCharacterInstruction, - SendControlInstruction, - SendInstruction, - SendShellInstruction, -) - -# To read escaped character from instructions -# https://stackoverflow.com/a/24519338/5913047 -ESCAPE_SEQUENCE_RE = re.compile( - r""" - ( \\U........ # 8-digit hex escapes - | \\u.... # 4-digit hex escapes - | \\x.. # 2-digit hex escapes - | \\[0-7]{1,3} # Octal escapes - | \\N\{[^}]+\} # Unicode characters by name - | \\[\\'"abfnrtv] # Single-character escapes - )""", - re.UNICODE | re.VERBOSE, -) - logger = logging.getLogger(__name__) -def decode_escapes(s): - def decode_match(match): - return codecs.decode(match.group(0), "unicode-escape") - - return ESCAPE_SEQUENCE_RE.sub(decode_match, s) +class Instruction: + def run(self, script: "Script") -> None: + logger.info(self.__class__.__name__) +@dataclass class Script: - def __init__( - self, - inputfile: pathlib.Path, - outputfile: pathlib.Path, - asciinema_arguments: str, - wait, - delay, - standard_deviation, - timeout, - delaybeforesend=50 / 1000.0, - ): - # Set members from arguments - self.inputfile = inputfile - self.outputfile = outputfile - self.asciinema_arguments = asciinema_arguments - self.delay = delay / 1000.0 - self.wait = wait / 1000.0 - self.standard_deviation = standard_deviation / 1000.0 - self.delaybeforesend = delaybeforesend - - # Default values for data members - self.expected = "\n" - self.send = "\n" - - # Create data members - self.instructions = [] - self.process = None - - # Compile regex - wait_time_regex = re.compile(r"^#\$ wait (\d*)(?!\S)") - delay_time_regex = re.compile(r"^#\$ delay (\d*)(?!\S)") - sendcontrol_command_regex = re.compile(r"^#\$ sendcontrol ([a-z])(?!\S)") - sendcharacter_command_regex = re.compile(r"^#\$ sendcharacter (.*)(?!\S)") - expect_regex = re.compile(r"^#\$ expect (.*)(?!\S)") - send_regex = re.compile(r"^#\$ send (.*)(?!\S)") - arrow_command_regex = re.compile( - r"^#\$ sendarrow (down|up|left|right)(?:\s([\d]+))?(?!\S)" - ) - arrow_sendline_command_regex = re.compile( - r"^#\$ sendlinearrow (down|up|left|right)(?:\s([\d]+))?(?!\S)" - ) - - # Read script - with open(inputfile) as f: - lines = [line.rstrip() for line in f.readlines() if line.strip()] - - previous_line = "" - for line in lines: - if line.startswith("#$ wait"): - wait_time = wait_time_regex.search(line, 0).group(1) - self.instructions.append(ChangeWaitInstruction(int(wait_time) / 1000)) - elif line.startswith("#$ delay"): - delay_time = delay_time_regex.search(line, 0).group(1) - self.instructions.append(ChangeDelayInstruction(int(delay_time) / 1000)) - elif line.startswith("#$ sendcontrol"): - sendcontrol_command = sendcontrol_command_regex.search(line, 0).group(1) - self.instructions.append(SendControlInstruction(sendcontrol_command)) - elif line.startswith("#$ sendcharacter"): - sendcharacter_command = sendcharacter_command_regex.search( - line, 0 - ).group(1) - self.instructions.append( - SendCharacterInstruction(sendcharacter_command) - ) - elif line.startswith("#$ sendarrow"): - arrow_command = arrow_command_regex.search(line, 0).group(1) - arrow_num = arrow_command_regex.search(line, 0).group(2) - if arrow_num is None: - arrow_num = 1 - self.instructions.append( - SendArrowInstruction(arrow_command, int(arrow_num), False) - ) - elif line.startswith("#$ sendlinearrow"): - arrow_command = arrow_sendline_command_regex.search(line, 0).group(1) - arrow_num = arrow_sendline_command_regex.search(line, 0).group(2) - if arrow_num is None: - arrow_num = 1 - self.instructions.append( - SendArrowInstruction(arrow_command, int(arrow_num), True) - ) - elif line.startswith("#$ expect"): - expect_value = "" - if expect_regex.search(line, 0) is not None: - expect_value = expect_regex.search(line, 0).group(1) - expect_value = decode_escapes(expect_value) - self.instructions.append(ExpectInstruction(expect_value, timeout)) - elif line.startswith("#$ send"): - send_value = "" - if send_regex.search(line, 0) is not None: - send_value = send_regex.search(line, 0).group(1) - send_value = decode_escapes(send_value) - self.instructions.append(SendInstruction(send_value)) - elif line.startswith("#"): - pass - else: - if line.endswith("\\"): - previous_line += line + "\n" - else: - self.instructions.append(SendShellInstruction(previous_line + line)) - previous_line = "" - - def execute(self): + outputfile: pathlib.Path + asciinema_arguments: str + wait: float + delay: float + standard_deviation: float + instructions: list[Instruction] + delaybeforesend: float = 50 / 1000.0 + + def execute(self) -> None: spawn_command = ( "asciinema rec " + str(self.outputfile) + " " + self.asciinema_arguments ) diff --git a/pyproject.toml b/pyproject.toml index 78ec46f..9afea2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,38 +1,49 @@ [project] name = "asciinema-automation" version = "0.1.3" -authors = [ - { name="Pierre Marchand", email="test@test.com" }, -] +authors = [{ name = "Pierre Marchand", email = "test@test.com" }] description = "CLI utility to automate asciinema" -readme = {file = "README.rst", content-type = "text/x-rst"} +readme = { file = "README.rst", content-type = "text/x-rst" } requires-python = ">=3.7" -license = {text = "MIT License"} +license = { text = "MIT License" } classifiers = [ - "Programming Language :: Python :: 3", - "License :: OSI Approved :: MIT License", - "Operating System :: MacOS", - "Operating System :: Unix" + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: MacOS", + "Operating System :: Unix", ] keywords = ["asciinema"] -dependencies = [ - "pexpect>=4.8.0", - "asciinema>=2.2.0" -] +dependencies = ["pexpect>=4.8.0", "asciinema>=2.2.0"] [project.optional-dependencies] -dev = ["black", "bumpver", "isort", "pytest"] -test = ["pytest"] +dev = ["ruff", "pytest", "mypy", "types-pexpect"] [project.urls] "Homepage" = "https://github.com/PierreMarchand20/asciinema_automation" "Bug Tracker" = "https://github.com/PierreMarchand20/asciinema_automation/issues" [project.scripts] -asciinema-automation="asciinema_automation.cli:cli" +asciinema-automation = "asciinema_automation.cli:cli" -[tool.black] +[tool.ruff] line-length = 88 +indent-width = 4 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle + "F", # Pyflakes + "UP", # pyupgrade + "B", # flake8-bugbear + "SIM", # flake8-simplify + "I", # isort +] +ignore = [] + +# Allow fix for all enabled rules (when `--fix`) is provided. +fixable = ["ALL"] +unfixable = [] -[tool.isort] -profile = "black" +[tool.mypy] +python_version = "3.12" +strict = true