Merge pull request #422 from cheat/refactor

Refactor
This commit is contained in:
Chris Allen Lane 2019-02-04 09:58:24 -05:00 committed by GitHub
commit aa33a36491
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 322 additions and 336 deletions

View File

@ -83,13 +83,13 @@ with your [dotfiles][].
Configuring
-----------
### Setting a CHEAT_DEFAULT_DIR ###
### Setting a CHEAT_USER_DIR ###
Personal cheatsheets are saved in the `~/.cheat` directory by default, but you
can specify a different default by exporting a `CHEAT_DEFAULT_DIR` environment
can specify a different default by exporting a `CHEAT_USER_DIR` environment
variable:
```sh
export CHEAT_DEFAULT_DIR='/path/to/my/cheats'
export CHEAT_USER_DIR='/path/to/my/cheats'
```
### Setting a CHEAT_PATH ###

View File

@ -13,7 +13,7 @@ Usage:
cheat -v
Options:
-d --directories List directories on CHEATPATH
-d --directories List directories on $CHEAT_PATH
-e --edit Edit cheatsheet
-l --list List cheatsheets
-s --search Search cheatsheets for <keyword>
@ -36,27 +36,57 @@ Examples:
# require the dependencies
from __future__ import print_function
from cheat.sheets import Sheets
from cheat.sheet import Sheet
from cheat.utils import Utils
from cheat.colorize import Colorize
from cheat.configuration import Configuration
from cheat.sheet import Sheet
from cheat.sheets import Sheets
from cheat.utils import Utils
from docopt import docopt
import os
if __name__ == '__main__':
# parse the command-line options
options = docopt(__doc__, version='cheat 2.4.2')
# initialize and validate configs
config = Configuration()
config.validate()
# create the CHEAT_USER_DIR if it does not exist
if not os.path.isdir(config.cheat_user_dir):
try:
os.mkdir(config.cheat_user_dir)
except OSError:
Utils.die("%s %s %s" % (
'Could not create CHEAT_USER_DIR (',
config.cheat_user_dir,
')')
)
# assert that the CHEAT_USER_DIR is readable and writable
if not os.access(config.cheat_user_dir, os.R_OK):
Utils.die("%s %s %s" % (
'The CHEAT_USER_DIR (',
config.cheat_user_dir,
') is not readable')
)
if not os.access(config.cheat_user_dir, os.W_OK):
Utils.die("%s %s %s" % (
'The CHEAT_USER_DIR (',
config.cheat_user_dir,
') is not writeable')
)
# bootsrap
sheets = Sheets(config)
utils = Utils(config)
sheet = Sheet(sheets, utils)
sheet = Sheet(config, sheets)
colorize = Colorize(config)
# list directories
if options['--directories']:
print("\n".join(sheets.paths()))
print("\n".join(sheets.directories()))
# list cheatsheets
elif options['--list']:
@ -64,12 +94,12 @@ if __name__ == '__main__':
# create/edit cheatsheet
elif options['--edit']:
sheet.create_or_edit(options['<cheatsheet>'])
sheet.edit(options['<cheatsheet>'])
# search among the cheatsheets
elif options['--search']:
print(utils.colorize(sheets.search(options['<keyword>'])), end="")
print(colorize.syntax(sheets.search(options['<keyword>'])), end="")
# print the cheatsheet
else:
print(utils.colorize(sheet.read(options['<cheatsheet>'])), end="")
print(colorize.syntax(sheet.read(options['<cheatsheet>'])), end="")

View File

@ -1,4 +0,0 @@
from . import sheet
from . import sheets
from . import utils
from . import configuration

62
cheat/colorize.py Normal file
View File

