├── .github └── workflows │ └── pypi.yaml ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── README.md ├── docs └── images │ ├── demo.png │ ├── highlighting.png │ ├── last_accessed_entry.png │ ├── parent_and_child.png │ └── simple_example.png ├── pickhost ├── __init__.py ├── __main__.py ├── pickhost.py └── system.py ├── requirements-dev.txt ├── requirements.txt ├── setup.py └── tests ├── hosts └── test_pickhost.py /.github/workflows/pypi.yaml: -------------------------------------------------------------------------------- 1 | name: PyPI 2 | on: 3 | push: 4 | branches: 5 | - master 6 | jobs: 7 | pypi: 8 | name: Build and upload package to PyPI 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout repo 12 | uses: actions/checkout@v2.3.4 13 | - name: Setup Python3 14 | uses: actions/setup-python@v2 15 | with: 16 | python-version: 3.x 17 | - name: Install dependencies 18 | run: | 19 | python -m pip install --upgrade pip 20 | pip install -r requirements-dev.txt 21 | - name: Build package 22 | run: python setup.py bdist_wheel 23 | - name: Publish package to PyPI 24 | uses: pypa/gh-action-pypi-publish@master 25 | with: 26 | user: __token__ 27 | password: ${{secrets.PYPI_API_TOKEN}} 28 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | 2 | CHANGELOG 3 | ========= 4 | 5 | 0.1.1: 2018-04-23 6 | ----------------- 7 | - Initial official (beta) release. 8 | 9 | 0.2.0: 2020-11-28 10 | ----------------- 11 | - Fixed config and cache directories on macOS. 12 | - Updated the doc on how to install and run the tool on macOS. 13 | - Also updated setup.py to read version from __init__.py. 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Pickhost is a simple SSH host manager in terminal. 5 | 6 | Copyright (C) 2019 by Huan Xiong. All Rights Reserved. 7 | 8 | This program is free software: you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation, either version 3 of the License, or 11 | (at your option) any later version. 12 | 13 | This program is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see . 20 | 21 | Project Web Site: https://github.com/rayx/pickhost 22 | Author: Huan Xiong -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.md 2 | include LICENSE 3 | recursive-include docs * -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | A simple SSH host manager in terminal. 2 | 3 | # Introduction 4 | 5 | Pickhost allows you to manage SSH login information (e.g., host, user, comments, etc.) in an simple INI style config file and select one item from them in an interactive way in terminal. It's implemented using [PyPick](https://github.com/rayx/pypick). 6 | 7 | Compared with existing SSH managers (as far as I know, all of them are GUI applications), pickhost is simple to config and quick to launch. It's non-disruptive and integrates with your workflow well if you spend most of your time in terminal. 8 | 9 | The following is an example screenshot: 10 | 11 | ![docs/images/demo.png](https://github.com/rayx/pickhost/raw/master/docs/images/demo.png) 12 | 13 | # Install 14 | 15 | As Python2 will be EOL'ed soon, PyPick supports only Python3. To install it, run: 16 | 17 | $ python3 -m pip install pickhost 18 | 19 | # A Quick-Start Guide 20 | 21 | ## A Simple Example 22 | 23 | To use pickhost, you need to add your hosts to a config file. Pickhost looks for its config file at $HOME/.config/pickhost/hosts by default. You can specify a custom config file by using -f option. Config file is of INI format. You can edit it with any editor you like. Pickhost provides a convenient option -e, which opens the file using vi or $EDITOR if it's set. 24 | 25 | So, if this is the first time you run pickhost, you should run the following command to edit the default config file: 26 | 27 | $ pickhost -e 28 | 29 | As an example you can add the following lines to it: 30 | 31 | [ARM servers] 32 | server-5 = rayx@10.64.4.5 #centos 7.4 33 | server-66 = root@10.64.4.66 #ubuntu 16.04 34 | 35 | [Benchmarking] 36 | arm-server = rayx,root@10.64.37.55 #Centriq 2400, 192G RAM 37 | x86-server = rayx,root@10.64.37.51 #Xeon Gold 5118, 192G RAM 38 | test-client = rayx,root@10.64.37.182 39 | 40 | The file contains two sections (BTW, section is not required but it's a useful way to organize your hosts). Each line in them defines a host. They are in the following format: 41 | 42 | = @ # 43 | 44 | - `name` is an arbitray string you'd like to call the host. It mustn't have any of these characters: `=`, `@`, `#`. 45 | - `users` is a comma separated list of user names. The first value in the list is displayed by default. You can go through other values by pressing `u`. 46 | - `host` is the host to be ssh'ed into. It can be any valid hostname accepted by SSH client on your machine. For example, it can be IP address, short hostname, FQDN, or a hostname translated by SSH client (see `HostName` option in ssh_config(5) man page). 47 | - `comment` is an arbitrary string. It provides additional information about the host, like what project it's used for, its configuration, etc. 48 | 49 | Then you run pickhost command: 50 | 51 | $ pickhost 52 | 53 | It reads the config file and generates a list like the following: 54 | 55 | ![docs/images/simple_example.png](https://github.com/rayx/pickhost/raw/master/docs/images/simple_example.png) 56 | 57 | You can press `UP` and `DOWN` (or vi style `j` and `k`) to navigate through items in the list, press `ENTER` (or `SPACE`) to select an entry, or press `ESC` (or `q`) to quit without selecting anything. 58 | 59 | Note that there is a small triangle `▾` after user name in some entries. That indicates there are multiple user name values and you can press `u` to select one of them. 60 | 61 | Now suppose you navigate to the first entry in 'Benchmarking' section. Press `u` once to change user name from 'rayx' to 'root', then press `ENTER` to select the entry. Pickhost would print its value in the following format on stderr: 62 | 63 | $ pickhost 64 | export PH_USER=root 65 | export PH_HOST=10.64.37.55 66 | export PH_NAME=arm-server 67 | 68 | That's pretty much the basic usage of the tool. Next we'll talk about how to use the above output in shell script and a few more useful features of pickhost. 69 | 70 | ## Integrating Pickhost with Shell Script 71 | 72 | Pickhost command output is expected to be consumed by shell script. Below is an example shell function on how to do that: 73 | 74 | function pick { 75 | unset PH_NAME PH_USER PH_HOST 76 | eval $(pickhost 2>&1 >/dev/tty) 77 | # Return if no entry is selected. 78 | [[ -n $PH_NAME ]] || return 0 79 | echo "Logging into ${PH_NAME}..." 80 | ssh ${PH_USER}@${PH_HOST} 81 | } 82 | 83 | If you want to use pickhost in your Python program, you can instantiate the PickHost class from pickhost module. The details are out of the scope of this tutorial, so please refer to the code. 84 | 85 | ## Last Accessed Host 86 | 87 | Now you have run pickhost for the first time and selected an entry. If you run it again, the list changes a bit: 88 | 89 | ![docs/images/last_accessed_entry.png](https://github.com/rayx/pickhost/raw/master/docs/images/last_accessed_entry.png) 90 | 91 | Note there is a `last accessed` entry at the top of the list, which contains the entry you selected last time. You can start a new login session to the same host by pressing `ENTER`. This is a useful feature to save a lot of key press in a busy work day. 92 | 93 | ## Parent/Child Relationship among Hosts 94 | 95 | You can define parent/child relationship between hosts. This is particularly useful when you need to deal with VMs in your work. Below is an example config file: 96 | 97 | [ARM servers] 98 | server-5 = rayx@10.64.4.5 #centos 7.4 99 | server-5->vm-176 = root,rayx@192.168.122.176 #qemu: hotplugging 100 | server-5->vm-176->nested-vm = root,rayx@191.168.56.18 #nested-vm 101 | server-5->vm-37 = root,rayx@192.168.122.37 #qemu: virtio-block 102 | server-66 = root@10.64.4.66 #ubuntu 16.04 103 | 104 | [Benchmarking] 105 | arm-server = rayx,root@10.64.37.55 #Centriq 2400, 192G RAM 106 | x86-server = rayx,root@10.64.37.51 #Xeon Gold 5118, 192G RAM 107 | test-client = rayx,root@10.64.37.182 108 | 109 | As you may notice, a child host's name contains all its ancestor names, separated by `->`. With this config file pickhost generates a list like the following, which shows the relationship visually and helps you to identify a VM quickly. 110 | 111 | ![docs/images/parent_and_child.png](https://github.com/rayx/pickhost/raw/master/docs/images/parent_and_child.png) 112 | 113 | Note that, if entries in a section have parent/child relationship, pickhost determines the appropriate position of each entry and makes sure a child host appears after its parent host. So if you like, you don't have to arrange their order manually. For example, the config file below generates the same list: 114 | 115 | [ARM servers] 116 | server-5 = rayx@10.64.4.5 #centos 7.4 117 | server-66 = root@10.64.4.66 #ubuntu 16.04 118 | server-5->vm-176 = root,rayx@192.168.122.176 #qemu: hotplugging 119 | server-5->vm-37 = root,rayx@192.168.122.37 #qemu: virtio-block 120 | server-5->vm-176->nested-vm = root,rayx@191.168.56.18 #nested-vm 121 | 122 | [Benchmarking] 123 | arm-server = rayx,root@10.64.37.55 #Centriq 2400, 192G RAM 124 | x86-server = rayx,root@10.64.37.51 #Xeon Gold 5118, 192G RAM 125 | test-client = rayx,root@10.64.37.182 126 | 127 | ## Highlighting a Host 128 | 129 | It's often that you access a few hosts more frequently than the others. For those frequently accessed hosts, you may want to highlight them so that you can easily identify them. This is particularly useful if you have a long list. 130 | 131 | You can highlight an entry by adding `!` character after the host name. Below is an example: 132 | 133 | [ARM servers] 134 | server-5 = rayx@10.64.4.5 #centos 7.4 135 | server-5->vm-176! = root,rayx@192.168.122.176 #qemu: hotplugging 136 | server-5->vm-176->nested-vm = root,rayx@191.168.56.18 #nested-vm 137 | server-5->vm-37 = root,rayx@192.168.122.37 #qemu: virtio-block 138 | server-66 = root@10.64.4.66 #ubuntu 16.04 139 | 140 | [Benchmarking] 141 | arm-server! = rayx,root@10.64.37.55 #Centriq 2400, 192G RAM 142 | x86-server = rayx,root@10.64.37.51 #Xeon Gold 5118, 192G RAM 143 | test-client = rayx,root@10.64.37.182 144 | 145 | With this config file pickhost generates a list like this: 146 | 147 | ![docs/images/highlighting.png](https://github.com/rayx/pickhost/raw/master/docs/images/highlighting.png) 148 | 149 | # Using the tool on macOS 150 | 151 | First make sure python3 is installed on your macOS. 152 | 153 | $ python3 --version 154 | 155 | Then run the command to install the tool: 156 | 157 | $ python3 -m pip install --user pickhost 158 | 159 | That will install the package under `~/Library/Python//lib/python/site-packages/pickhost` and an executable file under `~/Library/Python//bin`. 160 | 161 | Then add the following to your `~/.profile`: 162 | 163 | py3ver=$(python3 --version | awk '{print $2}') 164 | py3ver=${py3ver%.*} 165 | PATH=$PATH:${HOME}/Library/Python/${py3ver}/bin 166 | # Fix locale 167 | export LC_ALL=en_US.UTF-8 168 | export LANG=en_US.UTF-8 169 | 170 | The last two lines fix a common issue on macOS, which is not specific to this tool. You may want to change `en_US` to your language code (e.g. `zh_CN`, etc.) 171 | 172 | Then just follow the QuickStart guide above to setup and run the tool. The default config file is located at `~/Library/Application Support/pickhost/config` on macOS. 173 | 174 | # Manual 175 | 176 | $ pickhost -h 177 | usage: pickhost [-h] [-f file] [-e] 178 | 179 | optional arguments: 180 | -h, --help show this help message and exit 181 | -f file config file 182 | -e edit config file rather than show it 183 | 184 | # TODO 185 | 186 | - Allow user to set theme (e.g., width and color of the columns) 187 | 188 | # Other Projects 189 | 190 | - sshc: https://github.com/delucks/sshc 191 | 192 | - stormssh: https://github.com/emre/storm 193 | 194 | - ssh-config: https://github.com/dbrady/ssh-config 195 | 196 | All these three projects are CLI tools helping user modify ssh config. Pickhost is different in that it focuses on helping user select a host. It's the only tool that provides features like groups, last accessed host, parent/child relationship, and host highlighting, etc. 197 | -------------------------------------------------------------------------------- /docs/images/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rayx/pickhost/bd00a6813db27cb7e04640a2c689d2ce3c1c9e86/docs/images/demo.png -------------------------------------------------------------------------------- /docs/images/highlighting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rayx/pickhost/bd00a6813db27cb7e04640a2c689d2ce3c1c9e86/docs/images/highlighting.png -------------------------------------------------------------------------------- /docs/images/last_accessed_entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rayx/pickhost/bd00a6813db27cb7e04640a2c689d2ce3c1c9e86/docs/images/last_accessed_entry.png -------------------------------------------------------------------------------- /docs/images/parent_and_child.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rayx/pickhost/bd00a6813db27cb7e04640a2c689d2ce3c1c9e86/docs/images/parent_and_child.png -------------------------------------------------------------------------------- /docs/images/simple_example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rayx/pickhost/bd00a6813db27cb7e04640a2c689d2ce3c1c9e86/docs/images/simple_example.png -------------------------------------------------------------------------------- /pickhost/__init__.py: -------------------------------------------------------------------------------- 1 | from .pickhost import PickHost 2 | 3 | 4 | __all__ = ['PickHost'] 5 | __version__ = '0.2.1' 6 | __license__ = 'GPLv3+' 7 | __author__ = 'Huan Xiong ' 8 | -------------------------------------------------------------------------------- /pickhost/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import sys 3 | from pickhost import PickHost 4 | from pickhost import __version__ as version 5 | 6 | 7 | def main(): 8 | parser = argparse.ArgumentParser() 9 | parser.add_argument('-f', dest='file', metavar='file', 10 | help='config file') 11 | parser.add_argument('-e', dest='edit', action='store_true', 12 | help='edit config file rather than show it') 13 | parser.add_argument('-v', dest='version', action='store_true', 14 | help='print version') 15 | args = parser.parse_args() 16 | if args.version: 17 | print("Pickhost %s" % version) 18 | sys.exit(0) 19 | p = PickHost(args.file) 20 | if args.edit: 21 | p.edit() 22 | sys.exit(0) 23 | # If config file is empty there is no point to show it. 24 | if not p.config.groups: 25 | sys.exit("File %s is empty. Please rerun the command with " 26 | "-e option to add hosts first." % p.config.file) 27 | result = p.run() 28 | # If user selected an entry, print it to stderr, in a form that can 29 | # be consumed by shell script. 30 | if result: 31 | for key, value in result.items(): 32 | sys.stderr.write('export %s="%s"\n' % ('PH_' + key.upper(), value)) 33 | 34 | 35 | if __name__ == '__main__': 36 | main() 37 | -------------------------------------------------------------------------------- /pickhost/pickhost.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2019 by Huan Xiong. All Rights Reserved. 2 | # Licensed under GPLv3 or later. See LICENSE file under top level directory. 3 | 4 | 5 | import configparser 6 | import itertools 7 | import os 8 | import subprocess 9 | from collections import OrderedDict 10 | from pypick import Pick 11 | from . import system 12 | 13 | class Config: 14 | """ 15 | Its instance represents a config file, which is either passed by 16 | caller, or a default one, based on XDG base directory specification. 17 | 18 | Args: 19 | os: OS object which provides config and cache dir location. 20 | file: User specified config file 21 | 22 | Attributes: 23 | file: Config file, either passed by user, or the default one. 24 | groups: data returned by parse(). It's an OrderedDict. Key is 25 | group name, value is hosts in that group. 26 | """ 27 | def __init__(self, os, file=None): 28 | self.os = os 29 | if file: 30 | self.file = file 31 | else: 32 | self.file = self._get_config_file(create=True) 33 | 34 | def parse(self): 35 | """ 36 | Parse config file and return an OrderedDict in which key is group 37 | name and value is hosts in that group. 38 | 39 | Returns: 40 | OrderedDict: its key is group name, value is hosts in that 41 | group. 42 | 43 | Raises: 44 | ValueError: Raised if failed to parse the file. 45 | """ 46 | def _insert_after_parent(l, new_item): 47 | parent = None 48 | # First, Look for its parent. Then skip all existing children 49 | # of the parent. In other words, look for an item whose level 50 | # is same as or less than the parent and insert the item before 51 | # it. 52 | for index, item in enumerate(l): 53 | if not parent: 54 | if item['name'] == new_item['_parent']: 55 | parent = item 56 | else: 57 | if item['_level'] <= parent['_level']: 58 | l.insert(index, new_item) 59 | break 60 | if parent and new_item not in l: 61 | l.append(new_item) 62 | if not parent: 63 | raise ValueError("Failed to find parent '%s' for '%s'" % 64 | (new_item['_parent'], new_item['name'])) 65 | 66 | def _reorder(inlist, level, outlist): 67 | rest = [] 68 | for i in inlist: 69 | if i['_level'] == level: 70 | if not i['_parent']: 71 | outlist.append(i) 72 | else: 73 | _insert_after_parent(outlist, i) 74 | else: 75 | rest.append(i) 76 | if rest: 77 | _reorder(rest, level + 1, outlist) 78 | 79 | config = configparser.ConfigParser() 80 | with open(self.file) as f: 81 | config.read_file(itertools.chain(['[_global]'], f), 82 | source=self.file) 83 | self.groups = OrderedDict() 84 | for section in config: 85 | entries = [] 86 | for key, value in config[section].items(): 87 | entries.append(self._parse_value(key, value)) 88 | # Reorder hosts based on their parent/child relationship. 89 | self.groups[section] = [] 90 | _reorder(entries, 0, self.groups[section]) 91 | 92 | # 'DEFAULT' and '_global' are special sections. They don't appear 93 | # by default unless they have entries. 94 | if not self.groups['DEFAULT']: 95 | del self.groups['DEFAULT'] 96 | if not self.groups['_global']: 97 | del self.groups['_global'] 98 | return self.groups 99 | 100 | def _parse_value(self, name, value): 101 | """ 102 | Parse option value, which has this format: 103 | [!]@ [#] 104 | 105 | Args: 106 | name: Option name 107 | value: Option value 108 | 109 | Returns: 110 | dict: Contains the entry's fields. 111 | """ 112 | entry = '%s = %s' % (name, value) 113 | 114 | def _get_name_and_parent(name): 115 | l = [i.strip() for i in name.split('->')] 116 | level = 0 117 | critical = False 118 | name = l[-1] 119 | # Set cirtical attribute if its name ends with '!' 120 | if name[-1] == '!': 121 | name = name[:-1] 122 | critical = True 123 | # Set its parent and level attirbute 124 | parent = None 125 | if len(l) > 1: 126 | parent = l[-2].replace('!', '') 127 | level = len(l) - 1 128 | return name, parent, level, critical 129 | 130 | def _get_user(value): 131 | # value format: [!]@ [#] 132 | l = [i.strip() for i in value.split('@', 1)] 133 | if len(l) < 2: 134 | raise ValueError(entry) 135 | # The result contains two items: user and the rest part 136 | return l 137 | 138 | def _get_host_and_comment(value): 139 | if not value: 140 | raise ValueError(entry) 141 | l = [i.strip() for i in value.split('#', 1)] 142 | # Append a empty comment stirng if user doesn't specify one 143 | if len(l) == 1: 144 | l.append('') 145 | # The result contains two items: host and comment 146 | return l 147 | 148 | if not value: 149 | raise ValueError(entry) 150 | name, parent, level, critical = _get_name_and_parent(name) 151 | users, rest = _get_user(value) 152 | users = users.split(',') # Convert it to list 153 | host, comment = _get_host_and_comment(rest) 154 | return {'name': name, 155 | 'user': users, 156 | 'host': host, 157 | 'comment': comment, 158 | '_parent': parent, 159 | '_level': level, 160 | '_critical': critical} 161 | 162 | def _get_config_file(self, create=True): 163 | """Return the pathname of the default config file 164 | 165 | Args: 166 | create (boolean): if ture, create the file if it doesn't exist. 167 | 168 | Returns: 169 | str: pathname of the default config file. 170 | 171 | Raises: 172 | FileNotFoundError: Raised if the file doesn't exist and 173 | create flag is not True. 174 | """ 175 | config_dir = self.os.config_dir() 176 | file = '%s/pickhost/hosts' % config_dir 177 | if os.path.exists(file): 178 | return file 179 | # If it reaches here, the file doesn't exist. 180 | if not create: 181 | raise FileNotFoundError(file) 182 | else: 183 | # Create the directory if it doesn't exist. 184 | dir = '%s/pickhost' % config_dir 185 | if not os.path.exists(dir): 186 | os.mkdir(dir) 187 | # Create an empty file. 188 | open(file, 'w').close() 189 | return file 190 | 191 | 192 | class Cache: 193 | """ 194 | Its instance represents a cache file, which contains only one entry 195 | (the last selected entry) in current implementation. Cache file is 196 | located based on XDG base directory specification. The function creates 197 | it if it doesn't exist. 198 | 199 | Args: 200 | os: OS object which provides config and cache dir location. 201 | """ 202 | 203 | def __init__(self, os): 204 | self.os = os 205 | self.file = self._get_cache_file() 206 | 207 | def parse(self): 208 | """Parse the contents of cache file 209 | 210 | Returns: 211 | list: a list of entries 212 | """ 213 | with open(self.file, 'r') as f: 214 | entry = f.read().strip() 215 | if not entry: 216 | return [] 217 | name, user_and_host = [i.strip() for i in entry.split('=', 1)] 218 | user, host_and_desc = [i.strip() for i in user_and_host.split('@', 1)] 219 | host, desc = [i.strip() for i in host_and_desc.split('#', 1)] 220 | return [{'description': '%s: %s' % (desc, name), 221 | 'name': name, 222 | 'user': user, 223 | 'host': host, 224 | '_critical': True}] 225 | 226 | def save_entry(self, entry, description): 227 | """ 228 | Save an entry in cache file in this format: 229 | = @ # 230 | Note it doesn't contains comments, "!", "->", etc, and user value 231 | must be a string (it can't be a list). 232 | """ 233 | with open(self.file, 'w') as f: 234 | f.write('%s = %s@%s #%s' % (entry['name'], entry['user'], 235 | entry['host'], description)) 236 | 237 | def _get_cache_file(self): 238 | """ 239 | Return the pathname of cache file. The function creates it if 240 | it doesn't exist. 241 | 242 | Returns: 243 | str: pathname of cache file. 244 | """ 245 | cache_dir = self.os.cache_dir() 246 | file = '%s/pickhost/hosts' % cache_dir 247 | if not os.path.exists(file): 248 | # Create the directory if it doesn't exist 249 | dir = '%s/pickhost' % cache_dir 250 | if not os.path.exists(dir): 251 | os.mkdir(dir) 252 | # Create an empty file 253 | open(file, 'w').close() 254 | return file 255 | 256 | 257 | class PickHost(Pick): 258 | FIELDS = ['name', 'user', 'host', 'comment'] 259 | FIELD_ATTS = {'name': {'width': 20}, 260 | 'host': {'width': 20}, 261 | 'user': {'width': 12}, 262 | 'comment': {'return': False, 263 | 'style': 'trivial'}} 264 | 265 | CACHE_FIELDS = ['description'] # Showing this 266 | CACHE_EXTRA_FIELDS = ['name', 'user', 'host'] # Reurn these 267 | CACHE_FIELD_ATTS = {'description': {'return': False}} # Not return this 268 | 269 | def __init__(self, file=None): 270 | super(PickHost, self).__init__(self.FIELDS, 271 | field_attrs=self.FIELD_ATTS) 272 | os = system.OS() 273 | # Get entries from cache file and add them to a specific group. 274 | self.cache = Cache(os) 275 | cached_entries = self.cache.parse() 276 | if cached_entries: 277 | group = self.create_group('', (self.CACHE_FIELDS, 278 | self.CACHE_EXTRA_FIELDS, 279 | self.CACHE_FIELD_ATTS)) 280 | group.add_entries(cached_entries) 281 | 282 | # Config file contains multiple groups. Create and populate them. 283 | self.config = Config(os, file=file) 284 | for name, entries in self.config.parse().items(): 285 | group = self.create_group(name) 286 | group.add_entries(entries) 287 | 288 | def run(self): 289 | entry = super(PickHost, self).run() 290 | # Save the result only when user selects an entry. 291 | if entry: 292 | self.cache.save_entry(entry, 'Last Accessed') 293 | return entry 294 | 295 | def edit(self): 296 | editor = self._get_editor() 297 | subprocess.call([editor, self.config.file]) 298 | 299 | def _get_editor(self): 300 | # Return EDITOR if it's set. 301 | if 'EDITOR' in os.environ: 302 | editor = os.environ['EDITOR'] 303 | if not os.path.isfile(editor): 304 | raise FileNotFoundError('EDITOR (%s)' % dir) 305 | return editor 306 | # Otherwise return vi 307 | editor = '/usr/bin/vi' 308 | if not os.path.isfile(editor): 309 | raise FileNotFoundError(editor) 310 | return editor 311 | -------------------------------------------------------------------------------- /pickhost/system.py: -------------------------------------------------------------------------------- 1 | # Copyright (C) 2020 by Huan Xiong. All Rights Reserved. 2 | # Licensed under GPLv3 or later. See LICENSE file under top level directory. 3 | 4 | 5 | import os 6 | import sys 7 | 8 | 9 | class OS: 10 | """ 11 | Provide config and cache file locations on different OSes. 12 | """ 13 | def __init__(self): 14 | self.os = sys.platform 15 | self.home = os.environ['HOME'] 16 | if self.os not in ['linux', 'darwin']: 17 | sys.stderr.write("The tool doesn't support the OS. Aborted.") 18 | sys.exit(1) 19 | 20 | def config_dir(self): 21 | if self.os == "linux": 22 | return self._linux_config_dir() 23 | elif self.os == "darwin": 24 | return self._darwin_config_dir() 25 | 26 | def _linux_config_dir(self): 27 | """ 28 | Return XDG_CONFIG_HOME environment variable value if its set, 29 | otherwiese return its default value: $HOME/.config. 30 | 31 | Returns: 32 | str: User config dir. 33 | 34 | Raises: 35 | FileNotFoundError: Raised if XDG_CONFIG_HOME is set to a 36 | non-existant directory, or if $HOME/.config doesn't exist. 37 | """ 38 | # Return XDG_CONFIG_HOME if it's set. 39 | if 'XDG_CONFIG_HOME' in os.environ: 40 | dir = os.environ['XDG_CONFIG_HOME'] 41 | if not os.path.isdir(dir): 42 | raise FileNotFoundError('XDG_CONFIG_HOME (%s)' % dir) 43 | return dir 44 | # Otherwise return $HOME/.config 45 | dir = '%s/.config' % os.environ['HOME'] 46 | if not os.path.isdir(dir): 47 | raise FileNotFoundError(dir) 48 | return dir 49 | 50 | def _darwin_config_dir(self): 51 | """ 52 | Return ~/Library/Application Support/pickhost/config. Create it 53 | if it doesn't exist. 54 | 55 | Returns: 56 | str: User cache dir. 57 | """ 58 | app_dir = '%s/Library/Application Support/pickhost' % self.home 59 | if not os.path.isdir(app_dir): 60 | os.mkdir(app_dir) 61 | config_dir = '%s/config' % app_dir 62 | if not os.path.isdir(config_dir): 63 | os.mkdir(config_dir) 64 | return config_dir 65 | 66 | def cache_dir(self): 67 | if self.os == "linux": 68 | return self._linux_cache_dir() 69 | elif self.os == "darwin": 70 | return self._darwin_cache_dir() 71 | 72 | def _linux_cache_dir(self): 73 | """ 74 | Return XDG_CACHE_HOME environment variable value if its set, 75 | otherwiese return its default value: $HOME/.cache. 76 | 77 | Returns: 78 | str: User cache dir. 79 | 80 | Raises: 81 | FileNotFoundError: Raised if XDG_CACHE_HOME is set to a 82 | non-existant directory, or if $HOME/.cache doesn't exist. 83 | """ 84 | # Return XDG_CACHE_HOME if it's set. 85 | if 'XDG_CACHE_HOME' in os.environ: 86 | dir = os.environ['XDG_CACHE_HOME'] 87 | if not os.path.isdir(dir): 88 | raise FileNotFoundError('XDG_CACHE_HOME (%s)' % dir) 89 | return dir 90 | 91 | # Otherwise return $HOME/.cache, which is the default value of 92 | # XDG_CACHE_HOME. 93 | dir = '%s/.cache' % self.home 94 | if not os.path.isdir(dir): 95 | raise FileNotFoundError(dir) 96 | return dir 97 | 98 | def _darwin_cache_dir(self): 99 | """ 100 | Return ~/Library/Application Support/pickhost/cache. Create it 101 | if it doesn't exist. 102 | 103 | Returns: 104 | str: User cache dir. 105 | """ 106 | app_dir = '%s/Library/Application Support/pickhost' % self.home 107 | if not os.path.isdir(app_dir): 108 | os.mkdir(app_dir) 109 | cache_dir = '%s/cache' % app_dir 110 | if not os.path.isdir(cache_dir): 111 | os.mkdir(cache_dir) 112 | return cache_dir 113 | -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | pypick>=0.2.0 2 | wheel 3 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | pypick>=0.2.0 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import setuptools 3 | from setuptools.command.test import test as TestCommand 4 | from pickhost import __version__ as version 5 | 6 | 7 | class PyTest(TestCommand): 8 | def finalize_options(self): 9 | TestCommand.finalize_options(self) 10 | # Disable screen capturing because it doesn't work with 11 | # text-based UI 12 | self.test_args = ['-s'] 13 | self.test_suite = True 14 | 15 | def run_tests(self): 16 | import pytest 17 | errcode = pytest.main(self.test_args) 18 | sys.exit(errcode) 19 | 20 | 21 | with open('README.md', 'r') as f: 22 | long_description = f.read() 23 | 24 | setuptools.setup( 25 | name='pickhost', 26 | version=version, 27 | author='Huan Xiong', 28 | author_email='huan.xiong@outlook.com', 29 | description=('A simple SSH host manager in terminal'), 30 | entry_points={ 31 | 'console_scripts': ['pickhost=pickhost.__main__:main'] 32 | }, 33 | packages=['pickhost'], 34 | url='https://github.com/rayx/pickhost', 35 | license='GPLv3+', 36 | platforms='unix-like', 37 | long_description=long_description, 38 | long_description_content_type='text/markdown', 39 | install_requires=['pypick>=0.2.0'], 40 | tests_require=['pytest', 41 | 'flake8'], 42 | test_suite='tests', 43 | cmdclass={'test': PyTest}, 44 | include_package_data=True, 45 | classifiers=[ 46 | 'Development Status :: 4 - Beta', 47 | 'License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)', 48 | 'Intended Audience :: Developers', 49 | 'Intended Audience :: End Users/Desktop', 50 | 'Intended Audience :: System Administrators', 51 | 'Topic :: Software Development :: Libraries :: Python Modules', 52 | 'Environment :: Console', 53 | 'Operating System :: POSIX', 54 | 'Operating System :: Unix', 55 | 'Operating System :: MacOS :: MacOS X', 56 | 'Programming Language :: Python :: 3', 57 | 'Programming Language :: Python :: 3.5', 58 | 'Programming Language :: Python :: 3.6', 59 | 'Programming Language :: Python :: 3.7']) 60 | -------------------------------------------------------------------------------- /tests/hosts: -------------------------------------------------------------------------------- 1 | 2 | server-5 = rayx@10.64.4.5 #centos 7.4 3 | server-5->vm-37 = root,rayx@192.168.122.37 #qemu: virtio-block 4 | 5 | [ARM servers] 6 | server-5 = rayx@10.64.4.5 #centos 7.4 7 | server-66 = root@10.64.4.66 #ubuntu 16.04 8 | server-5->vm-176 = root,rayx@192.168.122.176 #qemu: hotplugging 9 | server-5->vm-37 = root,rayx@192.168.122.37 #qemu: virtio-block 10 | server-5->vm-176->nested-vm! = root,rayx@191.168.56.18 #nested-vm 11 | 12 | [Benchmarking] 13 | arm-server! = rayx,root@10.64.37.55 #Centriq 2400, 192G RAM 14 | x86-server = rayx,root@10.64.37.51 #Xeon Gold 5118, 192G RAM 15 | test-client = rayx,root@10.64.37.182 16 | 17 | [VMs on My Laptop] 18 | vm1 = root,rayx@192.168.182.218 #devstack 19 | vm2 = root@192.168.182.200 #latest kernel 20 | 21 | [Google Cloud] 22 | myserver = rayx@104.199.155.188 #vpn, etc. 23 | localhost = rayx@127.0.0.1 24 | 25 | -------------------------------------------------------------------------------- /tests/test_pickhost.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | from pypick import Pick 3 | 4 | def test_basic(): 5 | subprocess.call(['pickhost', '-f', 'tests/hosts']) 6 | --------------------------------------------------------------------------------