Initial commit

This commit is contained in:
Eric Torres 2020-10-23 11:21:59 -07:00
parent 478cd22c07
commit 1cb66c8834
15 changed files with 762 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
**/*/__pycache__

0
README.rst Normal file
View File

71
bin/cptemplate Executable file
View File

@ -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

107
bin/fedit Executable file
View File

@ -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)

177
bin/quickdel Executable file
View File

@ -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"))

0
file_scripts/__init__.py Normal file
View File

76
file_scripts/editor.py Normal file
View File

@ -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]

19
file_scripts/error.py Normal file
View File

@ -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 ==========

45
file_scripts/fzf.py Normal file
View File

@ -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"))

80
file_scripts/search.py Normal file
View File

@ -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

40
setup.py Normal file
View File

@ -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"),
}
},
)

0
tests/__init__.py Normal file
View File

29
tests/test_editor.py Normal file
View File

@ -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)

27
tests/test_fzf.py Normal file
View File

@ -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()

90
tests/test_search.py Normal file
View File

@ -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()