@ -0,0 +1,62 @@
from __future__ import print_function
import sys
class Colorize:
def __init__(self, config):
self._config = config
def search(self, needle, haystack):
""" Colorizes search results matched within a line """
# if a highlight color is not configured, exit early
if not self._config.cheat_highlight:
return haystack
# otherwise, attempt to import the termcolor library
try:
from termcolor import colored
# if the import fails, return uncolored text
except ImportError:
return haystack
# if the import succeeds, colorize the needle in haystack
return haystack.replace(needle,
colored(needle, self._config.cheat_highlight))
def syntax(self, sheet_content):
""" Applies syntax highlighting """
# only colorize if cheat_colors is true, and stdout is a tty
if self._config.cheat_colors is False or not sys.stdout.isatty():
return sheet_content
# don't attempt to colorize an empty cheatsheet
if not sheet_content.strip():
return ""
# otherwise, attempt to import the pygments library
try:
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import TerminalFormatter
# if the import fails, return uncolored text
except ImportError:
return sheet_content
# otherwise, attempt to colorize
first_line = sheet_content.splitlines()[0]
lexer = get_lexer_by_name('bash')
# apply syntax-highlighting if the first line is a code-fence
if first_line.startswith('```'):
sheet_content = '\n'.join(sheet_content.split('\n')[1:-2])
try:
lexer = get_lexer_by_name(first_line[3:])
except Exception:
pass
return highlight(sheet_content, lexer, TerminalFormatter())

View File

