diff --git a/README.md b/README.md index ac0bae8..be690a6 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ # pyshell A Linux subprocess module, An easier way to interact with the Linux shell + >pyshell should be cross platform but has only been tested with linux + + # Installation + `$ pip install pyshell` + + # Usage + + from pyshell import pyshell + shell = pyshell() + shell.echo('Hello', "GitHub!") + + # Docs + +Check out the Official [Documentation](https://volitank.com/pyshell/index.html) for help with syntax and different arguments diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = source +BUILDDIR = build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..9534b01 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=source +set BUILDDIR=build + +if "%1" == "" goto help + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.http://sphinx-doc.org/ + exit /b 1 +) + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..4474d06 --- /dev/null +++ b/docs/source/conf.py @@ -0,0 +1,45 @@ +# Configuration file for the Sphinx documentation builder. +import sphinx_rtd_theme +# pyshell must be installed for this import to work +import pyshell +# -- Project information + +project = pyshell.__title__ +copyright = pyshell.__copyright__ +author = pyshell.__author__ +version = pyshell.__version__ + +# -- General configuration + +extensions = [ + 'sphinx.ext.duration', + 'sphinx.ext.doctest', + 'sphinx.ext.autodoc', + 'sphinx.ext.autosummary', + 'sphinx.ext.intersphinx', +] + +intersphinx_mapping = { + 'python': ('https://docs.python.org/3/', None), + 'sphinx': ('https://www.sphinx-doc.org/en/master/', None), +} +intersphinx_disabled_domains = ['std'] + +templates_path = ['_templates'] + +# -- Options for HTML output +# import sphinx_bootstrap_theme + +html_theme = 'sphinx_rtd_theme' +html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] +pygments_style = 'sphinx' +html_theme_options = { + 'collapse_navigation': False, + 'display_version': False, +} +# html_theme = 'bootstrap' +# html_theme_path = sphinx_bootstrap_theme.get_html_theme_path() + +# -- Options for EPUB output +epub_show_urls = 'footnote' +intersphinx_mapping = {"python": ("https://docs.python.org/3.9", None)} diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..ec9fa57 --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,19 @@ +.. toctree:: + :hidden: + + usage + +Welcome to pyshell's documentation! +=================================== + +**pyshell** is a Linux subprocess module, +an easier way to interact with the Linux shell. +Check out the *source* code at pyshell's `Official Github `_ + +Check out the :doc:`usage` section for further information, including :ref:`installation` of the project. + +.. note:: + + This project is under active development. + + Thank you for checking us out. diff --git a/docs/source/sections/initial_setup.rst b/docs/source/sections/initial_setup.rst new file mode 100644 index 0000000..54ca765 --- /dev/null +++ b/docs/source/sections/initial_setup.rst @@ -0,0 +1,26 @@ +.. _installation: + +Installation +============ + +To use pyshell, first install it using pip: + +.. code-block:: console + + $ pip install pyshell + +.. _initialization: + +Initialization +-------------- + +Now that it's installed you can import and initialize: + +.. code-block:: python + + from pyshell import pyshell + + shell = pyshell(logfile='/tmp/shell.log', shell='/bin/bash') + shell.echo('Hello', 'World') + +.. seealso:: :ref:`pyshell_arguments` \ No newline at end of file diff --git a/docs/source/sections/passing_arguments.rst b/docs/source/sections/passing_arguments.rst new file mode 100644 index 0000000..1d0e7ff --- /dev/null +++ b/docs/source/sections/passing_arguments.rst @@ -0,0 +1,90 @@ +.. _passing_arguments: + +Passing Arguments +================= + +When passing multiple arguments to a command, each argument *must* be a separate +string: + +.. code-block:: python + + shell = pyshell() + shell.tar("cvf", "/tmp/test.tar", "/my/home/directory/") + +This *will not work*: + +.. code-block:: python + + shell.tar("cvf /tmp/test.tar /my/home/directory") + +If you're using the ``shell='/bin/bash'`` Both of these will work. This is because pyshell does some special magic behind the scenes so the shell gets exactly what you told it. This feature is almost certainly to be buggy, example with output in comments: + +.. code-block:: python + + dash = pyshell(shell='/bin/dash') + + dash.echo('shell is $0') + # shell is /bin/dash + + dash.echo('shell', 'is' ,'$0') + # shell is /bin/dash + + dash.run(["echo", "shell", "is", "$0"], shell='/bin/bash') + # shell is /bin/bash ## Notice we can change the shell one time on the fly and not effect the instance + + dash.run(["echo shell is $0"]) + # shell is /bin/dash + + dash("echo", "shell", "is", "$0") + # shell is /bin/dash + + dash("echo shell is $0") + # shell is /bin/dash + + dash("echo", "shell", "is", "$0", shell=DEFAULT) + # shell is $0 ## We can override back to no shell using the DEFAULT switch. + + dash("echo shell is $0", shell=DEFAULT) + # FileNotFoundError: [Errno 2] No such file or directory: 'echo shell is $0' + # This one doesn't work because with out a shell it tries to use that string as a command + +The only difference between using our run function and calling the class directly is with run you must use a list, as seen above. If something isn't working by calling the class then you can try to call run directly, it *should* work with run. + +.. seealso:: Pyshell Arguments section on the :ref:`pyshell_arguments_shell` + +Dashes +------ + +For commands with dashes we use underscores instead. All underscores in the caller will be converted to dashes. Anything in the args section will not. Below is an example of different ways to run commands and their outputs in comments below so you can get an idea as how it works. + +.. code-block:: python + + shell = pyshell() + shell.echo.test_echo() + # test-echo + + shell.echo("test_echo") + # test_echo + + shell.run(["echo_test"]) + # FileNotFoundError: [Errno 2] No such file or directory: 'echo_test' + + shell("echo_test") + # pyshell.pyshell.CommandNotFound: command echo_test does not exist + + shell.echo_test() + # pyshell.pyshell.CommandNotFound: command echo-test does not exist + +Equivalents +----------- + +There are many ways to go about running commands in pyshell. For example all commands below are equal. + +.. code-block:: python + + shell = pyshell() + shell.mkfs.ext4('/dev/sdb1') + shell.run(['mkfs.ext4', '/dev/sdb1']) + shell('mkfs.ext4', '/dev/sdb1') + +When initialized we build a tuple of program names that you can use. If you do something like ``shell.mkfs.ext4.hello`` we take ``mkfs.ext4.hello`` and test it against that tuple. If nothing is found we try ``mkfs.ext4``, and then ``mkfs``. if something matches we stop, use that as the command and then the rest as arguments. This is how we are able to accommodate some commands. \ No newline at end of file diff --git a/docs/source/sections/pyshell_arguments.rst b/docs/source/sections/pyshell_arguments.rst new file mode 100644 index 0000000..cd661b4 --- /dev/null +++ b/docs/source/sections/pyshell_arguments.rst @@ -0,0 +1,97 @@ +.. _pyshell_arguments: + +Pyshell Arguments +================= + +Defaults +-------- + +.. code-block:: python + + input=None, capture_output=False, check=False, + logfile=None, timeout=None, alias: dict=None, **kwargs): + +Most of pyshell's arguments are much like subprocess.run. +We actually use a slightly modified version of subprocess.run + +input +----- + +With pyshell input can be either a string or bytes, and there is no need to specify ``text=true`` as with subprocess + +.. code-block:: python + + shell = pyshell(input='this will go to stdin') + +capture_output +-------------- + +This option works exactly like the one in subprocess. It essentially sets stdout and stderr to PIPE + +check +----- + +Another option we have that works just like subprocess. If set to true it will throw an exception on a bad exit code + +.. code-block:: python + + shell = pyshell(capture_output=False, check=True) + +logfile +------- + +This is our first totally new option. Setting this will change stdout and stderr to a file. This cannot be used with **capture_output**. This should be a file in string or PathLike format. + + +.. code-block:: python + + shell = pyshell(logfile='/tmp/pyshell.log') + +timeout +------- + +Sets the timeout, in seconds, that we will wait on a child process. + +.. code-block:: python + + shell = pyshell(timeout=10) + +alias +----- + +This one is probably one of my favorite additions. To set this it is simple a dict with Key=command Value=alias. + +.. code-block:: python + + alias = { + 'ls': ['ls', '-lah', '--color'], + 'echo': ['printf'] + } + shell = pyshell(alias=alias) + +You can also change them on the fly with ``shell.setAlias(ls, ['-lah'])`` + +.. _pyshell_arguments_shell: + +shell +----- + +While this is technically a kwarg we do run with a patched Popen that allows you to set the shell for ``shell=`` rather than True and setting ``executable=``. Another change that I've made is that you can pass a list for ``shell=`` if you want your executable to have more options than just ``-c`` + +.. code-block:: python + + shell = pyshell(shell='/bin/dash') # Sets shell=True and executable='/bin/dash' + shell = pyshell(shell=['/bin/bash', '-O', 'extglob']) # Same as above yet we will have extglob for bash + +All other ``**kwargs`` are passed directly to Popen. If you're interested check out their documentation for more options. + +override +-------- + +Any settings whether initialized or not can be overrode while running your commands. Any option other than the defaults will override, to override to the default we have a special constant. + +.. code-block:: python + + shell = pyshell(shell='/bin/dash', logfile='/tmp/pyshell.log') + shell.echo('Hello', 'World', logfile=DEFAULT) # This will run this command with the logfile set to the default of None + shell.echo('Hello', 'World', shell='/bin/bash') # This will change our shell to bash for this command only diff --git a/docs/source/usage.rst b/docs/source/usage.rst new file mode 100644 index 0000000..e810858 --- /dev/null +++ b/docs/source/usage.rst @@ -0,0 +1,8 @@ +Usage +===== + +.. toctree:: + + sections/initial_setup + sections/pyshell_arguments + sections/passing_arguments diff --git a/pyshell.py b/pyshell.py deleted file mode 100644 index a381de1..0000000 --- a/pyshell.py +++ /dev/null @@ -1,264 +0,0 @@ -# This file is part of pyshell - -# pyshell is a Linux subprocess module. -# Copyright (C) 2021 Volitank - -# pyshell is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. - -# pyshell is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details.tures - -# You should have received a copy of the GNU General Public License -# along with pyshell. If not, see . - -##TODO -# Right now we wrap around the current subprocess run function. -# I will likely merge them do I'm not wrapping a wrapper dawg. -# -# Add more functions. This is barely useful right now outside of proof of concept - -from subprocess import run - -PIPE = -1 -STDOUT = -2 -DEVNULL = -3 - -class pyshell(): - - def __init__( self, - capture_output: bool=False, check: bool=False, - stdin=None, stdout=None, stderr=None, - # Supply an unopened file. Such at '/tmp/logfile.txt'. We will open, append, and close for you. - # If you want to redirect stderr to the log as well pass stderr=STDOUT - logfile=None, - shell: bool=False, text: bool=False, input: bool=False, - #TODO add an input option for individual functions. Right now it is just the object and run - **kwargs): - - """Subprocess as an object, for Linux. - - Initialize with certain options and use them through the life of your object. - Most of these are exactly the same you're use too with subprocess.run. - Outlined below are new features - - Arguments: - logfile: logfile='/tmp/pyshell.log' expects an unopen file. We will open for you. - aliasing: You can alias commands like so sh = pybash(ls_alias=['ls', '-lah', '--color']) - - anytime you run sh.ls() you will actually run 'ls -lah --color'. This works for all functions except run - - input: can be either str or bytes. It will figure it out and switch text for you. - shell: if you specify True it will use '/bin/bash'. You can also pass the shell instead of True, it's fine. shell='/bin/dash' - - The run function can override things that are defined in the object. If you just need one off command to work differently the use it. - - Example to mount readonly:: - - dash = pyshell(ls_alias=['ls', '-lah', '--color'], shell='/bin/dash') - dash.echo("hello this should be working") - dash.run(["lsblk"], shell=False) - """ - - self.stdin = stdin - self.stdout = stdout - self.stderr = stderr - self.capture_output = capture_output - self.check = check - self.kwargs = kwargs - self.logfile = logfile - self.shell = shell - self.text = text - self.input = input - - # You can call run directly and override any thing that you've initialized with - def run(self, command_list, shell=None, - stdin=None, stdout=None, stderr=None, logfile=None, - check: bool=False, capture_output: bool=False, - text=None, input=None, - **kwargs): - """ Our main function that executes all of our commands. - - You can use this to override settings in your pybash instance - """ - # Define selfs - _stdin = self.stdin - _stdout = self.stdout - _stderr = self.stderr - _check = self.check - _capture = self.capture_output - _text = self.text - _input = self.input - - # We need to convert our input if we're using the shell - if self.shell and shell is None: - - rcommand = [] - # We need to make sure our strings are exactly how we want them for the shell - for arg in command_list: - rcommand.append(repr(arg).strip('\'')) - command_list = [' '.join((arg) for arg in rcommand)] - - # Our special settings for shell. - if self.shell: - _shell = True - if self.shell is True: - _exec = '/bin/bash' - else: - _exec = self.shell - else: - _shell = False - _exec = None - - # Override object with shell=False - # Handle overrides last - if shell is not None: - _shell = True - if shell is False: - _shell = False - _exec = None - elif shell is True: - _exec = '/bin/bash' - else: - _exec = shell - - if stdout: - _stdin = stdin - if stdout: - _stdout = stdout - if stdout: - _stderr = stderr - - if check: - _check = check - if capture_output: - _capture = capture_output - - if text: - _text = text - if input: - _input = input - - # Do a quick check on the input and set text to yes or no depending. - # Not sure how this could bite us but we'll see - - if input or self.input: - if isinstance(_input, str): - _text = True - if isinstance(_input, bytes): - _text = False - - # You can disable a defined logfile setting logfile to false - if self.logfile and logfile is None: - with open(self.logfile, 'a') as logfile: - _stdout = logfile - self.process = run(command_list, - check=_check, - capture_output=_capture, - stdin=_stdin, - stdout=_stdout, - stderr=_stderr, - shell=_shell, - text=_text, - input=_input, - executable=_exec - ) - else: - self.process = run(command_list, - check=_check, - capture_output=_capture, - stdin=_stdin, - stdout=_stdout, - stderr=_stderr, - shell=_shell, - text=_text, - input=_input, - executable=_exec - ) - - def echo(self, *args, input=None): - """Uses the echo command - - alias with pybash(echo_alias=['echo', 'e']) - """ - commands = [] - # Check kwargs to see if an alias was set - if self.kwargs.get('echo_alias'): - #If there was then append the args first - for arg in self.kwargs.get('echo_alias'): - commands.append(arg) - else: - commands = ['echo'] - - for arg in args: - commands.append(arg) - - self.run(commands, input=input) - return self.process - - def ls(self, *args, input=None): - """ls 'lists' files and directories - - alias with pyshell(ls_alias=['ls', '-lah', '--color']) - """ - commands = [] - # Check kwargs to see if an alias was set - if self.kwargs.get('ls_alias'): - #If there was then append the args first - for arg in self.kwargs.get('ls_alias'): - commands.append(arg) - else: - commands = ['ls'] - - # Now we add our method arguments - for arg in args: - commands.append(arg) - - self.run(commands, input=input) - return self.process - - def cat(self, *args, input=None): - """Concatenates files. - - alias with pyshell(cat_alias=['cat', '"I've never used a cat switch in my entire life"']) - """ - commands = [] - # Check kwargs to see if an alias was set - if self.kwargs.get('cat_alias'): - #If there was then append the args first - for arg in self.kwargs.get('cat_alias'): - commands.append(arg) - else: - commands = ['cat'] - - # Now we add our method arguments - for arg in args: - commands.append(arg) - - self.run(commands, input=input) - return self.process - - def grep(self, *args, input=None): - """grep files. - - alias with pyshell(grep_alias=['grep', '--color']) - """ - commands = [] - # Check kwargs to see if an alias was set - if self.kwargs.get('grep_alias'): - #If there was then append the args first - for arg in self.kwargs.get('grep_alias'): - commands.append(arg) - else: - commands = ['grep'] - - # Now we add our method arguments - for arg in args: - commands.append(arg) - - self.run(commands, input=input) - return self.process diff --git a/pyshell/__init__.py b/pyshell/__init__.py new file mode 100644 index 0000000..8a011e6 --- /dev/null +++ b/pyshell/__init__.py @@ -0,0 +1,8 @@ + +__title__ = 'pyshell' +__author__ = 'volitank' +__license__ = 'GPLv3' +__copyright__ = '2021 volitank' +__version__ = '1.0.0.a1' + +from .pyshell import pyshell, DEFAULT, DEVNULL, PIPE, STDOUT \ No newline at end of file diff --git a/pyshell/pyshell.py b/pyshell/pyshell.py new file mode 100644 index 0000000..fcce881 --- /dev/null +++ b/pyshell/pyshell.py @@ -0,0 +1,644 @@ +# This file is part of pyshell + +# pyshell is a Linux subprocess module. +# Copyright (C) 2021 Volitank + +# pyshell is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# pyshell is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details.tures + +# You should have received a copy of the GNU General Public License +# along with pyshell. If not, see . + +import shutil +import os +import builtins +import sys +from inspect import getouterframes, currentframe +from subprocess import Popen, CalledProcessError, TimeoutExpired, SubprocessError, CompletedProcess, _USE_POSIX_SPAWN +from pathlib import Path + +try: + import msvcrt + import _winapi + _mswindows = True +except ModuleNotFoundError: + _mswindows = False + import _posixsubprocess + import select + import selectors + +PIPE = -1 +STDOUT = -2 +DEVNULL = -3 +DEFAULT = -4 + +class pyshellPopen(Popen): + # we are overriding execute child so we can pass a list into the shell for extglob. + def _execute_child(self, args, executable, preexec_fn, close_fds, + pass_fds, cwd, env, + startupinfo, creationflags, shell, + p2cread, p2cwrite, + c2pread, c2pwrite, + errread, errwrite, + restore_signals, + gid, gids, uid, umask, + start_new_session): + """Execute program (POSIX version)""" + + if isinstance(args, (str, bytes)): + args = [args] + elif isinstance(args, os.PathLike): + if shell: + raise TypeError('path-like args is not allowed when ' + 'shell is true') + args = [args] + else: + args = list(args) + + if shell: + # On Android the default shell is at '/system/bin/sh'. + unix_shell = ('/system/bin/sh' if + hasattr(sys, 'getandroidapilevel') else '/bin/sh') + args = [unix_shell, "-c"] + args + + if executable: + ## Pyshell Patch ## + # Patching this part of the method so that we will be + # able to pass more arguments easily such as below + # shell=['/bin/bash', '-O', 'extglob'] + if isinstance(executable, list): + del args[0] + args = executable + args + executable = args[0] + else: + args[0] = executable + + if executable is None: + executable = args[0] + + sys.audit("subprocess.Popen", executable, args, cwd, env) + + if (_USE_POSIX_SPAWN + and os.path.dirname(executable) + and preexec_fn is None + and not close_fds + and not pass_fds + and cwd is None + and (p2cread == -1 or p2cread > 2) + and (c2pwrite == -1 or c2pwrite > 2) + and (errwrite == -1 or errwrite > 2) + and not start_new_session + and gid is None + and gids is None + and uid is None + and umask < 0): + self._posix_spawn(args, executable, env, restore_signals, + p2cread, p2cwrite, + c2pread, c2pwrite, + errread, errwrite) + return + + orig_executable = executable + + # For transferring possible exec failure from child to parent. + # Data format: "exception name:hex errno:description" + # Pickle is not used; it is complex and involves memory allocation. + errpipe_read, errpipe_write = os.pipe() + # errpipe_write must not be in the standard io 0, 1, or 2 fd range. + low_fds_to_close = [] + while errpipe_write < 3: + low_fds_to_close.append(errpipe_write) + errpipe_write = os.dup(errpipe_write) + for low_fd in low_fds_to_close: + os.close(low_fd) + try: + try: + # We must avoid complex work that could involve + # malloc or free in the child process to avoid + # potential deadlocks, thus we do all this here. + # and pass it to fork_exec() + + if env is not None: + env_list = [] + for k, v in env.items(): + k = os.fsencode(k) + if b'=' in k: + raise ValueError("illegal environment variable name") + env_list.append(k + b'=' + os.fsencode(v)) + else: + env_list = None # Use execv instead of execve. + executable = os.fsencode(executable) + if os.path.dirname(executable): + executable_list = (executable,) + else: + # This matches the behavior of os._execvpe(). + executable_list = tuple( + os.path.join(os.fsencode(dir), executable) + for dir in os.get_exec_path(env)) + fds_to_keep = set(pass_fds) + fds_to_keep.add(errpipe_write) + + self.pid = _posixsubprocess.fork_exec( + args, executable_list, + close_fds, tuple(sorted(map(int, fds_to_keep))), + cwd, env_list, + p2cread, p2cwrite, c2pread, c2pwrite, + errread, errwrite, + errpipe_read, errpipe_write, + restore_signals, start_new_session, + gid, gids, uid, umask, + preexec_fn) + self._child_created = True + finally: + # be sure the FD is closed no matter what + os.close(errpipe_write) + + self._close_pipe_fds(p2cread, p2cwrite, + c2pread, c2pwrite, + errread, errwrite) + + # Wait for exec to fail or succeed; possibly raising an + # exception (limited in size) + errpipe_data = bytearray() + while True: + part = os.read(errpipe_read, 50000) + errpipe_data += part + if not part or len(errpipe_data) > 50000: + break + finally: + # be sure the FD is closed no matter what + os.close(errpipe_read) + + if errpipe_data: + try: + pid, sts = os.waitpid(self.pid, 0) + if pid == self.pid: + self._handle_exitstatus(sts) + else: + self.returncode = sys.maxsize + except ChildProcessError: + pass + + try: + exception_name, hex_errno, err_msg = ( + errpipe_data.split(b':', 2)) + # The encoding here should match the encoding + # written in by the subprocess implementations + # like _posixsubprocess + err_msg = err_msg.decode() + except ValueError: + exception_name = b'SubprocessError' + hex_errno = b'0' + err_msg = 'Bad exception data from child: {!r}'.format( + bytes(errpipe_data)) + child_exception_type = getattr( + builtins, exception_name.decode('ascii'), + SubprocessError) + if issubclass(child_exception_type, OSError) and hex_errno: + errno_num = int(hex_errno, 16) + child_exec_never_called = (err_msg == "noexec") + if child_exec_never_called: + err_msg = "" + # The error must be from chdir(cwd). + err_filename = cwd + else: + err_filename = orig_executable + if errno_num != 0: + err_msg = os.strerror(errno_num) + raise child_exception_type(errno_num, err_msg, err_filename) + raise child_exception_type(err_msg) + +class pyshell(object): + # Originally I was using functions for each command before I started the caller parser. + # Leaving thise import in for now just as reference. If we decide later we don't want them then they will be removed + # Further more the modules for those are not going to be public, but I haven't destroyed them yet + #from .utils.coreutils import echo, ls, grep, cat + def __init__( self, + input=None, capture_output=False, check=False, + logfile=None, timeout=None, alias: dict=None, **kwargs): + #from .utils.util_linux import mkfs + """Subprocess as an object, for Linux. + + Initialize with certain options and use them through the life of your object. + Most of these are exactly the same you're use too with subprocess.run. + Outlined below are new features + + Arguments: + logfile: logfile='/tmp/pyshell.log' expects an unopen file. We will open for you. + aliasing: You can alias commands like so sh = pyshell(alias={'ls': ['ls', '-lah', '--color']}) + + anytime you run sh.ls() you will actually run 'ls -lah --color'. This works for all commands as it does it by name + + input: can be either str or bytes. It will figure it out and switch text for you. + shell: if you specify True it will use '/bin/bash'. You can also pass the shell instead of True, it's fine. shell='/bin/dash' + + The run function can override things that are defined in the object. If you just need one off command to work differently the use it. + + Example:: + + dash = pyshell(alias = {'ls': ['ls', '-lah', '--color']}, shell='/bin/dash') + dash.echo("hello this should be working") + dash.run(["lsblk"], shell=False) + """ + # These are not passed directly to Popen + self.input = input + self.capture_output = capture_output + self.check = check + self.logfile = logfile + self.timeout = timeout + # Arguments that will be passed to Popen + self.kwargs = kwargs + self.list = [] + + # Define all the available shell commands + # Might end up removing some of these in the future as they aren't used + # But for now they aren't causing any harm + (self.programs_tuple, + self.dot_file_tuple, + self.dash_file_tuple, + self.underscore_file_tuple, + self.hybrid_file_tuple) = self.Parse_Path_Programs() + + # If alias was defined and is not a dict throw an error + if alias is not None: + if not isinstance(alias, dict): + raise TypeError(f"expected dict but got {type(alias).__name__} instead") + self.alias = alias + + # You can call run directly and override any thing that you've initialized with + def run(self, *popenargs, + input=None, capture_output=False, check=False, + logfile=None, timeout=None, **kwargs): + + """Run command with arguments and return a CompletedProcess instance. + + The returned instance will have attributes args, returncode, stdout and + stderr. By default, stdout and stderr are not captured, and those attributes + will be None. Pass stdout=PIPE and/or stderr=PIPE in order to capture them. + + If check is True and the exit code was non-zero, it raises a + CalledProcessError. The CalledProcessError object will have the return code + in the returncode attribute, and output & stderr attributes if those streams + were captured. + + If timeout is given, and the process takes too long, a TimeoutExpired + exception will be raised. + + There is an optional argument "input", allowing you to + pass bytes or a string to the subprocess's stdin. If you use this argument + you may not also use the Popen constructor's "stdin" argument, as + it will be used internally. + + pyshell run has a couple of customizations over the original run. + + 1. pyshell.run will check if you pass a string or bytes for input= + you do not need to specify text=True or false + (unless you are not sending input and want the output in text/bytes specifically) + + 2. When specifying shell=True it will use '/bin/bash' by default instead of sh. + You can change this without specifying executable, just pass shell='/bin/sh' + Additionally you can pass a list to shell if you want more options. + shell=['/bin/bash', '-O', 'extglob'] if you want to add extglob + + 3. When using shell= you do not need to escape characters. + What you have in quotes will be passed exact and the shell will expand. + + The other arguments are the same as for the Popen constructor. + """ + + if input is None: + input = self.input + if input is DEFAULT: + input = None + + if capture_output is False: + capture_output = self.capture_output + if capture_output is DEFAULT: + capture_output = False + + if check is False: + check = self.check + if check is DEFAULT: + check = False + + if logfile is None: + logfile = self.logfile + if logfile is DEFAULT: + logfile = None + + if timeout is None: + timeout = self.timeout + if timeout is DEFAULT: + timeout = None + + if kwargs.get('shell') is None: + kwargs['shell'] = self.kwargs.get('shell') + + #if kwargs.get('shell') is None: + # Update all arguments with the initialized choices. + for Key, Value in self.kwargs.items(): + # Except for any choices explicitly Defaulted + if kwargs.get(Key) != DEFAULT: + # Make sure we Don't add our aliases. Popen can't use them + if 'alias' not in Key: + if Key != 'shell': + kwargs[Key] = Value + + # Start real argument handling + if input is not None: + if kwargs.get('stdin') is not None: + raise ValueError('stdin and input arguments may not both be used.') + kwargs['stdin'] = PIPE + + if capture_output: + if logfile is not None: + raise ValueError('logfile will not work with capture_output') + if kwargs.get('stdout') is not None or kwargs.get('stderr') is not None: + raise ValueError('stdout and stderr arguments may not be used ' + 'with capture_output.') + kwargs['stdout'] = PIPE + kwargs['stderr'] = PIPE + + if input: + if isinstance(input, str): + kwargs['text'] = True + if isinstance(input, bytes): + kwargs['text'] = False + + if not capture_output: + if logfile: + # Open our file. This makes it easier on the user. Just give us a filename + logfile = open(logfile, 'a') + kwargs['stdout'] = logfile + kwargs['stderr'] = STDOUT + + # before we pass kwargs we need to remove anything remaining which was defaulted. + for Key, Value in kwargs.copy().items(): + if kwargs.get(Key) == DEFAULT: + del kwargs[Key] + + if kwargs.get('shell') is not None: + if kwargs.get('shell') is True: + kwargs['executable'] = '/bin/bash' + kwargs['shell'] = True + elif kwargs.get('shell') is False: + kwargs['shell'] = None + kwargs['executable'] = None + else: + kwargs['executable'] = kwargs.get('shell') + kwargs['shell'] = True + + # We need to make sure our strings are exactly how we want them for the shell + # All of these different ways you can do this simple command will convert it exactly. + # It is repr as well so if you send \n, the shell will get \n. You don't have to escape + # dash = pyshell(shell='/bin/dash') + # + # dash.echo('$0') ## dash.run(["echo", "$0"]) ## dash.run("echo", "$0") ## dash.run("echo $0") + # Final output ['echo $0'] + + rcommand = [] + if len(popenargs) == 1: + popenargs = popenargs[0] + + if isinstance(popenargs, str): + popenargs = (popenargs,) + + for arg in popenargs: + rcommand.append(repr(arg).strip('\'')) + popenargs = [' '.join((arg) for arg in rcommand)] + + try: + # Using our patched Popen for some special goodies. + with pyshellPopen(*popenargs, **kwargs) as process: + try: + stdout, stderr = process.communicate(input, timeout=timeout) + except TimeoutExpired as exc: + process.kill() + if _mswindows: + # Windows accumulates the output in a single blocking + # read() call run on child threads, with the timeout + # being done in a join() on those threads. communicate() + # _after_ kill() is required to collect that and add it + # to the exception. + exc.stdout, exc.stderr = process.communicate() + else: + # POSIX _communicate already populated the output so + # far into the TimeoutExpired exception. + process.wait() + raise + except: # Including KeyboardInterrupt, communicate handled that. + process.kill() + # We don't call process.wait() as .__exit__ does that for us. + raise + retcode = process.poll() + if check and retcode: + raise CalledProcessError(retcode, process.args, + output=stdout, stderr=stderr) + self.process = CompletedProcess(process.args, retcode, stdout, stderr) + return self.process + # Wrap everything in a try finally so we make sure we close our file if it exists. + finally: + if logfile: + logfile.close() + + def setAlias(self, command: str, alias: list): + """Sets a command alias + + Arguments: + command: The command you want to alias. Such as 'echo' or 'mkfs.ext4' + alias: a list containing your alias ['echo', '-e'] + """ + # If our alias is not a dict we assume it's none and then create a bare one. + if not isinstance(self.alias, dict): + self.alias = {} + # If the alias is not in a list format then we will raise an exception + if not isinstance(alias, list): + raise TypeError(f"expected list but got {type(alias).__name__} instead") + self.alias.update({command:alias}) + + def __call__(self, *args, + input=None, capture_output=False, check=False, + logfile=None, timeout=None, alias: dict=None, **kwargs): + + name, args = self.__parse_attr_commands(self.list, *args) + + self.list.clear() + if kwargs.get('shell') is None and self.kwargs.get('shell') is None: + if shutil.which(name) is not None: + self._run_wrapper( name, *args, + input=input, capture_output=capture_output, check=check, + logfile=logfile, timeout=timeout, **kwargs) + else: + raise CommandNotFound(f'command {name} does not exist') + else: + self._run_wrapper( name, *args, + input=input, capture_output=capture_output, check=check, + logfile=logfile, timeout=timeout, **kwargs) + def __getattr__(self, attr: str): + + # We need to do some check against what was called. So we get our call. + call = getouterframes(currentframe())[1].code_context[0] + try: + # Now we're going to try to index it at a (). If this fails due to ValueError + # Then it means the command wasn't called. Ex: pyshell.mkfs.ext4 instead of pyshell.mkfs.ext4(). + # We HAVE to catch and clear the list if this happens Or else commands could build in the store and + # You might call something later you don't really want to. + call = call[:call.index('(')].split('.') + self.list.append(attr) + return self + except ValueError: + self.list.clear() + return self + + def _run_wrapper(self, name, *args, + input=None, capture_output=False, check=False, + logfile=None, timeout=None, **kwargs): + # DEV-REMOVE echo + #commands = ['echo', name] + commands = [name] + # Check kwargs to see if an alias was set + # If it was set our commands to that + if self.alias is not None: + if self.alias.get(name): + commands = self.alias.get(name) + + # Now that possible aliases are set, we can append our arguments + for arg in args: + commands.append(arg) + + self.run(commands, + input=input, capture_output=capture_output, check=check, + logfile=logfile, timeout=timeout, **kwargs) + + return self.process + + @staticmethod + def Parse_Path_Programs(): + # Might end up removing some of these in the future as they aren't used + # But for now they aren't causing any harm + # Get Users Path + env = os.environ.get('PATH').split(':') + # Make a set to store Items since it's unique + files_set = set() + # Iterate through the environment globing up the whole town + for path in env: + for file in Path(path).glob('*'): + if file.is_file(): + files_set.add(file.name) + # Convert the set to a list + files = list(files_set) + files.sort() + # Make a new list because we need to run each program + # Through which to make sure they're executable + programs_list = [] + for path in files: + prog = shutil.which(path) + if prog is not None: + prog = Path(prog) + programs_list.append(prog) + programs_list = tuple(programs_list) + + programs_tuple = [] + for path in programs_list: + programs_tuple.append(path.name) + programs_tuple = tuple(programs_tuple) + + dot_file_list = [] + # Make a list of dot.files + for path in programs_list: + if '.' in path.name: + dot_file_list.append(path.name) + dot_file_tuple = tuple(dot_file_list) + + underscore_file_list = [] + # Make a list of underscore_files + for path in programs_list: + if '_' in path.name: + if '-' not in path.name: + underscore_file_list.append(path.name) + underscore_file_tuple = tuple(underscore_file_list) + + dash_file_list = [] + # Make a list of dash-files + for path in programs_list: + if '-' in path.name: + if '_' not in path.name: + dash_file_list.append(path.name) + dash_file_tuple = tuple(dash_file_list) + + hybrid_file_list = [] + # Make a hybrid_file-list + for path in programs_list: + if '-' in path.name: + if '_' in path.name: + hybrid_file_list.append(path.name) + hybrid_file_tuple = tuple(hybrid_file_list) + + return programs_tuple, dot_file_tuple, dash_file_tuple, underscore_file_tuple, hybrid_file_tuple + + def __parse_attr_commands(self, command_list: list, *args): + + # If our list is only one then we can just drop and use that as a command + if len(command_list) >1: + + convert_list = [] + for command in command_list: + convert_list.append(command.replace('_','-')) + + # Make a copy of our list and reverse it + command_list = convert_list + reverse_list = command_list.copy() + reverse_list.reverse() + + name = '' + # Iterate through and build our first name + # This will be all attributes combined + for command in command_list: + name = name+'.'+command + name = name.lstrip('.') + num = len(reverse_list) + # Iterate through the reverse list stripping our command down 1 by 1 + # And then checking it against the programs tuple + for command in reverse_list: + num = num -1 + # All this replacing was here before I did a full convert list. It doesn't hurt anything so I'll keep it in + # Possible that it comes in handy later if we don't want full conversion, and only want conversion on matched programs + # Maybe make a switch to disable argument conversion? - is more common in linux commands anyway + name = name.rstrip(command.replace('_','-')).rstrip('.').replace('_','-') + if name in self.programs_tuple: + break + + # We kept track of our place in the list so that we can use the rest of our list as arguments + for number in range(0, num): + del command_list[0] + return name, tuple(command_list)+tuple(args) + + else: + # Our list is only 1 or zero. If it's one easy just assume that's our command + if command_list: + name = command_list.pop(0).replace('_','-') + + return name, tuple(command_list)+tuple(args) + else: + # empty list means pyshell was called directly + # We should see if there are any arguments and handle them + args = list(args) + if args: + # We pop the first arg as the name so pyshell('echo', 'hello'), echo becomes our name + name = args.pop(0) + return name, tuple(command_list)+tuple(args) + else: + raise PyshellError(f"No arguments were passed") + +class PyshellError(Exception): pass + +class CommandNotFound(OSError): pass + + diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..85c478a --- /dev/null +++ b/setup.py @@ -0,0 +1,50 @@ +# Always prefer setuptools over distutils +from setuptools import setup, find_packages +from pathlib import Path +import pyshell +# Define the directory that setup.py is in +here = Path(__file__).parent.resolve() + +# Get the long description from the README file +long_description = (here / 'README.md').read_text(encoding='utf-8') + +# Arguments marked as "Required" below must be included for upload to PyPI. +# Fields marked as "Optional" may be commented out. + +setup( + name=pyshell.__title__, # Required + version=pyshell.__version__, # Required + description='A Linux subprocess module.', # Optional + long_description=long_description, # Optional + long_description_content_type='text/markdown', # Optional (see note above) + url='https://github.com/volitank/pyshell', # Optional + author=pyshell.__author__, # Optional + author_email='blake@volitank.com', # Optional + classifiers=[ # Optional + # List of classifiers https://gist.github.com/nazrulworld/3800c84e28dc464b2b30cec8bc1287fc + 'Development Status :: 1 - Planning', + 'Environment :: Console', + 'Intended Audience :: System Administrators', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', + 'Natural Language :: English', + 'Operating System :: POSIX :: Linux', + 'Topic :: System :: Operating System Kernels :: Linux', + 'Topic :: System :: Systems Administration', + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3 :: Only', + ], + + keywords='python, shell, subprocess', # Optional + packages=['pyshell'], # Required + python_requires='>=3.6, <4', + + project_urls={ # Optional + 'Documentation': 'https://volitank.com/pyshell', + 'Source': 'https://github.com/volitank/pyshell', + }, +) \ No newline at end of file