├── .gitignore ├── LICENSE ├── README.md ├── poetry.lock ├── pyproject.toml └── src └── textualize_see ├── __init__.py ├── cli.py ├── errors.py └── file_map.py /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 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 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Textualize 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textualize See 2 | 3 | Textualize See is a command line tool to open files in the terminal. 4 | 5 | A TOML configuration file maps glob-style patterns on to commands. You could configure `see` to open any file matching the pattern `"*.py"` with [rich-cli](https://github.com/Textualize/rich-cli) and `"*.rs"` files with [bat](https://github.com/sharkdp/bat), for example. 6 | 7 | Think of `see` as the terminal equivalent of double-clicking a file on the desktop. 8 | 9 | The configuration is flexible enough that `see` can run a different command depending on the directory. For instance you might want a different command to display `.html` files in a Django project (which are in reality template files), than elsewhere on your system. 10 | 11 | While the default is to *view* the file, you can request different actions, such as "edit", "format", "print" etc. 12 | 13 | ## Install 14 | 15 | `See` is distributed as a Python package. 16 | The easiest way to install it is probably with [pipx](https://pypa.github.io/pipx/). 17 | 18 | ``` 19 | pipx install textualize_see 20 | ``` 21 | 22 | This will add `see` to your path. 23 | 24 | ## Usage 25 | 26 | > **Note** 27 | > You will need to configure `see` before you use it. 28 | 29 | Call `see` with a path to view that file in the terminal: 30 | 31 | ``` 32 | see application.py 33 | ``` 34 | 35 | If you add two arguments, then the first should be an *action*, and the second should be a path. 36 | 37 | ``` 38 | see edit application.py 39 | ``` 40 | 41 | This will open `application.py` with a command to edit the file. 42 | 43 | Any additional arguments added after the path are forwarded to the command. 44 | In the following command `--pager` is not an option for `see`, so it will be forwarded to the command that opens the file. 45 | 46 | ``` 47 | see application.py --pager 48 | ``` 49 | 50 | Note that `see` will run commands for configured paths only. 51 | If there is no matching path then `see` will do nothing. 52 | See below for configuration. 53 | 54 | ## Configure 55 | 56 | Textual reads its configuration from `~/.see.toml` (a [TOML](https://toml.io/en/) file). 57 | This file should consist of several tables which specify the action (e.g. "view") and a glob style pattern to match against. 58 | 59 | The table should have a `run` key which defines the command to run. 60 | The `run` value may contain `$PATH` or `$ARGS` which will be replaced with the path and forwarded arguments respectively. 61 | 62 | The following entry in `~/.see.toml` will match any files with the extension ".py": 63 | 64 | ```toml 65 | [[actions.view."*.py"]] 66 | run = "rich $PATH $ARGS" 67 | ``` 68 | 69 | If you were to run the following `see` command: 70 | 71 | ``` 72 | see application.py --pager 73 | ``` 74 | 75 | Then `see` would pass the path to `rich` along with any options it doesn't recognize, such as `--pager`. 76 | 77 | ``` 78 | rich application.py --pager 79 | ``` 80 | 81 | ### Priority 82 | 83 | You can optionally add a `priority` integer value, associated with a pattern. 84 | If not provided, `priority` will default to 1. 85 | 86 | If more than one pattern matches the path, then the action with the highest priority will be used. 87 | This can be used to add a fallback if there is no explicit match. 88 | For example, we could add the following section to `cat` any files to the terminal that we haven't explicitly matched: 89 | 90 | ```toml 91 | [[actions.view."*"]] 92 | priority = 0 93 | run = "cat $PATH $ARGS" 94 | ``` 95 | 96 | ## Why did I build this? 97 | 98 | I've always felt something like this should exist. 99 | 100 | It is functionality that desktops take for granted, but the experience is not quite as transparent in the terminal. 101 | There are alternatives (see below) but this is how I would want it work. 102 | It is also cross-platform so I don't seem like a fish out of water on Windows. 103 | 104 | ## Why not just use ... ? 105 | 106 | Inevitably this will prompt the question "Why not just use TOOL?". 107 | I don't want to talk you out of TOOL, but this is what I considered: 108 | 109 | ### open or xdg-open 110 | 111 | There is `open` on macOS, and `xdg-open` on Linux, which open files. 112 | But these typically open desktop applications, and when I'm in the terminal I typically want to stay in the terminal. 113 | 114 | ### hash bangs? 115 | 116 | The hash bang `#!` is used to *execute* the file, while I just want to open it. It also requires that you can edit the file itself. 117 | 118 | ### shell aliases 119 | 120 | You could add an alias for each filetype you want to open, like `md-view` and `md-edit` etc. 121 | Which is a perfectly reasonable use for alias, but it does require a command per filetype + action which is harder to commit to muscle memory. 122 | 123 | ZSH offers `alias -s` which associates a file extension with a command. 124 | For example if you have the alias `alias -s py=rich` then you can enter `foo.py` to syntax highlight a Python file. 125 | I like this, but I *think* it is only offered by the `zsh` shell (may be wrong) and it is not cross platform. 126 | 127 | ## Why Python? 128 | 129 | It's Python because I am mainly a Python developer. 130 | Tools like this do tend to be written a little closer to the metal. 131 | If `see` becomes popular and the interface stabilizes, then maybe I (or somebody else) will write it a compiled language. 132 | Until then you might have to wait an additional few microseconds to run apps. 133 | 134 | ## Support 135 | 136 | Consider this project alpha software for the time being. 137 | It was written in under a day and hasn't been battle tested. 138 | It has so far only been tested under MacOS, but the goal is to make it work across all the platforms. 139 | -------------------------------------------------------------------------------- /poetry.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand. 2 | 3 | [[package]] 4 | name = "click" 5 | version = "8.1.3" 6 | description = "Composable command line interface toolkit" 7 | category = "main" 8 | optional = false 9 | python-versions = ">=3.7" 10 | files = [ 11 | {file = "click-8.1.3-py3-none-any.whl", hash = "sha256:bb4d8133cb15a609f44e8213d9b391b0809795062913b383c62be0ee95b1db48"}, 12 | {file = "click-8.1.3.tar.gz", hash = "sha256:7682dc8afb30297001674575ea00d1814d808d6a36af415a82bd481d37ba7b8e"}, 13 | ] 14 | 15 | [package.dependencies] 16 | colorama = {version = "*", markers = "platform_system == \"Windows\""} 17 | 18 | [[package]] 19 | name = "colorama" 20 | version = "0.4.6" 21 | description = "Cross-platform colored terminal text." 22 | category = "main" 23 | optional = false 24 | python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" 25 | files = [ 26 | {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, 27 | {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, 28 | ] 29 | 30 | [[package]] 31 | name = "toml" 32 | version = "0.10.2" 33 | description = "Python Library for Tom's Obvious, Minimal Language" 34 | category = "main" 35 | optional = false 36 | python-versions = ">=2.6, !=3.0.*, !=3.1.*, !=3.2.*" 37 | files = [ 38 | {file = "toml-0.10.2-py2.py3-none-any.whl", hash = "sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b"}, 39 | {file = "toml-0.10.2.tar.gz", hash = "sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"}, 40 | ] 41 | 42 | [metadata] 43 | lock-version = "2.0" 44 | python-versions = "^3.8" 45 | content-hash = "3dd4255f87f57b0d4e098b1b650968f8a5a9b23b27f997a24ba73fe253bda3c7" 46 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "textualize_see" 3 | version = "0.1.1" 4 | description = "Open files in the terminal" 5 | authors = ["Will McGugan "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.8" 11 | click = "^8.1.3" 12 | toml = "^0.10.2" 13 | 14 | [build-system] 15 | requires = ["poetry-core"] 16 | build-backend = "poetry.core.masonry.api" 17 | 18 | 19 | [tool.black] 20 | includes = "src" 21 | 22 | [tool.poetry.scripts] 23 | see = "textualize_see.cli:app" 24 | 25 | -------------------------------------------------------------------------------- /src/textualize_see/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Textualize/textualize-see/eef61dd348178ec60c5b0a01062e0b621eb57315/src/textualize_see/__init__.py -------------------------------------------------------------------------------- /src/textualize_see/cli.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import click 4 | import shlex 5 | import os 6 | import sys 7 | 8 | from .errors import AppError 9 | from .file_map import FileMap 10 | 11 | 12 | @click.command( 13 | context_settings={ 14 | "ignore_unknown_options": True, 15 | }, 16 | ) 17 | @click.option( 18 | "-c", 19 | "--config", 20 | default=lambda: os.environ.get("SEE_CONFIG", "~/.see.toml"), 21 | help="Location of configuration file", 22 | ) 23 | @click.option("-n", "--no-run", is_flag=True, help="Display command but don't run it.") 24 | @click.argument( 25 | "path", 26 | metavar="[ACTION] PATH", 27 | ) 28 | @click.argument( 29 | "forward_args", nargs=-1, type=click.UNPROCESSED, metavar="" 30 | ) 31 | def app(config: str, no_run: bool, path: str, forward_args: list[str]) -> None: 32 | """Open files in the terminal.""" 33 | 34 | action = "view" 35 | if "." not in path and forward_args: 36 | action = path 37 | path = forward_args[0] 38 | forward_args = forward_args[1:] 39 | 40 | try: 41 | file_map = FileMap(config) 42 | except AppError as app_error: 43 | print(str(app_error), file=sys.__stderr__) 44 | sys.exit(1) 45 | 46 | args = shlex.join(forward_args) 47 | 48 | for command in file_map.get_commands(path, action): 49 | run = command.run.replace("$ARGS", args).replace("$PATH", shlex.quote(path)) 50 | if no_run: 51 | print(run) 52 | else: 53 | sys.exit(os.system(run)) 54 | break 55 | print("No matching pattern in `~/.see.toml`", file=sys.stderr) 56 | sys.exit(1) 57 | -------------------------------------------------------------------------------- /src/textualize_see/errors.py: -------------------------------------------------------------------------------- 1 | class AppError(Exception): 2 | """App failed to run.""" 3 | -------------------------------------------------------------------------------- /src/textualize_see/file_map.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | 4 | from dataclasses import dataclass, field 5 | from operator import attrgetter 6 | from pathlib import Path 7 | 8 | import toml 9 | from toml.decoder import TomlDecodeError 10 | 11 | from .errors import AppError 12 | 13 | 14 | @dataclass 15 | class Command: 16 | """A command to run with a file.""" 17 | 18 | action: str = "view" 19 | run: str = "" 20 | priority: int = 1 21 | 22 | 23 | @dataclass 24 | class Config: 25 | paths: dict[str, list[Command]] = field(default_factory=dict) 26 | 27 | 28 | class FileMap: 29 | """A map of files and extensions.""" 30 | 31 | def __init__(self, path: str) -> None: 32 | """Initialise a file map. 33 | 34 | Args: 35 | path (str): Path ot the toml config 36 | """ 37 | self.config = self._read_config(path) 38 | 39 | def get_commands(self, path: str, action: str) -> list[Command]: 40 | """Get commands associated with a path.""" 41 | 42 | results: list[Command] = [] 43 | for wildcard, commands in self.config.paths.items(): 44 | if Path(path).resolve().match(wildcard): 45 | for command in commands: 46 | if command.action == action: 47 | results.append(command) 48 | results.sort(key=attrgetter("priority"), reverse=True) 49 | return results 50 | 51 | def _read_config(self, path: str) -> Config: 52 | """Ready TOML config. 53 | 54 | Args: 55 | path (str): Path to the config. 56 | 57 | Raises: 58 | AppError: If the config failed to load or validate. 59 | 60 | 61 | Returns: 62 | Config: Config object. 63 | """ 64 | 65 | try: 66 | data = toml.load(Path(path).expanduser()) 67 | except FileNotFoundError: 68 | raise AppError(f"Unable to read config {path!r}") 69 | except TomlDecodeError as error: 70 | raise AppError(f"Unable to parse TOML config {path!r}: {error}") 71 | 72 | config = Config() 73 | 74 | actions = data.get("actions", {}) 75 | 76 | for action, action_commands in actions.items(): 77 | for ext, extensions in action_commands.items(): 78 | for extension_config in extensions: 79 | run = extension_config.get("run", "") 80 | if not isinstance(action, str): 81 | raise AppError( 82 | f"Config invalid: [[{action}.{ext}]] / 'action' expected string, found {run!r}" 83 | ) 84 | if not run: 85 | continue 86 | priority = extension_config.get("priority", 1) 87 | if not isinstance(priority, int): 88 | raise AppError( 89 | f"Config invalid: [[{action}.{ext}]] / 'priority' expected int, found {priority!r}" 90 | ) 91 | extension = Command(action, run, priority) 92 | config.paths.setdefault(ext, []).append(extension) 93 | 94 | return config 95 | --------------------------------------------------------------------------------