refactor: move optional scripts to external repo

This commit is contained in:
sithlord48
2024-12-23 14:08:04 -05:00
committed by Nick Bolton
parent ccc60ff900
commit 98eb89255d
9 changed files with 5 additions and 592 deletions

4
.gitignore vendored
View File

@ -10,7 +10,6 @@
/tmp
/vcpkg
/vcpkg_installed
/scripts/**/*.pyc
/.cache
/.venv
aqtinstall.log
@ -37,3 +36,6 @@ CMakeFiles/*
# vscode folder
/.vscode
# scripts folder
/scripts

View File

@ -1,156 +0,0 @@
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import subprocess
import sys
import lib.env as env
try:
import colorama # type: ignore
from colorama import Fore # type: ignore
colorama.init()
except ImportError:
class Fore:
RESET = ""
YELLOW = ""
def has_command(command):
platform = sys.platform
if platform == "win32":
cmd = f"where {command}"
else:
cmd = f"which {command}"
try:
subprocess.check_output(cmd, shell=True)
return True
except subprocess.CalledProcessError:
return False
def strip_continuation_sequences(command, strip_newlines=True):
"""
Remove the continuation sequences (\\) from a command.
To spread strings over multiple lines in YAML files, like in bash, a backslash is used at
the end of each line as continuation character.
"""
if isinstance(command, list):
raise ValueError("List commands are not supported")
cmd_continuation = " \\"
command = command.replace(cmd_continuation, "")
# Some versions of pyyaml will remove the newlines already, so always stripping
# makes the output more consistent.
if strip_newlines:
command = command.replace("\n", " ")
return command
def run(
command,
check=True, # true by default to fail fast
shell=False, # false by default for security
get_output=False,
print_cmd=False, # false by default for security
):
"""
Convenience wrapper around `subprocess.run` to:
- print the command before running it (if `print_cmd` is True)
This differs to `subprocess.run` in that by default it:
- checks the return code by default
- prints list commands as a readable string on failure
This is the same as `subprocess.run` in that it:
- does not use shell by default for security (shell is less secure)
Args:
command (str or list): The command to run.
check (bool): Raise an exception if the command fails.
shell (bool): Run the command in a shell (false by default for security)
get_output (bool): Return the output of the command.
print_cmd (bool): Print the command before running it (false by default for security)
"""
is_list_cmd = isinstance(command, list)
# create string version of list command, only for debugging purposes
command_str = command
if is_list_cmd:
command_str = " ".join(command)
if print_cmd:
print(f"Running: {command_str}")
else:
print("Running command...")
command_str = "***"
# TODO: You can definitely use a list command with shell=True on Windows,
# but can you use a string command with shell=False on Windows?
#
# The `subprocess.run` function has a little gotcha:
# - a string command must be used when `shell=True`
# - a list command must be used when shell isn't or `shell=False`
# however, it allows you to pass a string command when shell isn't used or `shell=False`
# then fails with a vague error message. same problem with list commands and `shell=True`
if not env.is_windows() and is_list_cmd and shell:
raise ValueError("List commands cannot be used when shell=True on Unix systems")
elif not is_list_cmd and not shell:
raise ValueError("String commands cannot be used when shell=False or not set")
# Flush the output to ensure the command is printed before the output of the command,
# which seems to happen in the GitHub runner logs.
sys.stdout.flush()
sys.stderr.flush()
try:
if get_output:
result = subprocess.run(
command,
shell=shell,
check=check,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
else:
result = subprocess.run(command, check=check, shell=shell)
except subprocess.CalledProcessError as e:
# Take control of how failed commands are printed:
# - if `print_cmd` is false, it will print `***` instead of the command
# - if the command was a list, the command is printed as a readable string
raise RuntimeError(
f"Command exited with code {e.returncode}: {command_str}"
) from None
except Exception:
# Take control of how failed commands are printed:
# - if `print_cmd` is false, it will print `***` instead of the command
# - if the command was a list, the command is printed as a readable string
raise RuntimeError(f"Command failed: {command_str}")
if result.returncode != 0:
print(
f"{Fore.YELLOW}Command exited with code {result.returncode}:{Fore.RESET} {command_str}",
file=sys.stderr,
)
return result

View File

@ -1,24 +0,0 @@
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import colorama # type: ignore
from colorama import Fore # type: ignore
colorama.init()
SUCCESS_TEXT = f"{Fore.LIGHTGREEN_EX}Success:{Fore.RESET}"
ERROR_TEXT = f"{Fore.LIGHTRED_EX}Error:{Fore.RESET}"
WARNING_TEXT = f"{Fore.LIGHTYELLOW_EX}Warning:{Fore.RESET}"
HINT_TEXT = f"{Fore.LIGHTBLUE_EX}Hint:{Fore.RESET}"

View File

@ -1,269 +0,0 @@
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os, sys, subprocess
import lib.cmd_utils as cmd_utils
# The `.venv` dir seems to be most common for virtual environments.
VENV_DIR = ".venv"
def check_module(module):
try:
__import__(module)
return True
except ImportError:
print(f"Python is missing {module} module", file=sys.stderr)
return False
def get_os():
"""Detects the operating system."""
if sys.platform == "win32":
return "windows"
elif sys.platform == "darwin":
return "mac"
elif sys.platform.startswith("linux"):
return "linux"
else:
raise RuntimeError(f"Unsupported platform: {sys.platform}")
def is_windows():
return get_os() == "windows"
def is_mac():
return get_os() == "mac"
def is_linux():
return get_os() == "linux"
def get_linux_distro():
"""Detects the Linux distro."""
os_file = "/etc/os-release"
name = None
name_like = None
version_id = None
version_codename = None
if os.path.isfile(os_file):
with open(os_file) as f:
for line in f:
if line.startswith("ID="):
name = line.strip().split("=")[1].strip('"')
elif line.startswith("ID_LIKE="):
name_like = line.strip().split("=")[1].strip('"')
elif line.startswith("VERSION_ID="):
version_id = line.strip().split("=")[1].strip('"')
elif line.startswith("VERSION_CODENAME="):
version_codename = line.strip().split("=")[1].strip('"')
return name, name_like, version_id or version_codename
def get_env(name, required=True, default=None):
"""
Returns an env var (stripped) or optionally raises an error if not set.
If `default` is set, it will be returned even if `required` is True.
"""
value = os.getenv(name)
if value:
value = value.strip()
if not value:
if default:
return default
elif required:
raise ValueError(f"Required env var not set: {name}")
return value
def get_env_bool(name, default=False):
"""Returns a boolean value from an env var (stripped)."""
value = os.getenv(name)
if value:
value = value.strip()
if value is None:
return default
return value.lower() in ["true", "1", "yes"]
def get_venv_executable(binary="python"):
if sys.platform == "win32":
return os.path.join(VENV_DIR, "Scripts", binary)
else:
return os.path.join(VENV_DIR, "bin", binary)
def in_venv():
"""Returns True if the script is running in a Python virtual environment."""
return sys.prefix != sys.base_prefix
def ensure_in_venv(script_file, create_venv=False):
"""
Ensures the script is running in a Python virtual environment (venv).
If the script is not running in a venv, it will create one and re-run the script in the venv.
"""
check_dependencies(raise_error=True)
import venv
if in_venv():
print(f"Running in venv, executable: {sys.executable}", flush=True)
return
if create_venv and not os.path.exists(VENV_DIR):
print(f"Creating virtual environment at {VENV_DIR}")
venv.create(VENV_DIR, with_pip=True)
if os.path.exists(VENV_DIR):
script_file_abs = os.path.abspath(script_file)
print(f"Using virtual environment for: {script_file_abs}", flush=True)
python_executable = get_venv_executable()
result = subprocess.run([python_executable, script_file_abs] + sys.argv[1:])
sys.exit(result.returncode)
else:
print(
"The Python virtual environment (.venv) needs to be created before you can "
"run this script.\n"
"Please run: scripts/setup_venv.py"
)
sys.exit(1)
def install_requirements():
"""
Uses `pip` to install required Python modules from the `requirements.txt` file.
"""
check_dependencies(raise_error=True)
print("Updating pip...")
cmd_utils.run(
[sys.executable, "-m", "pip", "install", "--upgrade", "pip"],
shell=False,
print_cmd=True,
)
print("Installing required modules...")
cmd_utils.run(
[sys.executable, "-m", "pip", "install", "-e", "scripts"],
shell=False,
print_cmd=True,
)
def check_dependencies(raise_error=False):
"""
Returns True if pip and venv are available.
"""
has_pip = check_module("pip")
has_venv = check_module("venv")
if raise_error:
if not has_pip:
raise RuntimeError("Python is missing pip")
if not has_venv:
raise RuntimeError("Python is missing venv")
else:
return has_pip and has_venv
def ensure_dependencies():
"""
Ensures that pip and venv are available, and installs them if they are not.
This is normally only installs on Linux, as Windows and Mac usually come with pip and venv.
"""
if check_dependencies():
return
print("Installing Python dependencies...")
os = get_os()
if os != "linux":
# should not be a problem, since windows and mac come with pip and venv
raise RuntimeError(f"Unable to install Python dependencies on {os}")
has_sudo = cmd_utils.has_command("sudo")
sudo = "sudo" if has_sudo else ""
distro, distro_like, _version = get_linux_distro()
if not distro_like:
distro_like = distro
update_cmd = None
install_cmd = None
if distro == "rhel" or "rhel" in distro_like:
update_cmd = "yum check-update"
install_cmd = "yum install -y python3-pip" # rhel-like has venv already
elif "debian" in distro_like:
update_cmd = "apt update"
install_cmd = "apt install -y python3-pip python3-venv"
elif "fedora" in distro_like:
update_cmd = "dnf check-update"
install_cmd = "dnf install -y python3-pip python3-virtualenv"
elif "arch" in distro_like:
install_cmd = "pacman -Syu --noconfirm python-pip python-virtualenv"
elif "opensuse" in distro_like:
update_cmd = "zypper refresh"
install_cmd = "zypper install -y python3-pip python3-virtualenv"
else:
raise RuntimeError(f"Unable to install Python dependencies on {distro}")
if update_cmd:
# don't check the return code, as some package managers return non-zero exit codes
# under normal circumstances (e.g. dnf check-update returns 100 when there are
# updates available).
cmd_utils.run(
f"{sudo} {update_cmd}".strip(), check=False, shell=True, print_cmd=True
)
cmd_utils.run(f"{sudo} {install_cmd}".strip(), shell=True, print_cmd=True)
def import_colors():
import lib.colors as colors
return colors
def persist_lock_file(path):
"""
Persists a lock file and ensures the directory part of the path exists.
"""
dir_path = os.path.dirname(path)
if not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
with open(path, "w") as f:
f.write(str(os.getpid()))
def remove_lock_file(path):
"""
Removes a lock file if it exists.
"""
if os.path.exists(path):
os.remove(path)

View File

@ -1,29 +0,0 @@
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os, fnmatch
def find_files(search_dirs, include_files, exclude_dirs=[]):
"""Recursively find files, excluding specified directories"""
matches = []
for dir in search_dirs:
for root, dirnames, filenames in os.walk(dir):
dirnames[:] = [d for d in dirnames if d not in exclude_dirs]
for pattern in include_files:
for filename in fnmatch.filter(filenames, pattern):
matches.append(os.path.join(root, filename))
return matches

View File

@ -1,76 +0,0 @@
#!/usr/bin/env python3
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import lib.env as env
env.ensure_in_venv(__file__)
import argparse, sys
import lib.fs as fs
from clang_format import clang_format # type: ignore
include_files = [
"*.h",
"*.c",
"*.hpp",
"*.cpp",
"*.m",
"*.mm",
]
dirs = ["src"]
def main():
"""
Cross-platform equivalent of using find and xargs with clang-format.
Lints by performing a dry run (--dry-run) which fails when formatting is needed.
"""
parser = argparse.ArgumentParser()
parser.add_argument(
"-f",
"--format",
action="store_true",
help="In-place format all files",
)
args = parser.parse_args()
cmd_args = ["-i"] if args.format else ["--dry-run", "--Werror"]
files_recursive = fs.find_files(dirs, include_files)
if args.format:
print("Formatting files with Clang formatter:")
else:
print("Checking files with Clang formatter:")
for file in files_recursive:
print(file)
if files_recursive:
sys.argv = [""] + cmd_args + files_recursive
result = clang_format()
if result == 0:
print("Clang lint passed")
sys.exit(result)
else:
print("No files for Clang to process", file=sys.stderr)
sys.exit(0)
if __name__ == "__main__":
main()

View File

@ -1,10 +0,0 @@
[project]
name = "scripts"
version = "0.0.1"
description = "Scripts to assist with development of Deskflow"
requires-python = ">=3.9"
dependencies = [
"clang-format",
"python-dotenv",
"colorama",
]

View File

@ -1,25 +0,0 @@
#!/usr/bin/env python3
# Deskflow -- mouse and keyboard sharing utility
# Copyright (C) 2024 Symless Ltd.
#
# This package is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# found in the file LICENSE that should have accompanied this file.
#
# This package is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import lib.env as env
env.ensure_in_venv(__file__, create_venv=True)
env.install_requirements()
import lib.colors as colors
print(colors.SUCCESS_TEXT, "Python virtual environment is ready.")

View File

@ -1,9 +1,9 @@
sonar.organization=deskflow
sonar.projectKey=deskflow_deskflow
sonar.sources=scripts,src/apps,src/lib
sonar.sources=src/apps,src/lib
sonar.tests=src/test
sonar.exclusions=subprojects/**,build/**
sonar.coverage.exclusions=subprojects/**,scripts/**,src/test/**,src/apps/deskflow-gui/**,src/apps/res/**
sonar.coverage.exclusions=subprojects/**,src/test/**,src/apps/deskflow-gui/**,src/apps/res/**
sonar.cpd.exclusions=**/*Test*.cpp
sonar.host.url=https://sonarcloud.io
sonar.coverageReportPaths=${{ steps.coverage-paths.outputs.csv }}