From 1cb66c8834e77a886c5db35085cc037ec615de4c Mon Sep 17 00:00:00 2001 From: Eric Torres Date: Fri, 23 Oct 2020 11:21:59 -0700 Subject: [PATCH] Initial commit --- .gitignore | 1 + README.rst | 0 bin/cptemplate | 71 ++++++++++++++++ bin/fedit | 107 +++++++++++++++++++++++ bin/quickdel | 177 +++++++++++++++++++++++++++++++++++++++ file_scripts/__init__.py | 0 file_scripts/editor.py | 76 +++++++++++++++++ file_scripts/error.py | 19 +++++ file_scripts/fzf.py | 45 ++++++++++ file_scripts/search.py | 80 ++++++++++++++++++ setup.py | 40 +++++++++ tests/__init__.py | 0 tests/test_editor.py | 29 +++++++ tests/test_fzf.py | 27 ++++++ tests/test_search.py | 90 ++++++++++++++++++++ 15 files changed, 762 insertions(+) create mode 100644 .gitignore create mode 100644 README.rst create mode 100755 bin/cptemplate create mode 100755 bin/fedit create mode 100755 bin/quickdel create mode 100644 file_scripts/__init__.py create mode 100644 file_scripts/editor.py create mode 100644 file_scripts/error.py create mode 100644 file_scripts/fzf.py create mode 100644 file_scripts/search.py create mode 100644 setup.py create mode 100644 tests/__init__.py create mode 100644 tests/test_editor.py create mode 100644 tests/test_fzf.py create mode 100644 tests/test_search.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6194e9c --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +**/*/__pycache__ diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..e69de29 diff --git a/bin/cptemplate b/bin/cptemplate new file mode 100755 index 0000000..0d9ceb0 --- /dev/null +++ b/bin/cptemplate @@ -0,0 +1,71 @@ +#!/usr/bin/python3 +""" +cptemplate - copy a template file from a specified template directory + +Dependencies +============ +* fd +* fzf +""" + +# Module Imports +import argparse +import shutil +import subprocess + +import file_scripts.fzf as fzf +import file_scripts.editor as editor +import file_scripts.search as search +import file_scripts.error as error + +from pathlib import Path +from sys import platform + + +# ========== Constants ========== +# ----- Paths ----- +DEFAULT_TEMPLATE_DIR = Path.home() / "Templates" + +# ========== Functions========== +# ========== Main Script ========== +if __name__ == "__main__": + if platform == "win32": + sys.exit(error.E_INTERRUPT) + + parser = argparse.ArgumentParser() + parser.add_argument( + "-d", + "--template-dir", + dest="dir", + type=str, + default=DEFAULT_TEMPLATerror.E_DIR, + help="choose a template directory (default: ~/Templates)", + ) + parser.add_argument( + "-f", + "--force", + dest="force_overwrite", + action="store_true", + help="overwrite dest if it exists", + ) + parser.add_argument("dest", type=str) + + args = parser.parse_args() + + files = search.find_files(directory=args.dir) + + try: + selected_file = fzf.select_file_with_fzf(files) + except KeyboardInterrupt: + exit(error.E_INTERRUPT) + + dest_file = Path(args.dest) + + try: + dest_file.touch(mode=0o600, exist_ok=False) + except FileExistsError: + raise NotImplementedError("Implement if file already exists") + else: + dest_file.write_bytes(selected_file.read_bytes()) + # force_overwrite true: overwrite + # force_overwrite false: print error message diff --git a/bin/fedit b/bin/fedit new file mode 100755 index 0000000..f6ffe7e --- /dev/null +++ b/bin/fedit @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +""" +Fuzzy-find a file and edit it. + +Dependencies +============ +* fd +* fzf +""" + +import argparse +import subprocess +from sys import platform + +import file_scripts.fzf as fzf +import file_scripts.editor as editor +import file_scripts.search as search +import file_scripts.error as error + +# ========== Constants ========== +# ----- Paths ----- +BOOT_DIR = "/boot" +ETC_DIR = "/etc" + +# ========== Functions ========== + + +# ========== Main Script ========== +if __name__ == "__main__": + # This script doesn't support Windows + if platform == "win32": + sys.exit(error.E_INTERRUPT) + + parser = argparse.ArgumentParser() + parser.add_argument( + "-b", + "--boot", + action="store_const", + const=BOOT_DIR, + dest="dir", + help="edit a file in /boot", + ) + parser.add_argument( + "-d", "--dir", dest="dir", type=str, help="edit a file in a given directory" + ) + parser.add_argument( + "-E", + "--etc", + action="store_const", + const=ETC_DIR, + dest="dir", + help="edit a file in /etc", + ) + parser.add_argument( + "-I", + "--no-ignore", + action="append_const", + const=search.EXTRA_FIND_OPTS["no_ignore"], + dest="extra_find_opts", + help="do not respect .(git|fd)ignore files", + ) + parser.add_argument( + "-i", + "--no-ignore-vcs", + action="append_const", + const=search.EXTRA_FIND_OPTS["no_ignore_vcs"], + dest="extra_find_opts", + help="do not respect .gitignore files", + ) + parser.add_argument("-e", "--editor", help="use a given editor") + parser.add_argument( + "patterns", type=str, nargs="*", help="patterns to pass to locate" + ) + + args = parser.parse_args() + + user_opts = [] if args.extra_find_opts is None else args.extra_find_opts + user_opts.extend(search.DEFAULT_FIND_OPTS) + + selected_editor = None + + try: + selected_editor = editor.select_editor(args.editor) + except FileNotFoundError as e: + print(e) + exit(error.E_NOEDITORFOUND) + + # If patterns were passed, use locate + # Otherwise check for -d and use fd + files = ( + search.find_files(opts=user_opts, directory=args.dir) + if not args.patterns + else search.locate_files(args.patterns) + ) + + try: + selected_file = fzf.select_file_with_fzf(files) + except KeyboardInterrupt: + exit(error.E_INTERRUPT) + except fzf.FZFError: + exit(error.E_NOFILESELECTED) + + if selected_file != "": + cmd = editor.gen_editor_cmd(selected_editor, selected_file) + subprocess.run(cmd) + else: + exit(error.E_NOFILESELECTED) diff --git a/bin/quickdel b/bin/quickdel new file mode 100755 index 0000000..a1774b0 --- /dev/null +++ b/bin/quickdel @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +quickdel - delete any file matching a query + +Dependencies +============ +* fd +* python-termcolor + +Command-Line Arguments +====================== +* -d, --directories-only +* -D, --directory TODO: implement this +* -e, --empty-only +* -E, --extension +* -f, --files-only +* -F, --force-directory-delete +* -i, --no-ignore +* -I, --no-ignore-vcs +* -l, --links-only +""" + +import argparse +import os +import os.path +import re +import shutil +import file_scripts.error as error +import file_scripts.search as search + +from termcolor import colored + +# ========== Constants ========== +fd_opts = ["--hidden"] +# Matches 'y' or 'yes' only, ignoring case +USER_RESPONSE_YES = r"^[Yy]{1}([Ee]{1}[Ss]{1})?$" + + +# ========== Functions ========== +def color_file(filename): + """Return correct color code for filetype of filename. + + Example + ------- + >>> color_file('Test File', 'red') + '\x1b[31mTest String\x1b[0m' + + :param filename: file to determine color output for + :type filename: str + :return: filename with ANSII escape codes for color + :rtype: str + """ + if os.path.isdir(filename): + return colored(filename, "blue") + elif os.path.islink(filename): + return colored(filename, "green") + else: + return filename + + +# ========== Main Script ========== +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument( + "-d", + "--directories-only", + action="store_const", + const=["--type", "directory"], + dest="fd_extra_opts", + help="filter results to directories", + ) + parser.add_argument( + "-e", + "--empty-only", + action="store_const", + const=["--type", "empty"], + dest="fd_extra_opts", + help="filter results to empty files and directories", + ) + parser.add_argument( + "-E", + "--extension", + action="append", + dest="extensions", + help="file extension", + metavar="ext", + ) + parser.add_argument( + "-f", + "--files-only", + action="store_const", + const=["--type", "file"], + dest="fd_extra_opts", + help="filter results to files", + ) + parser.add_argument( + "-F", + "--force-directory-delete", + action="store_true", + help="do not ignore non-empty directories, delete anyways", + ) + parser.add_argument( + "-I", + "--no-ignore-vcs", + action="store_const", + const="--no-ignore-vcs", + dest="fd_extra_opts", + help="do not ignore .gitignore", + ) + parser.add_argument( + "-i", + "--no-ignore", + action="store_const", + const="--no-ignore", + dest="fd_extra_opts", + help="do not ignore .gitignore and .fdignore", + ) + parser.add_argument( + "-l", + "--links-only", + action="store_const", + const=["--type", "symlink"], + dest="fd_extra_opts", + help="filter results to symlinks", + ) + parser.add_argument("patterns", nargs="+", help="file matching patterns") + + args = parser.parse_args() + + if args.fd_extra_opts is not None: + fd_opts.extend(args.fd_extra_opts) + if args.extensions is not None: + for ext in args.extensions: + fd_opts.extend(["--extension", ext]) + + files = set() + for p in args.patterns: + files.update( + search.find_files(opts=fd_opts, capture_text=True, pattern=p).splitlines() + ) + files = sorted(files) + + if files == []: + print(f"No results found, exiting") + exit(error.E_NO_RESULTS) + + # Pretty print all filenames + for index, filename in enumerate([color_file(f) for f in files], 1): + print(f"{index}. {filename}") + # Padding line + print() + + try: + user_response = input("Would you like to delete these files? ") + except KeyboardInterrupt: + exit(error.E_INTERRUPT) + + if re.match(USER_RESPONSE_YES, user_response) is None: + print("Operation cancelled") + exit(error.E_USER_RESPONSE_NO) + + # Remove files first + for f in [fi for fi in files if os.path.isfile(fi)]: + os.remove(f) + + # Check -f, --force-directory-delete option + rmdir_func = shutil.rmtree if args.force_directory_delete else os.rmdir + + for d in filter(os.path.isdir, files): + try: + rmdir_func(d) + except OSError: + print( + f"{colored('Warning', 'yellow')}: {colored(d, 'blue')} is not empty, not deleting directory" + ) + + print(colored("\nDeletions complete", "green")) diff --git a/file_scripts/__init__.py b/file_scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/file_scripts/editor.py b/file_scripts/editor.py new file mode 100644 index 0000000..ad9fd15 --- /dev/null +++ b/file_scripts/editor.py @@ -0,0 +1,76 @@ +""" +.. moduleauthor:: Eric Torres +.. module:: file_scripts.editor + :synopsis: Helper functions for interacting with system editors +""" +# ========== Imports ========== +import os +import shutil + +from pathlib import Path + +# ========== Constants ========== +SUDO_COMMAND = "sudo" + +# ========== Functions ========== +def select_editor(override=None): + """Return a canonical path to an editor. + + Select an editor from one of: + * -e, --editor + * $EDITOR + * Default of vim + + In this order + + If an editor cannot be resolved, then a FileNotFoundError is raised instead. + + :param override: argument to override an editor + :returns: path to one of these editors + :rtype: str + :raises: FileNotFoundError if an editor could not be resolved + """ + editor = None + + if override is not None: + editor = shutil.which(override) + elif "EDITOR" in os.environ: + editor = shutil.which(os.environ.get("EDITOR")) + elif shutil.which("vim") is not None: + editor = shutil.which("vim") + + if editor is None: + raise FileNotFoundError("An editor could not be resolved") + + return editor + + +def gen_editor_cmd(editor, filename): + """Generate a command line to run for editing a file based on + permissions. This command line is suitable for passing to + subprocess.run(). + + This command does not pass extra options to the editor, hence + there are no arguments to pass for options. + + Conditions + ---------- + * Path exists: use normal command + * Path does not exist: use normal command + * Path exists but is not readable by current process: use SUDO_COMMAND + + + :param editor: path of editor + :type editor: str or path-like object + :param filename: path/name of file to edit + :type filename: str or path-like object + :returns: command to execute to edit file + :rtype: list + """ + # Possible for a race condition to occur here + # What happens if the file or its metadata changes? + path = Path(filename) + if path.exists() and not os.access(path, os.W_OK): + return [SUDO_COMMAND, "--edit", filename] + else: + return [editor, filename] diff --git a/file_scripts/error.py b/file_scripts/error.py new file mode 100644 index 0000000..e440349 --- /dev/null +++ b/file_scripts/error.py @@ -0,0 +1,19 @@ +""" +.. moduleauthor:: Eric Torres +.. module:: file_scripts:error + :synopsis: Exit codes and error handling. +""" +# ========== Imports ========== + +# ========== Constants ========== +# ----- Exit Codes ----- +E_INTERRUPT = 1 +E_NOFILESELECTED = 2 +E_NOEDITORFOUND = 3 +E_NO_RESULTS = 4 +E_USER_RESPONSE_NO = 5 + +# ----- Messages ----- +NO_FILE_SELECTED_MESSAGE = "No file was selected." + +# ========== Functions ========== diff --git a/file_scripts/fzf.py b/file_scripts/fzf.py new file mode 100644 index 0000000..a283636 --- /dev/null +++ b/file_scripts/fzf.py @@ -0,0 +1,45 @@ +""" +.. moduleauthor:: Eric Torres +.. module:: files-scripts.fzf + :synopsis: Helper functions for interacting with fzf +""" +# ========== Imports ========== +import shutil +import subprocess + +import file_scripts.error as error + +from pathlib import Path + +# ========== Constants ========== +# ----- Commands ----- +# Options: read null terminator, auto-select if one option, exit if no options, print null terminator +FZF_CMD = shutil.which("fzf") +FZF_OPTS = ["--read0", "--select-1", "--exit-0", "--print0"] + +# ----- Misc. ----- +LOCALE = "utf-8" + + +class FZFError(Exception): + pass + + +# ========== Functions ========== +def select_file_with_fzf(files): + """Run fzf on a stream of searched files for the user to select. + + :param files: stream of null-terminated files to read + :type files: bytes stream (stdout of a completed process) + :returns: selected file + :rtype: path-like object + :raises: FZFError if there was an error with fzf or no file was selected + """ + output = subprocess.run([FZF_CMD, *FZF_OPTS], input=files, stdout=subprocess.PIPE) + + try: + output.check_returncode() + except subprocess.CalledProcessError as e: + raise FZFError(error.NO_FILE_SELECTED_MESSAGE) from e + else: + return Path(selected_file.stdout.decode(LOCALE).strip("\x00")) diff --git a/file_scripts/search.py b/file_scripts/search.py new file mode 100644 index 0000000..18c3252 --- /dev/null +++ b/file_scripts/search.py @@ -0,0 +1,80 @@ +""" +.. moduleauthor:: Eric Torres +.. module:: package.module + :synopsis: Short and succinct module description +""" +# ========== Imports ========== +import shutil +import subprocess + +from sys import platform + +# ========== Constants ========== +# ----- Commands ----- +# Options: show hidden files, null terminator, files only +# Optional arguments: show vcs files, show every file +FIND_CMD = shutil.which("fd") +DEFAULT_FIND_OPTS = ["--hidden", "--print0", "--type", "f", "--type", "l"] +EXTRA_FIND_OPTS = {"no_ignore": "--no-ignore", "no_ignore_vcs": "--no-ignore-vcs"} + +# Options: null terminator, ignore case, print names matching all non-option arguments +LOCATE_CMD = shutil.which("locate") + +# Platform-specific options +# macOS doesn't support GNU-style long options +LOCATE_OPTS = [] +if platform == "linux": + LOCATE_OPTS = ["--all", "--ignore-case", "--null"] +elif platform == "darwin": + LOCATE_OPTS = ["-0", "-i"] + +# ========== Functions ========== +def find_files( + opts=DEFAULT_FIND_OPTS, + directory=None, + capture_text=None, + bin_override=None, + pattern=None, +): + """Use a find-based program to locate files. The returned data is + the stdout of the completed process. + + :param opts: options to pass to the find program + :type opts: list of str + :param directory: directory to search for files + :type directory: str + :param capture_text: capture output as text + :type capture_text: bool + :param bin_override: override find binary + :type bin_override: str + :param pattern: patterns to pass to fd + :type pattern: str + :returns: path of user-selected file + :rtype: bytes, str if capture_text was initialized from None + """ + cmd = [bin_override if bin_override is not None else FIND_CMD, *opts] + + cmd.append("--") + if pattern is not None: + cmd.append(pattern) + cmd.append(directory if directory is not None else ".") + + return subprocess.run(cmd, capture_output=True, text=capture_text).stdout + + +def locate_files(patterns, bin_override=None, capture_text=None): + """Use a locate-based program to locate files, then pass to fzf. + + :param patterns: patterns to pass to locate + :type patterns: list + :param bin_override: override find binary + :type bin_override: str + :param capture_text: capture output as text + :type capture_text: bool + :returns: path of user-selected file + :rtype: bytes, str if capture_text was initialized from None + """ + cmd = [bin_override if bin_override is not None else LOCATE_CMD, *LOCATE_OPTS] + cmd.extend(patterns) + + return subprocess.run(cmd, capture_output=True, text=capture_text).stdout diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..09ded36 --- /dev/null +++ b/setup.py @@ -0,0 +1,40 @@ +import setuptools +from sphinx.setup_command import BuildDoc + +# ========== Constants ========== +EXCLUDED_PACKAGES = ["test", "tests"] +PACKAGES = setuptools.find_packages(exclude=EXCLUDED_PACKAGES) +DEPENDENCIES = ["termcolor"] +SCRIPTS = ["bin/fedit", "bin/cptemplate", "bin/quickdel"] +CMDCLASS = {"build_sphinx": BuildDoc} + +# ========== Functions ========== +with open("README.rst", "r") as fh: + long_description = fh.read() + +# ========== Package Setup ========== +setuptools.setup( + name="file_scripts", + version="0.1", + author="Eric Torres", + author_email="erictorres4@protonmail.com", + description="File-related helper scripts", + long_description=long_description, + long_description_content_type="text/markdown", + url="https://gitlab.com/pypa/sampleproject", + packages=PACKAGES, + scripts=SCRIPTS, + classifiers=[ + "Programming Language :: Python :: 3", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", + ], + command_options={ + "build_sphinx": { + "project": ("setup.py", "name"), + "version": ("setup.py", "version"), + "release": ("setup.py", "release"), + "source_dir": ("setup.py", "doc"), + } + }, +) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_editor.py b/tests/test_editor.py new file mode 100644 index 0000000..b66ed25 --- /dev/null +++ b/tests/test_editor.py @@ -0,0 +1,29 @@ +""" +.. moduleauthor:: Eric Torres + +Tests for the rbackup.config module. +""" +import unittest +from unittest.mock import patch + +import file_scripts.editor as editor + +# ========== Constants ========== +# ========== Tests ========== + +"""Test the select_editor() function. + + Test cases + ----------- + * Returned object is an instance of str + * If override is not None, ensure that it is in the result + +""" +class TestSelectEditor(unittest.TestCase): + def test_returns_path_object(self): + self.assertIsInstance(editor.select_editor(), str) + + def test_override(self): + override = "doesnotexist" + with self.assertRaises(FileNotFoundError): + editor.select_editor(override) diff --git a/tests/test_fzf.py b/tests/test_fzf.py new file mode 100644 index 0000000..449bc1a --- /dev/null +++ b/tests/test_fzf.py @@ -0,0 +1,27 @@ +""" +.. moduleauthor:: Eric Torres +:synopsis: Unit tests for the fzf module. +""" +import subprocess +import unittest +from pathlib import Path +from unittest.mock import patch + +import file_scripts.fzf as fzf + +# ========== Constants ========== +TESTING_PACKAGE = "file_scripts" +TESTING_MODULE = f"{TESTING_PACKAGE}.fzf" + +# ========== Test Cases ========== +class TestRunFZF(unittest.TestCase): + def setUp(self): + self.patched_subprocess = patch(f"{TESTING_MODULE}.subprocess.run") + + self.mocked_run = self.patched_subprocess.start() + + def test_creates_pathlike_object(self): + self.assertIsInstance(fzf.select_file_with_fzf(b'test'), Path) + + def tearDown(self): + patch.stopall() diff --git a/tests/test_search.py b/tests/test_search.py new file mode 100644 index 0000000..737d9d0 --- /dev/null +++ b/tests/test_search.py @@ -0,0 +1,90 @@ +""" +.. moduleauthor:: Eric Torres + +Tests for the rbackup.config module. +""" +import subprocess +import unittest +from unittest.mock import patch + +from hypothesis import given +from hypothesis.strategies import text, lists + +import file_scripts.search as search + +# ========== Constants ========== +TESTING_PACKAGE = "file_scripts" +TESTING_MODULE = f"{TESTING_PACKAGE}.search" + +# ========== Tests ========== +"""Test the find_files() function. + + Test cases + ----------- + * Both cases of capture_text + * bin_override behaves correctly + * directory is not None +""" + + +class TestFindFiles(unittest.TestCase): + def setUp(self): + self.patched_subprocess = patch(f"{TESTING_MODULE}.subprocess.run") + + self.mocked_run = self.patched_subprocess.start() + + @given(test_override=text(), test_opts=lists(text())) + def test_bin_override(self, test_override, test_opts): + search.find_files(opts=test_opts, bin_override=test_override) + self.mocked_run.assert_called_with( + [test_override, *test_opts], capture_output=True, text=None + ) + + @given(text()) + def test_directory_override(self, d): + test_override = "fd_bin" + test_opts = ["option1", "option2"] + expected_dir_options = ["--", ".", d] + search.find_files(opts=test_opts, directory=d, bin_override=test_override) + self.mocked_run.assert_called_with( + [test_override, *test_opts, *expected_dir_options], + capture_output=True, + text=None, + ) + + def test_default_directory(self): + test_override = "fd_bin" + test_opts = ["option1"] + expected_cmd = [test_override, *test_opts] + search.find_files(opts=test_opts, bin_override=test_override) + self.mocked_run.assert_called_with(expected_cmd, capture_output=True, text=None) + + def tearDown(self): + patch.stopall() + + +"""Test search.locate_files() +""" + + +class LocateFiles(unittest.TestCase): + def setUp(self): + self.patched_locate_opts = patch(f"{TESTING_MODULE}") + self.patched_subprocess = patch(f"{TESTING_MODULE}.subprocess.run") + + self.mocked_locate_opts = self.patched_subprocess.start() + self.mocked_run = self.patched_subprocess.start() + + self.mocked_locate_opts.LOCATE_OPTS = ["opt1", "opt2"] + + @given(p=lists(text()), b=text()) + def test_bin_override(self, p, b): + search.locate_files(p, bin_override=b) + self.mocked_run.assert_called_with( + [b, *self.mocked_locate_opts, *p], + capture_output=True, + text=None, + ) + + def tearDown(self): + patch.stopall()