@ -1,134 +1,107 @@
import os
from cheat.utils import Utils
import json
import os
class Configuration:
def __init__(self):
self._get_global_conf_file_path()
self._get_local_conf_file_path()
self._saved_configuration = self._get_configuration()
def _get_configuration(self):
# get options from config files and environment vairables
merged_config = {}
# compute the location of the config files
config_file_path_global = self._select([
os.environ.get('CHEAT_GLOBAL_CONF_PATH'),
'/etc/cheat',
])
config_file_path_local = self._select([
os.environ.get('CHEAT_LOCAL_CONF_PATH'),
os.path.expanduser('~/.config/cheat/cheat'),
])
# attempt to read the global config file
config = {}
try:
merged_config.update(
self._read_configuration_file(self.glob_config_path)
)
config.update(self._read_config_file(config_file_path_global))
except Exception as e:
Utils.warn('error while parsing global configuration Reason: '
+ e.message
)
Utils.warn('Error while parsing global configuration: '
+ e.message)
# attempt to read the local config file
try:
merged_config.update(
self._read_configuration_file(self.local_config_path)
)
config.update(self._read_config_file(config_file_path_local))
except Exception as e:
Utils.warn('error while parsing user configuration Reason: '
+ e.message
)
Utils.warn('Error while parsing local configuration: ' + e.message)
merged_config.update(self._read_env_vars_config())
# With config files read, now begin to apply envvar overrides and
# default values
self._check_configuration(merged_config)
# self.cheat_colors
self.cheat_colors = self._select([
Utils.boolify(os.environ.get('CHEAT_COLORS')),
Utils.boolify(os.environ.get('CHEATCOLORS')),
Utils.boolify(config.get('CHEAT_COLORS')),
True,
])
return merged_config
# self.cheat_user_dir
self.cheat_user_dir = self._select([
os.environ.get('CHEAT_USER_DIR'),
os.environ.get('CHEAT_DEFAULT_DIR'),
os.environ.get('DEFAULT_CHEAT_DIR'),
# TODO: XDG home?
os.path.expanduser(
os.path.expandvars(os.path.join('~', '.cheat'))
),
])
def _read_configuration_file(self, path):
# Reads configuration file and returns list of set variables
read_config = {}
if (os.path.isfile(path)):
# self.cheat_editor
self.cheat_editor = self._select([
os.environ.get('CHEAT_EDITOR'),
os.environ.get('EDITOR'),
os.environ.get('VISUAL'),
config.get('CHEAT_EDITOR'),
'vi',
])
# self.cheat_highlight
self.cheat_highlight = self._select([
os.environ.get('CHEAT_HIGHLIGHT'),
config.get('CHEAT_HIGHLIGHT'),
False,
])
if isinstance(self.cheat_highlight, str):
Utils.boolify(self.cheat_highlight)
# self.cheat_path
self.cheat_path = self._select([
os.environ.get('CHEAT_PATH'),
os.environ.get('CHEATPATH'),
config.get('CHEAT_PATH'),
'/usr/share/cheat',
])
def _read_config_file(self, path):
""" Reads configuration file and returns list of set variables """
config = {}
if os.path.isfile(path):
with open(path) as config_file:
read_config.update(json.load(config_file))
return read_config
config.update(json.load(config_file))
return config
def _read_env_vars_config(self):
read_config = {}
def _select(self, values):
for v in values:
if v is not None:
return v
# NOTE: These variables are left here because of backwards
# compatibility and are supported only as env vars but not in
# configuration file
def validate(self):
""" Validates configuration parameters """
if (os.environ.get('VISUAL')):
read_config['EDITOR'] = os.environ.get('VISUAL')
# variables supported both in environment and configuration file
# NOTE: Variables without CHEAT_ prefix are legacy
# key is variable name and value is its legacy_alias
# if variable has no legacy alias then set to None
variables = {'CHEAT_DEFAULT_DIR': 'DEFAULT_CHEAT_DIR',
'CHEAT_PATH': 'CHEATPATH',
'CHEAT_COLORS': 'CHEATCOLORS',
'CHEAT_EDITOR': 'EDITOR',
'CHEAT_HIGHLIGHT': None
}
for (k, v) in variables.items():
self._read_env_var(read_config, k, v)
return read_config
def _check_configuration(self, config):
""" Check values in config and warn user or die """
# validate CHEAT_HIGHLIGHT values if set
colors = [
# assert that cheat_highlight contains a valid value
highlights = [
'grey', 'red', 'green', 'yellow',
'blue', 'magenta', 'cyan', 'white'
'blue', 'magenta', 'cyan', 'white',
False
]
if (
config.get('CHEAT_HIGHLIGHT') and
config.get('CHEAT_HIGHLIGHT') not in colors
):
Utils.die("%s %s" % ('CHEAT_HIGHLIGHT must be one of:', colors))
if self.cheat_highlight not in highlights:
Utils.die("%s %s" %
('CHEAT_HIGHLIGHT must be one of:', highlights))
def _read_env_var(self, current_config, key, alias=None):
if os.environ.get(key) is not None:
current_config[key] = os.environ.get(key)
return
elif alias is not None and os.environ.get(alias) is not None:
current_config[key] = os.environ.get(alias)
return
def _get_global_conf_file_path(self):
self.glob_config_path = (os.environ.get('CHEAT_GLOBAL_CONF_PATH')
or '/etc/cheat')
def _get_local_conf_file_path(self):
path = (os.environ.get('CHEAT_LOCAL_CONF_PATH')
or os.path.expanduser('~/.config/cheat/cheat'))
self.local_config_path = path
def _choose_value(self, primary_value_name, secondary_value_name):
""" Return primary or secondary value in saved_configuration
If primary value is in configuration then return it. If it is not
then return secondary. In the absence of both values return None
"""
primary_value = self._saved_configuration.get(primary_value_name)
secondary_value = self._saved_configuration.get(secondary_value_name)
if primary_value is not None:
return primary_value
else:
return secondary_value
def get_default_cheat_dir(self):
return self._choose_value('CHEAT_DEFAULT_DIR', 'DEFAULT_CHEAT_DIR')
def get_cheatpath(self):
return self._choose_value('CHEAT_PATH', 'CHEATPATH')
def get_cheatcolors(self):
return self._choose_value('CHEAT_COLORS', 'CHEATCOLORS')
def get_editor(self):
return self._choose_value('CHEAT_EDITOR', 'EDITOR')
def get_highlight(self):
return self._saved_configuration.get('CHEAT_HIGHLIGHT')
return True

29
cheat/editor.py Normal file
View File

@ -0,0 +1,29 @@
from __future__ import print_function
from cheat.utils import Utils
import subprocess
class Editor:
def __init__(self, config):
self._config = config
def editor(self):
""" Determines the user's preferred editor """
# assert that the editor is set
if not self._config.cheat_editor:
Utils.die(
'You must set a CHEAT_EDITOR, VISUAL, or EDITOR environment '
'variable or setting in order to create/edit a cheatsheet.'
)
return self._config.cheat_editor
def open(self, filepath):
""" Open `filepath` using the EDITOR specified by the env variables """
editor_cmd = self.editor().split()
try:
subprocess.call(editor_cmd + [filepath])
except OSError:
Utils.die('Could not launch ' + self.editor())

View File

@ -1,79 +1,64 @@
from cheat.editor import Editor
from cheat.utils import Utils
import io
import os
import shutil
from cheat.utils import Utils
class Sheet:
def __init__(self, sheets, utils):
def __init__(self, config, sheets):
self._config = config
self._editor = Editor(config)
self._sheets = sheets
self._utils = utils
def copy(self, current_sheet_path, new_sheet_path):
""" Copies a sheet to a new path """
# attempt to copy the sheet to DEFAULT_CHEAT_DIR
try:
shutil.copy(current_sheet_path, new_sheet_path)
# fail gracefully if the cheatsheet cannot be copied. This can happen
# if DEFAULT_CHEAT_DIR does not exist
except IOError:
Utils.die('Could not copy cheatsheet for editing.')
def create_or_edit(self, sheet):
""" Creates or edits a cheatsheet """
# if the cheatsheet does not exist
if not self.exists(sheet):
self.create(sheet)
# if the cheatsheet exists but not in the default_path, copy it to the
# default path before editing
elif self.exists(sheet) and not self.exists_in_default_path(sheet):
self.copy(self.path(sheet),
os.path.join(self._sheets.default_path(), sheet))
self.edit(sheet)
# if it exists and is in the default path, then just open it
else:
self.edit(sheet)
def create(self, sheet):
""" Creates a cheatsheet """
new_sheet_path = os.path.join(self._sheets.default_path(), sheet)
self._utils.open_with_editor(new_sheet_path)
def edit(self, sheet):
""" Opens a cheatsheet for editing """
self._utils.open_with_editor(self.path(sheet))
def exists(self, sheet):
def _exists(self, sheet):
""" Predicate that returns true if the sheet exists """
return (sheet in self._sheets.get() and
os.access(self.path(sheet), os.R_OK))
os.access(self._path(sheet), os.R_OK))
def exists_in_default_path(self, sheet):
def _exists_in_default_path(self, sheet):
""" Predicate that returns true if the sheet exists in default_path"""
default_path_sheet = os.path.join(self._sheets.default_path(), sheet)
default_path = os.path.join(self._config.cheat_user_dir, sheet)
return (sheet in self._sheets.get() and
os.access(default_path_sheet, os.R_OK))
os.access(default_path, os.R_OK))
def is_writable(self, sheet):
""" Predicate that returns true if the sheet is writeable """
return (sheet in self._sheets.get() and
os.access(self.path(sheet), os.W_OK))
def path(self, sheet):
def _path(self, sheet):
""" Returns a sheet's filesystem path """
return self._sheets.get()[sheet]
def edit(self, sheet):
""" Creates or edits a cheatsheet """
# if the cheatsheet does not exist
if not self._exists(sheet):
new_path = os.path.join(self._config.cheat_user_dir, sheet)
self._editor.open(new_path)
# if the cheatsheet exists but not in the default_path, copy it to the
# default path before editing
elif self._exists(sheet) and not self._exists_in_default_path(sheet):
try:
shutil.copy(
self._path(sheet),
os.path.join(self._config.cheat_user_dir, sheet)
)
# fail gracefully if the cheatsheet cannot be copied. This can
# happen if CHEAT_USER_DIR does not exist
except IOError:
Utils.die('Could not copy cheatsheet for editing.')
self._editor.open(self._path(sheet))
# if it exists and is in the default path, then just open it
else:
self._editor.open(self._path(sheet))
def read(self, sheet):
""" Returns the contents of the cheatsheet as a String """
if not self.exists(sheet):
if not self._exists(sheet):
Utils.die('No cheatsheet found for ' + sheet)
with io.open(self.path(sheet), encoding='utf-8') as cheatfile:
with io.open(self._path(sheet), encoding='utf-8') as cheatfile:
return cheatfile.read()

