diff --git a/README.md b/README.md index ecfa2dd..62be5cf 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # PyBash -Streamline bash-command execution from python with an easy-to-use syntax. It combines the simplicity of writing bash scripts with the flexibility of python. Under the hood, any line or variable assignment starting with `>` or surrounded by parentheses is transformed to python `subprocess` calls and then injected into `sys.meta_path` as an import hook. All possible thanks to the wonderful [ideas](https://github.com/aroberge/ideas) project! +Streamline bash-command execution from python with a new syntax. It combines the simplicity of writing bash scripts with the flexibility of python. Under the hood, any line or variable assignment starting with `>` or surrounded by parentheses is transformed to python `subprocess` calls and then injected into `sys.meta_path` as an import hook. All possible thanks to the wonderful [ideas](https://github.com/aroberge/ideas) project! For security and performance reasons, PyBash will NOT execute as shell, unless explicitly specified with a `$` instead of a single `>` before the command. While running commands as shell can be convenient, it can also spawn security risks and if you're not too careful. If you're curious about the transformations, look at the [unit tests](test_pybash.py) for some quick examples. @@ -86,30 +86,50 @@ WHENDY WORLD $ls .github/* ``` -### 10. Interpolation -Denoted by {{variable_or_function_call_here}}. Only static interpolation is supported currently so no quotes, spaces or expressions within the {{}} or in the string being injected. +### 10. Static interpolation +Denoted by {{variable_or_function_call_here}}. For static interpolation, no quotes, spaces or expressions within the {{}} or in the string being injected. ```python -# GOOD +## GOOD command = "status" def get_option(command): return "-s" if command == "status" else "-v" >git {{command}} {{get_option(command)}} display_type = "labels" ->k get pods --show-{{display_type}}=true +>kubectl get pods --show-{{display_type}}=true -# BAD +## BAD option = "-s -v" >git status {{option}} options = ['-s', '-v'] >git status {{" ".join(options)}} +# use dynamic interpolation options = {'version': '-v'} >git status {{options['version']}} ``` +### 11. Dynamic interpolation +Denoted by {{{ any python variable, function call, or expression here }}}. The output of the variable, function call, or the expression must still not include spaces. + +```python +## GOOD + +# git -h +options = {'version': '-v', 'help': '-h'} +>git {{{options['h']}}} + +# kubectl get pods --show-labels -n coffee +namespace = "coffee" +>kubectl get pods {{{"--" + "-".join(['show', 'labels'])}}} -n {{{namespace}}} + +## BAD +option = "-s -v" +>git status {{{ option }}} +``` + #### Also works inside methods! ```python # PYBASH DEMO # diff --git a/demo.py b/demo.py index d1f8634..da93968 100644 --- a/demo.py +++ b/demo.py @@ -1,6 +1,13 @@ # PYBASH DEMO # def run(): - # command interpolation + # dynamic interpolation + options = {'version': '-v', 'help': '-h'} + >git {{{options['h']}}} + + namespace = "coffee" + >kubectl get pods {{{"--" + "-".join(['show', 'labels'])}}} --namespace {{{namespace}}} + + # static interpolation git_command = "status" option = "-v" diff --git a/pybash.py b/pybash.py index fa75d16..fc9d593 100644 --- a/pybash.py +++ b/pybash.py @@ -5,14 +5,12 @@ from ideas import import_hook -@staticmethod def source_init(): """Adds subprocess import""" import_subprocess = "import subprocess" return import_subprocess -@staticmethod def add_hook(**_kwargs): """Creates and automatically adds the import hook in sys.meta_path""" hook = import_hook.create_hook( @@ -40,10 +38,39 @@ def parse(self): def transform(self) -> token_utils.Token: raise NotImplementedError + def interpolate(self) -> str: + self.token.string = Processor.dynamic_interpolate(self.token_line, self.token.string) + self.token.string = Processor.static_interpolate(self.token.string) + @staticmethod - def interpolate(string: str) -> str: - """Process {{ interpolations }} and substitute. - Interpolations are denotated by a {{ }} with a variable or a function call inside. + def dynamic_interpolate(token_string: str, parsed_command: str) -> str: + """Process {{{ dynamic interpolations }}} and substitute. + Dynamic interpolations are denotated by a {{{ }}} with any expression inside. + Substitution in the parsed command string happens relative to the order of the interpolations in the original command string. + + Args: + token_string (str): Original command string + parsed_command (str): Parsed command string + + Returns: + str: Interpolated parsed command string + """ + pattern = r'{{{(.+?)}}}' + subs = re.findall(pattern, token_string) + + if not subs: + return parsed_command + + for sub in subs: + parsed_command = re.sub(pattern, '" + ' + sub + ' + "', parsed_command, 1) + + return parsed_command + + @staticmethod + def static_interpolate(string: str) -> str: + """Process {{ static interpolations }} and substitute. + Static interpolations are denotated by a {{ }} with a variable or a function call inside. + Substitution happens directly on the parsed command string. Therefore, certain characters cannot be interpolated as they get parsed out before substitution. Args: string (str): String to interpolate @@ -100,8 +127,6 @@ def transform(self): self.token.string = ' '.join(self.parsed_line[: self.start_index]) self.token.string += Commander.build_subprocess_list_cmd("check_output", self.command) + '\n' - return self.token - class Wrapped(Processor): # print(>cat test.txt) @@ -143,9 +168,10 @@ def transform_source(source, **_kwargs): # matches exact token token_match = [tokenizer for match, tokenizer in Transformer.tokenizers.items() if token == match] if token_match: - token = token_match[0](token).transform() - token.string = Processor.interpolate(token.string) - new_tokens.append(token) + parser = token_match[0](token) + parser.transform() + parser.interpolate() + new_tokens.append(parser.token) continue # matches anywhere in line @@ -153,9 +179,10 @@ def transform_source(source, **_kwargs): tokenizer for match, tokenizer in Transformer.greedy_tokenizers.items() if match in token.line ] if greedy_match: - token = greedy_match[0](token).transform() - token.string = Processor.interpolate(token.string) - new_tokens.append(token) + parser = greedy_match[0](token) + parser.transform() + parser.interpolate() + new_tokens.append(parser.token) continue # no match diff --git a/pyproject.toml b/pyproject.toml index 90bd685..e4790e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "PyBash" -version = "0.2.2" +version = "0.2.3" description = ">execute bash commands from python easily" authors = ["Jay"] readme = "README.md" diff --git a/test_pybash.py b/test_pybash.py index 0546532..4621b50 100644 --- a/test_pybash.py +++ b/test_pybash.py @@ -97,7 +97,7 @@ def test_shell_commands(): assert run_bash("$ls .github/*") == 'subprocess.run("ls .github/*", shell=True)\n' -def test_interpolate(): +def test_static_interpolate(): assert run_bash(">git {{command}} {{option}}") == 'subprocess.run(["git","" + command + "","" + option + ""])\n' assert ( run_bash(">git {{command}} {{process(option)}}") @@ -109,6 +109,14 @@ def test_interpolate(): ) +def test_dynamic_interpolate(): + assert ( + run_bash(">kubectl get pods {{{\"--\" + \"-\".join(['show', 'labels'])}}} -n {{{ namespace }}}") + == 'subprocess.run(["kubectl","get","pods","" + "--" + "-".join([\'show\', \'labels\']) + "","-n","" + namespace + ""])\n' + ) + assert run_bash(">git {{{options['h']}}}") == 'subprocess.run(["git","" + options[\'h\'] + ""])\n' + + def test_invalid_interpolate(): with pytest.raises(pybash.InvalidInterpolation): assert run_bash(">git {{command}} {{ option }}")