├── clamshell ├── __init__.py ├── types.py ├── defaults.py ├── key_bindings.py ├── meta_functions.py ├── __main__.py ├── shell_utils.py └── shell.py ├── requirements.txt ├── pyproject.toml ├── LICENSE ├── .gitignore └── README.md /clamshell/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /clamshell/types.py: -------------------------------------------------------------------------------- 1 | class FileList(list): 2 | pass 3 | 4 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | send2trash 2 | prompt-toolkit 3 | pygments 4 | rich 5 | -------------------------------------------------------------------------------- /clamshell/defaults.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def get_prompt() -> str: 5 | """ 6 | Returns text to display in front of initial prompt 7 | """ 8 | cwd = os.getcwd() 9 | return f" 🐚 {cwd} $ " 10 | 11 | 12 | def get_continuation_prompt() -> str: 13 | """ 14 | Returns text to display on continuation lines after initial prompt 15 | """ 16 | return " ... " 17 | -------------------------------------------------------------------------------- /clamshell/key_bindings.py: -------------------------------------------------------------------------------- 1 | from prompt_toolkit.key_binding import KeyBindings 2 | from prompt_toolkit.keys import Keys 3 | 4 | key_bindings: KeyBindings = KeyBindings() 5 | 6 | 7 | @key_bindings.add(Keys.Tab) 8 | def _(event) -> None: 9 | before_cursor = event.app.current_buffer.document.current_line_before_cursor 10 | event.app.current_buffer.insert_text(" " * (4 - len(before_cursor) % 4)) 11 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["flit_core>=3.2"] 3 | build-backend = "flit_core.buildapi" 4 | 5 | [project] 6 | name = "clamshell" 7 | version = "0.4" 8 | description = "An experimental shell for the modern age!" 9 | readme = "README.md" 10 | requires-python = ">=3.6" 11 | dependencies = [ 12 | "send2trash", 13 | "prompt-toolkit", 14 | "pygments", 15 | "rich", 16 | ] 17 | 18 | [project.scripts] 19 | clamshell = "clamshell.__main__:run_clam" 20 | 21 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Ben 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /clamshell/meta_functions.py: -------------------------------------------------------------------------------- 1 | from typing import Callable, Type 2 | 3 | 4 | def try_else_none(function: Callable) -> Callable: 5 | """ 6 | Wraps the given function in a try/except 7 | Returning None on failure 8 | """ 9 | 10 | def wrapped_function(*args, **kwargs): 11 | try: 12 | return function(*args, **kwargs) 13 | except: 14 | return None 15 | 16 | return wrapped_function 17 | 18 | 19 | def try_else_empty_list(function: Callable) -> Callable: 20 | """ 21 | Wraps the given function in a try/except 22 | Returning empty list ([]) on failure 23 | """ 24 | 25 | def wrapped_function(*args, **kwargs): 26 | try: 27 | return function(*args, **kwargs) 28 | except: 29 | return [] 30 | 31 | return wrapped_function 32 | 33 | 34 | def coerce(value: Type, default: Type) -> Type: 35 | """ 36 | Returns the first argument, unless None, 37 | in which case the second argument is returned 38 | """ 39 | if value: 40 | return value 41 | return default 42 | 43 | 44 | def capture_and_return_exception(function: Type) -> Type: 45 | """ 46 | Wraps the given function to return exception as string 47 | on failure 48 | """ 49 | def new_function(*args, **kwargs): 50 | try: 51 | return function(*args, **kwargs) 52 | except Exception as exception: 53 | return f"[red bold] ! >> {str(repr(exception))}[/red bold]" 54 | return new_function 55 | -------------------------------------------------------------------------------- /clamshell/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import environ 3 | 4 | from rich import print 5 | 6 | from .shell import ClamShell 7 | from .shell_utils import ( 8 | files, 9 | delete, 10 | search, 11 | copy, 12 | move, 13 | goto, 14 | read, 15 | make_file, 16 | make_directory, 17 | run, 18 | clear, 19 | pipe, 20 | splitter, 21 | ) 22 | 23 | 24 | clamshell = ClamShell( 25 | super_commands=["files", "exit", "clear"], 26 | aliases={"_": "_"}, 27 | shell_globals=globals(), 28 | shell_locals=locals(), 29 | ) 30 | 31 | try: 32 | rc_path, rc_file = clamshell.rc_file().rsplit(splitter, 1) 33 | sys.path.append(rc_path) 34 | from clamrc import * 35 | 36 | if "super_commands" in locals(): 37 | clamshell.super_commands += super_commands 38 | del super_commands 39 | if "aliases" in locals(): 40 | clamshell.aliases.update(aliases) 41 | del aliases 42 | if "get_prompt" in locals(): 43 | clamshell.get_prompt = get_prompt 44 | del get_prompt 45 | if "get_continuation_prompt" in locals(): 46 | clamshell.get_continuation_prompt = get_continuation_prompt 47 | del get_continuation_prompt 48 | if "environment_variables" in locals(): 49 | for k, v in environment_variables.items(): 50 | environ[k] = v 51 | del environment_variables 52 | del splitter 53 | del sys 54 | del rc_path 55 | del rc_file 56 | except Exception as e: 57 | print( 58 | "[red]Error loading/running clamrc!\n" 59 | f"{e}\n" 60 | "(likely error in .config/clamrc.py script)[/red]" 61 | ) 62 | 63 | def run_clam(): 64 | while True: 65 | try: 66 | clamshell.repl() 67 | except KeyboardInterrupt: 68 | pass 69 | 70 | if __name__ == '__main__': 71 | run_clam() 72 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/#use-with-ide 110 | .pdm.toml 111 | 112 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 113 | __pypackages__/ 114 | 115 | # Celery stuff 116 | celerybeat-schedule 117 | celerybeat.pid 118 | 119 | # SageMath parsed files 120 | *.sage.py 121 | 122 | # Environments 123 | .env 124 | .venv 125 | env/ 126 | venv/ 127 | ENV/ 128 | env.bak/ 129 | venv.bak/ 130 | 131 | # Spyder project settings 132 | .spyderproject 133 | .spyproject 134 | 135 | # Rope project settings 136 | .ropeproject 137 | 138 | # mkdocs documentation 139 | /site 140 | 141 | # mypy 142 | .mypy_cache/ 143 | .dmypy.json 144 | dmypy.json 145 | 146 | # Pyre type checker 147 | .pyre/ 148 | 149 | # pytype static type analyzer 150 | .pytype/ 151 | 152 | # Cython debug symbols 153 | cython_debug/ 154 | 155 | # PyCharm 156 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 157 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 158 | # and can be added to the global gitignore or merged into this file. For a more nuclear 159 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 160 | #.idea/ 161 | -------------------------------------------------------------------------------- /clamshell/shell_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import subprocess 4 | from subprocess import run 5 | import time 6 | import shutil 7 | import glob 8 | from typing import List 9 | 10 | from send2trash import send2trash 11 | 12 | from . import meta_functions 13 | from .types import FileList 14 | 15 | 16 | def is_windows() -> bool: 17 | """ 18 | Returns true if os is windows (otherwise false) 19 | """ 20 | return os.name == "nt" 21 | 22 | 23 | def get_splitter() -> str: 24 | """ 25 | Returns character used to split path on operating system 26 | """ 27 | if is_windows(): 28 | return "\\" 29 | return "/" 30 | 31 | 32 | def get_home(): 33 | """ 34 | Returns users home directory 35 | """ 36 | if is_windows(): 37 | return os.environ["USERPROFILE"] 38 | return os.environ["HOME"] 39 | 40 | 41 | def get_clear_command(): 42 | """ 43 | Returns command called to clear screen (cls/clear) 44 | """ 45 | if is_windows(): 46 | return "cls" 47 | return "clear" 48 | 49 | 50 | @meta_functions.try_else_none 51 | def get_created_datetime(file): 52 | created = os.path.getctime(file) 53 | created = time.ctime(created) 54 | return created 55 | 56 | 57 | @meta_functions.try_else_none 58 | def get_modified_datetime(file): 59 | modified = os.path.getmtime(file) 60 | modified = time.ctime(modified) 61 | return modified 62 | 63 | 64 | @meta_functions.try_else_none 65 | def get_type(file): 66 | if os.path.isfile(file): 67 | return "file" 68 | else: 69 | return "directory" 70 | 71 | @meta_functions.try_else_none 72 | def file_metadata(path): 73 | return { 74 | "name": path, 75 | "path": os.path.abspath(path), 76 | "created": get_created_datetime(path), 77 | "modified": get_modified_datetime(path), 78 | "type": get_type(path), 79 | } 80 | 81 | def files(path=None, hidden=False, recursive=0): 82 | file_info = [file_metadata(i) for i in os.listdir(path)] 83 | file_info = [i for i in file_info if i is not None] 84 | if not hidden: 85 | file_info = [i for i in file_info if not i['name'].startswith('.')] 86 | file_info = FileList(file_info) 87 | if recursive == 0: 88 | return file_info 89 | for folder in [i['path'] for i in file_info if i['type'] == 'folder']: 90 | try: 91 | file_info += FileList(files(folder, recursive-1)) 92 | except: 93 | pass 94 | return file_info 95 | 96 | 97 | def clear(): 98 | os.system(clear_command) 99 | 100 | 101 | def goto(path="."): 102 | old_location = os.getcwd() 103 | if isinstance(path, dict): 104 | path = path["path"] 105 | try: 106 | os.chdir(path) 107 | except: 108 | match = [i for i in directory_map if i.endswith(path)] 109 | assert len(match) > 0, "No matching directory found" 110 | os.chdir(match[0]) 111 | # now let's add the current directory to the path 112 | # and remove the previous one 113 | new_location = os.getcwd() 114 | if new_location not in sys.path: 115 | sys.path.append(new_location) 116 | if old_location not in original_path and old_location in sys.path: 117 | sys.path.remove(old_location) 118 | 119 | 120 | def delete(path: str): 121 | if isinstance(path, dict): 122 | path = path["path"] 123 | send2trash(path) 124 | return f"[green]{path} sent to recycle bin[/green]" 125 | 126 | 127 | def move(source: str, destination: str): 128 | if isinstance(source, dict): 129 | source = source["path"] 130 | shutil.move(source, destination) 131 | return f"[green]{source} moved to {destination}[/green]" 132 | 133 | 134 | def copy(source: str, destination: str): 135 | if isinstance(source, dict): 136 | source = source["path"] 137 | shutil.copy(source, destination) 138 | return "f[green]{source} copied to {destination}[/green]" 139 | 140 | 141 | def read(source: str): 142 | if isinstance(source, dict): 143 | source = source["path"] 144 | with open(source, "r") as file: 145 | output = file.read() 146 | return output 147 | 148 | 149 | def make_file(path: str): 150 | split_path = path.rsplit(splitter, 1) 151 | if len(split_path) > 1: 152 | os.makedirs(split_path[0], exist_ok=True) 153 | with open(path, "w"): 154 | pass 155 | return f"[green]{path} created[/green]" 156 | 157 | 158 | def make_directory(path: str): 159 | os.makedirs(path, exist_ok=False) 160 | return f"[green]{path} created[/green]" 161 | 162 | 163 | def sandwich_split(string, splitters=[" ", '"', "'"]): 164 | split = splitters[0] 165 | segments = [] 166 | accumulator = "" 167 | for char in string: 168 | # if the char is out current split 169 | # then this accumulator is over 170 | if char == split: 171 | accumulator += char 172 | segments.append(accumulator) 173 | accumulator = "" 174 | # otherwise, if the char is a splitter 175 | # then 176 | elif char in splitters: 177 | split = char 178 | accumulator += char 179 | else: 180 | accumulator += char 181 | segments.append(accumulator) 182 | return segments 183 | 184 | 185 | def break_into_pieces(string): 186 | pieces = sandwich_split(string) 187 | pieces = [i.strip() for i in pieces] 188 | pieces = [i for i in pieces if i != ""] 189 | return pieces 190 | 191 | 192 | @meta_functions.try_else_empty_list 193 | def file_line_matches(search_string: str, file_to_search: dict): 194 | matches = FileList() 195 | with open(file_to_search["path"], "r") as match: 196 | lines = match.readlines() 197 | for i, line in enumerate(lines): 198 | if search_string in line: 199 | matches.append( 200 | { 201 | "name": file_to_search["name"], 202 | "path": file_to_search["path"], 203 | "line_number": i, 204 | "line": line, 205 | } 206 | ) 207 | return matches 208 | 209 | 210 | def search(search_string: str, path: str = None, recursive=False): 211 | files_to_search = files(path, recursive) 212 | files_to_search = [i for i in files_to_search if i["type"] == "file"] 213 | matches = [] 214 | for file_to_search in files_to_search: 215 | matches += file_line_matches(search_string, file_to_search) 216 | return matches 217 | 218 | 219 | def pipe(*args): 220 | result = args[0] 221 | for call in args[1:]: 222 | result = call(result) 223 | return result 224 | 225 | def map_all_dirs(parent: str, current_depth: int = 0, max_depth: int = 2) -> List[str]: 226 | initial_dir = os.getcwd() 227 | os.chdir(parent) 228 | # if we've hit bottom depth, we'll return empty list 229 | if current_depth > max_depth: 230 | return [] 231 | # otherwise, let's add all children to the list 232 | # and then return it 233 | new_dirs: List[str] = [i['path'] for i in files(parent) if i["type"] == "directory"] 234 | # we'll add home if this is the first run 235 | if current_depth == 0: 236 | new_dirs.append(parent) 237 | # lets get rid of windows non-dot "AppData" which will map too many things 238 | if is_windows(): 239 | new_dirs = [i for i in new_dirs if "AppData" not in i] 240 | # then add recursive steps 241 | to_recur: List[str] = new_dirs.copy() 242 | for i in to_recur: 243 | try: 244 | new_dirs += map_all_dirs(i, current_depth + 1, max_depth) 245 | except: 246 | pass 247 | os.chdir(initial_dir) 248 | new_dirs = [i for i in new_dirs if i is not None] 249 | return new_dirs 250 | 251 | 252 | splitter: str = get_splitter() 253 | home: str = get_home() 254 | original_path: str = sys.path.copy() 255 | clear_command: str = get_clear_command() 256 | directory_map: List[str] = map_all_dirs(home) 257 | 258 | 259 | def coerce(value, default): 260 | if value: 261 | return value 262 | return default 263 | -------------------------------------------------------------------------------- /clamshell/shell.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import os 3 | import threading 4 | from collections import defaultdict 5 | from typing import Callable, List, Dict, Type 6 | 7 | from pygments.lexers.python import PythonLexer 8 | from rich.table import Table 9 | from prompt_toolkit.key_binding import KeyBindings 10 | from prompt_toolkit import prompt, PromptSession 11 | from prompt_toolkit.lexers import PygmentsLexer 12 | from prompt_toolkit.auto_suggest import AutoSuggestFromHistory 13 | from prompt_toolkit.history import FileHistory 14 | from rich import print 15 | from prompt_toolkit.output.color_depth import ColorDepth 16 | 17 | from . import meta_functions, defaults, shell_utils 18 | from .key_bindings import key_bindings 19 | from .types import FileList 20 | 21 | 22 | class ClamShell: 23 | """ 24 | Class handles environment, and running of commands given in shell. 25 | """ 26 | 27 | def __init__( 28 | self, 29 | super_commands: list = None, 30 | aliases: Dict[str, str] = None, 31 | get_prompt: callable = defaults.get_prompt, 32 | get_continuation_prompt: callable = defaults.get_continuation_prompt, 33 | shell_globals: dict = None, 34 | shell_locals: dict = None, 35 | ): 36 | self.super_commands: List[str] = meta_functions.coerce(super_commands, []) 37 | self.aliases: Dict[str, str] = meta_functions.coerce(aliases, []) 38 | self.lexer: PygmentsLexer = PygmentsLexer(PythonLexer) 39 | self.globals: dict = meta_functions.coerce(shell_globals, globals()) 40 | self.locals: list = meta_functions.coerce(shell_locals, locals()) 41 | history_file: str = self.history_file() 42 | self.session: PromptSession = PromptSession(history=FileHistory(history_file)) 43 | self.key_bindings: KeyBindings = key_bindings 44 | self.get_prompt: Callable = get_prompt 45 | self.get_continuation_prompt: Callable = get_continuation_prompt 46 | self.command: str = None 47 | self.output: Type = None 48 | 49 | def history_file(self) -> str: 50 | """ 51 | Infers and returns location of history file 52 | """ 53 | home: str = shell_utils.home 54 | splitter: str = shell_utils.splitter 55 | history_file: str = ( 56 | f"{home}{splitter}.config{splitter}clamshell{splitter}history" 57 | ) 58 | if not os.path.exists(history_file): 59 | shell_utils.make_file(history_file) 60 | return history_file 61 | 62 | def rc_file(self) -> str: 63 | """ 64 | Infers and returns location of clamrc file 65 | """ 66 | home: str = shell_utils.home 67 | splitter: str = shell_utils.splitter 68 | rc_file: str = f"{home}{splitter}.config{splitter}clamshell{splitter}clamrc.py" 69 | if not os.path.exists(rc_file): 70 | shell_utils.make_file(rc_file) 71 | return rc_file 72 | 73 | def is_uncompleted(self, command: str) -> bool: 74 | """ 75 | Checks for unclosed brackets, speech marks etc, 76 | and returns bool based on them being present 77 | """ 78 | completion_dict = { 79 | "(": ")", 80 | "{": "}", 81 | "[": "]", 82 | '"': '"', 83 | "'": "'", 84 | } 85 | last: str = None 86 | for char in command: 87 | if last is not None: 88 | if char == completion_dict[last]: 89 | last: str = None 90 | elif char in list(completion_dict.keys()): 91 | last: str = char 92 | completed: bool = last is not None 93 | return completed 94 | 95 | def with_quotes_if_undefined(self, name: str) -> str: 96 | """ 97 | Checks whether name is defined (if not, adds quotes around it) 98 | """ 99 | try: 100 | eval(name, self.globals, self.locals) 101 | return name 102 | except: 103 | return f'"{name}"' 104 | 105 | def flatten_list(self, list_of_lists: List[list]) -> list: 106 | """ 107 | Takes a list of list of values and returns list of values 108 | """ 109 | return [item for sublist in list_of_lists for item in sublist] 110 | 111 | def sandwich_split( 112 | self, string, splitters: List[str] = [" ", '"', "'"] 113 | ) -> List[str]: 114 | """ 115 | Splits items from start to end of splitter, without interception 116 | """ 117 | split: str = splitters[0] 118 | segments: list = [] 119 | accumulator: str = "" 120 | 121 | for char in string: 122 | if char == split: 123 | accumulator += char 124 | segments.append(accumulator) 125 | accumulator: str = "" 126 | elif accumulator == "" and char in splitters: 127 | accumulator += char 128 | split = char 129 | else: 130 | accumulator += char 131 | segments.append(accumulator) 132 | return segments 133 | 134 | def break_into_pieces(self, string: str) -> List[str]: 135 | """ 136 | Breaks a string into individual pieces based on 137 | use of sandwich_split function 138 | """ 139 | pieces: List[str] = self.sandwich_split(string) 140 | pieces = [i.strip() for i in pieces] 141 | pieces = [i for i in pieces if i != ""] 142 | return pieces 143 | 144 | def reform(self, pieces: List[str]) -> str: 145 | """ 146 | Forms individual pieces into a function call based on 147 | clam syntax 148 | """ 149 | remade = f'{pieces[0]}({", ".join(pieces[1:])})' 150 | return remade 151 | 152 | @meta_functions.capture_and_return_exception 153 | def python_exec(self, command: str) -> Type: 154 | """ 155 | Executes as python (attempts evaluation first) 156 | """ 157 | try: 158 | return eval(command, self.globals, self.locals) 159 | except SyntaxError: 160 | return exec(command, self.globals, self.locals) 161 | 162 | def clam_compile(self, command: str) -> str: 163 | """ 164 | Converts a string into python executable based on clam syntax 165 | """ 166 | pieces: List[str] = self.break_into_pieces(command) 167 | pieces = [pieces[0]] + [self.with_quotes_if_undefined(i) for i in pieces[1:]] 168 | reformed: str = self.reform(pieces) 169 | return reformed 170 | 171 | @meta_functions.capture_and_return_exception 172 | def clam_exec(self, command: str) -> Type: 173 | """ 174 | Compiles based on clam syntax then executes as python 175 | """ 176 | reformed: str = self.clam_compile(command) 177 | return self.python_exec(reformed) 178 | 179 | @meta_functions.capture_and_return_exception 180 | def shell_exec(self, command: str) -> str: 181 | for key, value in self.aliases.items(): 182 | if command[: len(key)] == key: 183 | command = value + command[len(key) :] 184 | break 185 | pieces: List[str] = self.break_into_pieces(command) 186 | output: int = subprocess.call(pieces) 187 | output = f"\n[italic]output: {output}[/italic]" 188 | return output 189 | 190 | @meta_functions.capture_and_return_exception 191 | def super_exec(self, command: str) -> Type: 192 | """ 193 | Takes command of function name and executes no argument call 194 | """ 195 | assert len(command.strip().split(" ")) == 1 196 | command += "()" 197 | return self.python_exec(command) 198 | 199 | def print_output(self) -> None: 200 | """ 201 | Custom print of output 202 | """ 203 | if self.output is None: 204 | return 205 | if isinstance(self.output, FileList) and len(self.output) > 0: 206 | table = Table() 207 | for i in self.output[0].keys(): 208 | table.add_column( 209 | i, 210 | style=defaultdict( 211 | lambda: "", {"name": "cyan", "path": "italic", "type": "green"} 212 | )[i], 213 | ) 214 | for i in self.output: 215 | table.add_row(*list(i.values())) 216 | print(table) 217 | else: 218 | print(self.output) 219 | 220 | def compiles_without_errors(self, command: str) -> bool: 221 | """ 222 | Returns true if command string can compile to python without 223 | throwing a SyntaxError, otherwise false 224 | """ 225 | try: 226 | compile(command, "", mode="exec") 227 | return True 228 | except SyntaxError: 229 | return False 230 | 231 | @meta_functions.capture_and_return_exception 232 | def meta_exec(self) -> None: 233 | """ 234 | Execution order for the self.command string, as follows: 235 | - If 'super self.command' will run as so 236 | - Otherwise, will see if it can compile to: 237 | - Python 238 | - Then "clam python" 239 | - (Will run as one of those if it can) 240 | - Will run as a subprocess if those conditions aren't met 241 | - (or if python running throws a name error) 242 | """ 243 | if self.command in self.super_commands: 244 | result: Type = self.super_exec(self.command) 245 | elif self.compiles_without_errors(self.command): 246 | result: Type = self.python_exec(self.command) 247 | elif self.compiles_without_errors(self.clam_compile(self.command)): 248 | result: Type = self.clam_exec(self.command) 249 | else: 250 | result: Type = self.shell_exec(self.command) 251 | if isinstance(result, str) and result.startswith("[red bold] ! >> NameError("): 252 | python_result: Type = result 253 | result: Type = self.shell_exec(self.command) 254 | if isinstance(result, str) and result.startswith( 255 | "[red bold] ! >> FileNotFoundError(" 256 | ): 257 | result = python_result 258 | self.output = result 259 | 260 | def repl(self) -> None: 261 | """ 262 | Central read-evaluate-print loop 263 | """ 264 | self.command = None 265 | self.prompt() 266 | self.meta_exec() 267 | self.print_output() 268 | 269 | def initial_prompt(self) -> None: 270 | """ 271 | Sets first prompt to self.command value 272 | """ 273 | self.command = self.session.prompt( 274 | self.get_prompt(), 275 | lexer=self.lexer, 276 | auto_suggest=AutoSuggestFromHistory(), 277 | key_bindings=self.key_bindings, 278 | color_depth=ColorDepth.ANSI_COLORS_ONLY, 279 | ) 280 | 281 | @meta_functions.try_else_none 282 | def continuation_prompt(self) -> None: 283 | """ 284 | Uses continuation prompt to append to self.command 285 | """ 286 | new_line: str = None 287 | while new_line != "": 288 | new_line = self.session.prompt( 289 | self.get_continuation_prompt(), 290 | lexer=self.lexer, 291 | auto_suggest=AutoSuggestFromHistory(), 292 | key_bindings=self.key_bindings, 293 | color_depth=ColorDepth.ANSI_COLORS_ONLY, 294 | ) 295 | self.command += f"\n{new_line}" 296 | 297 | @meta_functions.try_else_none 298 | def prompt(self) -> None: 299 | """ 300 | Runs inital_prompt and continuation_prompt in own thread 301 | (so that async of prompt doesn't affect anything ran) 302 | """ 303 | th: threading.Thread = threading.Thread(target=meta_functions.try_else_none(self.initial_prompt)) 304 | th.start() 305 | th.join() 306 | if self.command != "" and ( 307 | self.command[-1] == ":" or self.is_uncompleted(self.command) 308 | ): 309 | th: threading.Thread = threading.Thread(target=meta_functions.try_else_none(self.continuation_prompt)) 310 | th.start() 311 | th.join() 312 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clamshell 🦪 2 | 3 | Experimental shell for utopians! 4 | 5 | 6 | ### What is this? 7 | 8 | clamshell is a repl shell for using python as an interactive shell (like bash, fish, zsh etc) 9 | 10 | I love working in the terminal, and use Bash all the time, but there's a bunch of stuff I feel like I wish it has: 11 | 12 | - Autocompletion, syntax highlighting etc 13 | - Actual types that got used (like arrays, numbers, ints) 14 | - Easier syntax (does anyone actually know how to define a function, loop through something, add two numbers without having to look it up every time?) 15 | - Less terse (This is the most subjective, but built in functions like 'cd', 'ls' are designed to be quick to type, but we have autocompletion for that- I'd rather things were more obvious to beginners) 16 | 17 | It seems odd to me that as developers, our daily diver is missing so much of the functionality that we demand from our modern languages. 18 | 19 | I built clamshell as an experiment to think about what an ideal shell utopia might look like. 20 | 21 | It's an experiment, and you can run it on windows, mac or linux. 22 | 23 | ### That sounds like xonsh 24 | 25 | Yes it does! Xonsh is actually a much better idea if you're looking for something to use since it is production ready, has less dependencies and a safer execution pattern [check it out!](https://xon.sh/) 26 | 27 | But, xonsh is based on the idea of using python *with* another existing shell language like bash, I wanted to experiment with what it would be like to actually have a *fully python shell* - where your standard shell commands had types, and where python functions could get ran just like command line arguments/ 28 | 29 | ### Like command line arguments you say? 30 | 31 | Yeah! Let's taken an example. 32 | 33 | Python has a function to change directory already, so why not just load up a default python repl and do stuff like this: 34 | 35 | ```python 36 | os.chdir('Music/great-tunes/') 37 | ``` 38 | 39 | It should work fine, but it seems like a lot more typing than just: 40 | 41 | ```bash 42 | cd Music/great-tunes 43 | ``` 44 | 45 | So clamshell adds an extra layer of interpretation whereby this: 46 | 47 | ```clam 48 | os.chdir Music/great-tunes 49 | ``` 50 | 51 | gets ran as if it has the first command. 52 | 53 | This works for anything you define as well, so say we type up this new function: 54 | 55 | ```python 56 | def say_hello(name='stranger'): 57 | print(f'Hello {name}!') 58 | ``` 59 | 60 | We can run this as usual with: 61 | ```python 62 | say_hello('Joe') 63 | >> "Hello Joe!" 64 | ``` 65 | 66 | But you also have the option of running it with clam syntax: 67 | ```clam 68 | say_hello Joe 69 | >> "Hello Joe!" 70 | ``` 71 | 72 | There's also a concept of "super_commands" (sorry for the terrible name), where a function explicitly set as one, will run when typed in with no arguments (rather than just spitting back the type, as is normal python repl behaviour): 73 | 74 | ```clam 75 | say_hello 76 | >> "Hello stranger!" 77 | ``` 78 | Pretty handy right?! 79 | If something isn't runnable as python, but is a runnable command, then that'll work too: 80 | 81 | ```clam 82 | docker run 83 | >> "doing docker stuff" 84 | ``` 85 | 86 | You can import .py files and modules as you would in a normal repo too: 87 | 88 | ```clam 89 | import pandas as pd 90 | ``` 91 | 92 | And then use that in your interactive shell. 93 | 94 | Bear in mind, this is all experimental, and you probably shouldn't use it in production - again, see the awesome Xonsh if you want to use python in the shell in a more robust setting. 95 | 96 | 97 | ## A quick tour of built in functions 98 | 99 | ### goto: move directories 100 | 101 | Initial use is basically just like 'cd' in bash: 102 | 103 | ```clam 104 | ~/me $_ goto Documents/some_folder 105 | ~/me/Documents/some_folder $_ 106 | ``` 107 | 108 | We move to working in another directory. So far so good! 109 | 110 | Aside from 'goto' being a more beginner friendly name that 'cd', this is pretty much identical behaviour. 111 | 112 | But. . . 113 | 114 | As a nice tweak, clamshell maps out your directories on start up, so if a folder isn't hidden, and is within a certain depth from your home directory, you don't have to type the full path: 115 | 116 | ```clam 117 | ~/me $_ goto cool_album 118 | ~/me/Music/cool_album $_ goto important_docs 119 | ~/me/Documents/important_docs $_ 120 | ``` 121 | 122 | Nice! That's a lot less time we can spend remembering where we saved a folder. 123 | 124 | ### files: get a list of files 125 | 126 | This is the equivalent of 'ls', although it returns a special "file list" which is basically just a list that clamshell will print as a nice table. 127 | 128 | ```clam 129 | ~/me $_ files 130 | 131 | 132 | ``` 133 | 134 | We can pass arguments to see another folder, include hidden files, and recur down to see subfolders too 135 | 136 | ```clam 137 | ~/me $_ files Documents/another_folder True 3 138 | 139 | 140 | ``` 141 | 142 | That above command calls the equivalent of this: 143 | ```python 144 | files(path='Documents/another_folder', hidden=True, recursive=3) 145 | ``` 146 | which means it'll include hidden files and folders, and recur into folders up to a depth of 3. 147 | 148 | Because the return is a list, we can do things like loop through it's return (although note that for setting variables, we no longer get to use our fun clam syntax 🥲): 149 | 150 | ```clam 151 | ~/me $_ tunes = files('Music') 152 | ~/me $_ for item in tunes: 153 | > if 'Rick Astley' not in item['name']: 154 | > delete(item['path']) 155 | ``` 156 | 157 | Phew! That deleted every file and folder in Music that didn't have Rick Astley in the name (more on delete later) 158 | 159 | 160 | ### copy and move 161 | 162 | Not much to save about these, they copy or move a file/folder from one place to another: 163 | 164 | ```clam 165 | ~/me $_ move some_file.txt a_folder/somewhere_else.txt 166 | ``` 167 | 168 | Although, combined with the files() return, we can do some cool stuff to move things from one location to another: 169 | 170 | ```clam 171 | ~/me/Downloads/new_album $_ to_move = files() 172 | ~/me/Downlaods/new_album $_ goto Music 173 | ~/me/Music $_ [copy(i['path'], i['name']) for i in to_move] 174 | ``` 175 | 176 | 177 | ### delete 178 | 179 | Works *a bit* like rm, except that it'll put things into the recycle bin thanks to the beautiful send2trash module. 180 | 181 | So we can do this in a burst of desire to destroy everything: 182 | ```clam 183 | ~/me $_ delete Documents 184 | ``` 185 | 186 | but unlike running: 187 | ```bash 188 | ~/me $_ rm -rf Documents 189 | ``` 190 | 191 | We can recover our stuff later. 192 | 193 | 194 | ### pipe and run 195 | 196 | Sadly, if we're in some python logic, we can't just run shell commands. This function (which uses my 'mpy3' command line runnable) won't work: 197 | 198 | ```clam 199 | ~/me/Music $_ for i in files(): 200 | > if i['name'].endswith('.mp3'): 201 | > mpy3 i['name'] 202 | ``` 203 | 204 | If something isn't native python, once we're in a loop for function, we have to use "run" (which is basically just pythons subprocessing module's run. 205 | 206 | It returns an object with .stdout and .stderr methods that can be handy, so you can set variables, or process standard output and input pretty easy. 207 | 208 | ```clam 209 | ~/me/Music $_ for i in files(): 210 | > if i['name'].endswith('.mp3'): 211 | > run(f'mpy3 {i['name']}') 212 | ``` 213 | 214 | There's also a handy "pipe" function, that'll do basically what you think (pipe the results from the first argument into the second, and the second into the third, etc). 215 | 216 | ```clam 217 | ~/me/Music $_ pipe( 218 | > files()[0], 219 | > lambda x: run(f'mpy3 {x}') 220 | > ) 221 | ``` 222 | 223 | or something like: 224 | 225 | ```clam 226 | ~/me/Music $_ pipe( 227 | > run('ls') 228 | > lambda x: print(f'output of ls is {x.stdout}') 229 | > ) 230 | ``` 231 | 232 | ### Other functions 233 | 234 | There are a bunch of other functions to, which are a bit more self explanatory, and have less to say on: 235 | - read(name_of_file) -> prints out a nicely formatted version of a file 236 | - search(string, path, recursive=0) -> will seach for a string occurence within files and give use the lines 237 | - make_file, make_directory -> make a file or directory with the name of the argument given 238 | 239 | ## clamrc.py 240 | 241 | on first running, clamshell with make a file in your home under '.config/clamshell/clamrc.py'. This'll get ran and brought into global variables every time you start up clamshell. 242 | 243 | At first it'll just be blank. 244 | 245 | It's just a normal python file, so if we add something like this to it: 246 | 247 | ```python 248 | interesting_opinion = """ 249 | I’d just like to interject for a moment. What you’re refering to as Linux, is in fact, GNU/Linux, or as I’ve recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. 250 | """ 251 | def be_interesting(): 252 | print(interesting_optinion) 253 | ``` 254 | 255 | We can call that function anytime in our repl: 256 | ```clam 257 | ~/me $_ be_interesting() 258 | 259 | I’d just like to interject for a moment. What you’re refering to as Linux, is in fact, GNU/Linux, or as I’ve recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. 260 | ``` 261 | 262 | There's a couple of special tweaks that are worth menthioning. . . 263 | 264 | ### super_commands 265 | 266 | Again, sorry for the name, but if we define a list with it of **strings of the names of functions** (kinda wierd, sorry!). Then those functions become 'super_commands', and are ran whenever they're typed without having to use python's () function sytanx. 267 | 268 | So if we add this to the end of our clamrc.py: 269 | 270 | ```python 271 | super_commands = ['be_interesting'] 272 | ``` 273 | Now we can be interesting with even less keystrokes: 274 | 275 | ```clam 276 | ~/me $_ be_interesting 277 | 278 | I’d just like to interject for a moment. What you’re refering to as Linux, is in fact, GNU/Linux, or as I’ve recently taken to calling it, GNU plus Linux. Linux is not an operating system unto itself, but rather another free component of a fully functioning GNU system made useful by the GNU corelibs, shell utilities and vital system components comprising a full OS as defined by POSIX. 279 | ``` 280 | 281 | ### aliases 282 | 283 | Just like with bash, we can alias things by adding them in our clamrc.py. 284 | 285 | We just add a dictionary called "aliases": 286 | 287 | ```python 288 | aliases = {'python2': 'python3'} 289 | ``` 290 | 291 | Now every time someone tries to run python2, python3 will run instead. Phew, how helpful! 292 | 293 | ### get_prompt & get_continuation_prompt 294 | 295 | We can define a custom prompt by making a function that returns a string: 296 | 297 | ```python 298 | import os 299 | 300 | def get_prompt(): 301 | return 'cooooooooooooool ' + os.getcwd() + ' >' 302 | 303 | def continuation_prompt(): 304 | return 'keep it up>>' 305 | ``` 306 | 307 | Now our shell looks like this: 308 | 309 | ```clam 310 | coooooooool ~/me > def something(): 311 | keep it up>> print('stuff') 312 | ``` 313 | 314 | Aside from that, the whole file is just normal python that'll get interpretted as normal python. 315 | 316 | That makes it pretty easy to write quick utilities for ourselves, or take use of python's capabilities to do something crazy, like: 317 | 318 | - Print out an inspirational quote every time we start the shell 319 | - Delete every file we own if it's 14:34 320 | - Get the price of bitcoin from an api and print it out in the command prompt 321 | 322 | 323 | ## How do I get this wonderful beast!? 324 | 325 | Though pip: 326 | 327 | ``` 328 | pip install clamshell 329 | ``` 330 | 331 | and then either: 332 | ``` 333 | clamshell 334 | ``` 335 | or 336 | ``` 337 | python -m clamshell 338 | ``` 339 | 340 | 341 | 342 | --------------------------------------------------------------------------------