├── .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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------