View File

@ -1,55 +1,34 @@
from cheat.colorize import Colorize
from cheat.utils import Utils
import io
import os
from cheat.utils import Utils
class Sheets:
def __init__(self, config):
self._default_cheat_dir = config.get_default_cheat_dir()
self._cheatpath = config.get_cheatpath()
self._utils = Utils(config)
self._config = config
self._colorize = Colorize(config)
def default_path(self):
""" Returns the default cheatsheet path """
# Assembles a dictionary of cheatsheets as name => file-path
self._sheets = {}
sheet_paths = [
config.cheat_user_dir
]
# determine the default cheatsheet dir
default_sheets_dir = (self._default_cheat_dir or
os.path.join('~', '.cheat'))
default_sheets_dir = os.path.expanduser(
os.path.expandvars(default_sheets_dir))
# merge the CHEAT_PATH paths into the sheet_paths
if config.cheat_path:
for path in config.cheat_path.split(os.pathsep):
if os.path.isdir(path):
sheet_paths.append(path)
# create the DEFAULT_CHEAT_DIR if it does not exist
if not os.path.isdir(default_sheets_dir):
try:
# @kludge: unclear on why this is necessary
os.umask(0000)
os.mkdir(default_sheets_dir)
except OSError:
Utils.die('Could not create DEFAULT_CHEAT_DIR')
# assert that the DEFAULT_CHEAT_DIR is readable and writable
if not os.access(default_sheets_dir, os.R_OK):
Utils.die('The DEFAULT_CHEAT_DIR ('
+ default_sheets_dir
+ ') is not readable.')
if not os.access(default_sheets_dir, os.W_OK):
Utils.die('The DEFAULT_CHEAT_DIR ('
+ default_sheets_dir
+ ') is not writable.')
# return the default dir
return default_sheets_dir
def get(self):
""" Assembles a dictionary of cheatsheets as name => file-path """
cheats = {}
if not sheet_paths:
Utils.die('The CHEAT_USER_DIR dir does not exist '
+ 'or the CHEAT_PATH is not set.')
# otherwise, scan the filesystem
for cheat_dir in reversed(self.paths()):
cheats.update(
for cheat_dir in reversed(sheet_paths):
self._sheets.update(
dict([
(cheat, os.path.join(cheat_dir, cheat))
for cheat in os.listdir(cheat_dir)
@ -58,26 +37,22 @@ class Sheets:
])
)
return cheats
def paths(self):
def directories(self):
""" Assembles a list of directories containing cheatsheets """
sheet_paths = [
self.default_path(),
self._config.cheat_user_dir,
]
# merge the CHEATPATH paths into the sheet_paths
if self._cheatpath:
for path in self._cheatpath.split(os.pathsep):
if os.path.isdir(path):
sheet_paths.append(path)
if not sheet_paths:
Utils.die('The DEFAULT_CHEAT_DIR dir does not exist '
+ 'or the CHEATPATH is not set.')
for path in self._config.cheat_path.split(os.pathsep):
sheet_paths.append(path)
return sheet_paths
def get(self):
""" Returns a dictionary of cheatsheets as name => file-path """
return self._sheets
def list(self):
""" Lists the available cheatsheets """
sheet_list = ''
@ -94,7 +69,7 @@ class Sheets:
match = ''
for line in io.open(cheatsheet[1], encoding='utf-8'):
if term in line:
match += ' ' + self._utils.highlight(term, line)
match += ' ' + self._colorize.search(term, line)
if match != '':
result += cheatsheet[0] + ":\n" + match + "\n"

View File

@ -1,100 +1,26 @@
from __future__ import print_function
import os
import subprocess
import sys
class Utils:
def __init__(self, config):
self._displaycolors = config.get_cheatcolors()
self._editor_executable = config.get_editor()
self._highlight_color = config.get_highlight()
def highlight(self, needle, haystack):
""" Highlights a search term matched within a line """
# if a highlight color is not configured, exit early
if not self._highlight_color:
return haystack
# otherwise, attempt to import the termcolor library
try:
from termcolor import colored
# if the import fails, return uncolored text
except ImportError:
return haystack
# if the import succeeds, colorize the needle in haystack
return haystack.replace(needle, colored(needle, self._highlight_color))
def colorize(self, sheet_content):
""" Colorizes cheatsheet content if so configured """
# cover all possible positive values to be safe
positive_values = ["True", "true", "1", 1, True]
# only colorize if configured to do so, and if stdout is a tty
if (self._displaycolors not in positive_values or
not sys.stdout.isatty()):
return sheet_content
# don't attempt to colorize an empty cheatsheet
if not sheet_content.strip():
return ""
# otherwise, attempt to import the pygments library
try:
from pygments import highlight
from pygments.lexers import get_lexer_by_name
from pygments.formatters import TerminalFormatter
# if the import fails, return uncolored text
except ImportError:
return sheet_content
# otherwise, attempt to colorize
first_line = sheet_content.splitlines()[0]
lexer = get_lexer_by_name('bash')
# apply syntax-highlighting if the first line is a code-fence
if first_line.startswith('```'):
sheet_content = '\n'.join(sheet_content.split('\n')[1:-2])
try:
lexer = get_lexer_by_name(first_line[3:])
except Exception:
pass
return highlight(sheet_content, lexer, TerminalFormatter())
@staticmethod
def die(message):
""" Prints a message to stderr and then terminates """
Utils.warn(message)
exit(1)
def editor(self):
""" Determines the user's preferred editor """
# assert that the editor is set
if (not self._editor_executable):
Utils.die(
'You must set a CHEAT_EDITOR, VISUAL, or EDITOR environment '
'variable or setting in order to create/edit a cheatsheet.'
)
return self._editor_executable
def open_with_editor(self, filepath):
""" Open `filepath` using the EDITOR specified by the env variables """
editor_cmd = self.editor().split()
try:
subprocess.call(editor_cmd + [filepath])
except OSError:
Utils.die('Could not launch ' + self.editor())
@staticmethod
def warn(message):
""" Prints a message to stderr """
print((message), file=sys.stderr)
@staticmethod
def boolify(value):
""" Type-converts 'true' and 'false' to Booleans """
# if `value` is not a string, return it as-is
if not isinstance(value, str):
return value
# otherwise, convert "true" and "false" to Boolean counterparts
return value.strip().lower() == "true"

10
ci/lint.sh Executable file
View File

@ -0,0 +1,10 @@
#!/bin/sh
# Resolve the app root
SCRIPT=`realpath $0`
SCRIPTPATH=`dirname $SCRIPT`
APPROOT=`realpath "$SCRIPTPATH/.."`
flake8 $APPROOT/setup.py
flake8 $APPROOT/bin/cheat
flake8 $APPROOT/cheat/*.py

View File

@ -1,5 +1,5 @@
{
"CHEAT_COLORS": true,
"CHEAT_EDITOR":"vi",
"CHEAT_PATH":"/usr/share/cheat"
"CHEAT_COLORS" : true,
"CHEAT_EDITOR" : "vi",
"CHEAT_PATH" : "/usr/share/cheat"
}

View File

@ -9,31 +9,31 @@ cheat_path = os.environ.get('CHEAT_PATH') or '/usr/share/cheat'
# aggregate the systme-wide cheatsheets
cheat_files = []
for f in os.listdir('cheat/cheatsheets/'):
cheat_files.append(os.path.join('cheat/cheatsheets/',f))
cheat_files.append(os.path.join('cheat/cheatsheets/', f))
# specify build params
setup(
name = 'cheat',
version = '2.4.2',
author = 'Chris Lane',
author_email = 'chris@chris-allen-lane.com',
license = 'GPL3',
description = 'cheat allows you to create and view interactive cheatsheets '
name='cheat',
version='2.4.2',
author='Chris Lane',
author_email='chris@chris-allen-lane.com',
license='GPL3',
description='cheat allows you to create and view interactive cheatsheets '
'on the command-line. It was designed to help remind *nix system '
'administrators of options for commands that they use frequently, but not '
'frequently enough to remember.',
url = 'https://github.com/chrisallenlane/cheat',
packages = [
url='https://github.com/chrisallenlane/cheat',
packages=[
'cheat',
'cheat.test',
],
scripts = ['bin/cheat'],
install_requires = [
scripts=['bin/cheat'],
install_requires=[
'docopt >= 0.6.1',
'pygments >= 1.6.0',
'termcolor >= 1.1.0',
],
data_files = [
data_files=[
(cheat_path, cheat_files),
('/etc', ['config/cheat']),
],