Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly

- package-ecosystem: uv
directory: /
schedule:
interval: weekly
21 changes: 19 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,32 @@ jobs:
- name: Test
run: rite test

# One job per linter so the PR checks list shows each check's status
# at a glance instead of bundling them behind a single "lint" line.
lint:
strategy:
fail-fast: false
matrix:
check: [pylint, ruff, shell, actions]
name: lint:${{ matrix.check }}
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: jdx/mise-action@v2

- name: Lint
run: rite lint
- name: Lint (${{ matrix.check }})
run: rite lint:${{ matrix.check }}

audit:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4

- uses: jdx/mise-action@v2

- name: Audit dependencies for known vulnerabilities
run: rite audit

# "Does it build": render the formula against a tarball of HEAD, brew
# install it, run the formula test block, then a real `macprefs backup`
Expand Down
42 changes: 39 additions & 3 deletions Ritefile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ tasks:
- uv sync

test:
desc: Run tests with coverage
desc: Run tests with coverage (fails under 95%)
aliases: [t]
deps: [setup]
cmds:
- uv run pytest --cov=src --cov-report xml:cov.xml --cov-report term-missing
- uv run pytest --cov=src --cov-fail-under=95 --cov-report xml:cov.xml --cov-report term-missing

test:watch:
desc: Re-run tests on file changes
Expand All @@ -42,14 +42,50 @@ tasks:
- uv run ptw --nobeep

lint:
desc: Run pylint
desc: Run all linters in parallel (pylint, ruff, shellcheck, actionlint)
aliases: [l]
deps: [lint:pylint, lint:ruff, lint:shell, lint:actions]

lint:pylint:
desc: Run pylint
deps: [setup]
cmds:
# src/macprefs is listed explicitly: it has no .py extension, so the
# glob misses it, but pylint lints any file passed by name.
- uv run pylint src/*.py src/macprefs tests/*.py

lint:ruff:
desc: Run ruff check + format check
deps: [setup]
cmds:
- uv run ruff check src tests
- uv run ruff format --check src tests

lint:shell:
desc: Shellcheck the ci scripts
cmds:
- shellcheck ci/scripts/*.sh

lint:actions:
desc: Lint the GitHub Actions workflows
cmds:
- actionlint

format:
desc: Format the codebase with ruff (also sorts imports)
aliases: [f]
deps: [setup]
cmds:
- uv run ruff check --fix src tests
- uv run ruff format src tests

audit:
desc: Scan locked dependencies for known vulnerabilities
aliases: [a]
deps: [setup]
cmds:
- uv run pip-audit --skip-editable

build:
desc: Build the brew package from committed HEAD, install, verify, uninstall
aliases: [b]
Expand Down
9 changes: 9 additions & 0 deletions ci/scripts/brew-build-test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ brew tap-new --no-git "$TAP" >/dev/null
trap 'brew uninstall --force macprefs >/dev/null 2>&1 || true; brew untap -f "$TAP" >/dev/null 2>&1 || true; rm -rf "$TMP"' EXIT
cp "$TMP/macprefs.rb" "$(brew --repository "$TAP")/Formula/macprefs.rb"

# Style/audit the rendered formula before installing — catches formula-API
# deprecations (like the old `depends_on :python3`) before Homebrew breaks
# the tap. --except checks that can't pass for a local file:// url render.
brew style "$TAP/macprefs"
brew audit --strict --except=url,stable_version,github_repository "$TAP/macprefs" || {
echo "brew audit failed" >&2
exit 1
}

brew install "$TAP/macprefs"

# Formula's own test block (runs `macprefs --help`).
Expand Down
2 changes: 1 addition & 1 deletion macprefs.template.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,6 @@ def install
end

test do
system "#{bin}/macprefs", "--help"
system bin/"macprefs", "--help"
end
end
2 changes: 2 additions & 0 deletions mise.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
python = "3.13"
uv = "latest"
"github:clintmod/rite" = "latest"
shellcheck = "latest"
actionlint = "latest"
12 changes: 12 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@ license = { file = "LICENSE" }

[dependency-groups]
dev = [
"pip-audit==2.10.1",
"pylint==4.0.6",
"pytest==9.1.1",
"pytest-cov==7.1.0",
"pytest-testmon==2.2.0",
"pytest-watch==4.2.0",
"ruff==0.15.22",
]

[tool.uv]
Expand Down Expand Up @@ -70,3 +72,13 @@ score = false

[tool.pylint.format]
max-line-length = 130

[tool.ruff]
line-length = 130
target-version = "py310"
# The extensionless entrypoint needs to be named to be included.
include = ["src/**/*.py", "tests/**/*.py", "src/macprefs"]

[tool.ruff.lint]
# Default rules (pyflakes + core pycodestyle) plus import sorting.
extend-select = ["I"]
17 changes: 9 additions & 8 deletions src/app_store_preferences.py
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
from os import listdir, path
import logging as log
from config import get_app_store_preferences_backup_dir, get_app_store_preferences_dir, ensure_exists
from os import listdir, path

from config import ensure_exists, get_app_store_preferences_backup_dir, get_app_store_preferences_dir
from utils import copy_file, copy_files, execute_shell, restart_cfprefsd


def backup():
log.info('Backing up app store preferences (.plist)...')
log.info("Backing up app store preferences (.plist)...")
files = build_file_list()
dest = get_app_store_preferences_backup_dir()
copy_files(files, dest)


def restore():
log.info('Restoring app store preferences (.plist)...')
log.info("Restoring app store preferences (.plist)...")
source = get_app_store_preferences_backup_dir()
dest = get_app_store_preferences_dir()
for f in listdir(source):
domain = f.split('.plist')[0]
dest_path = path.join(dest, domain, 'Data/Library/Preferences')
domain = f.split(".plist")[0]
dest_path = path.join(dest, domain, "Data/Library/Preferences")
ensure_exists(dest_path)
source_file = path.join(source, f)
copy_file(source_file, dest_path)
Expand All @@ -26,7 +27,7 @@ def restore():

def build_file_list():
source = get_app_store_preferences_dir()
command = 'find ' + source + '*/Data/Library/Preferences -type f -name "*.plist" 2>/dev/null'
command = "find " + source + '*/Data/Library/Preferences -type f -name "*.plist" 2>/dev/null'
result = execute_shell(command, is_shell=True, suppress_errors=True)
files = result.strip().split('\n')
files = result.strip().split("\n")
return files
54 changes: 25 additions & 29 deletions src/config.py
Original file line number Diff line number Diff line change
@@ -1,56 +1,56 @@
from os import environ, makedirs, path, getenv
import getpass
from os import environ, getenv, makedirs, path


def get_macprefs_dir():
backup_dir = ''
if 'MACPREFS_BACKUP_DIR' in environ:
backup_dir = environ['MACPREFS_BACKUP_DIR']
backup_dir = ""
if "MACPREFS_BACKUP_DIR" in environ:
backup_dir = environ["MACPREFS_BACKUP_DIR"]
else:
backup_dir = path.join(get_home_dir(), 'Dropbox', 'MacPrefsBackup')
backup_dir = path.join(get_home_dir(), "Dropbox", "MacPrefsBackup")
ensure_exists(backup_dir)
return backup_dir


def get_preferences_dir():
return_val = path.join(get_home_dir(), 'Library/Preferences/')
return_val = path.join(get_home_dir(), "Library/Preferences/")
return return_val


def get_preferences_backup_dir():
return_val = path.join(get_macprefs_dir(), 'preferences/')
return_val = path.join(get_macprefs_dir(), "preferences/")
ensure_exists(return_val)
return return_val


def get_sys_preferences_backup_dir():
return_val = path.join(get_macprefs_dir(), 'system_preferences/')
return_val = path.join(get_macprefs_dir(), "system_preferences/")
ensure_exists(return_val)
return return_val


def get_shared_file_lists_backup_dir():
return_val = path.join(get_macprefs_dir(), 'shared_file_lists/')
return_val = path.join(get_macprefs_dir(), "shared_file_lists/")
ensure_exists(return_val)
return return_val


def get_shared_file_lists_dir():
return path.join(get_home_dir(), 'Library/Application Support/com.apple.sharedfilelist/')
return path.join(get_home_dir(), "Library/Application Support/com.apple.sharedfilelist/")


def get_dotfiles_backup_dir():
return_val = path.join(get_macprefs_dir(), 'dotfiles/')
return_val = path.join(get_macprefs_dir(), "dotfiles/")
ensure_exists(return_val)
return return_val


def get_dotfile_excludes():
return ['.CFUserTextEncoding', '.DS_Store']
return [".CFUserTextEncoding", ".DS_Store"]


def get_home_dir():
return getenv('HOME') + '/'
return getenv("HOME") + "/"


def ensure_exists(input_dir):
Expand All @@ -59,13 +59,13 @@ def ensure_exists(input_dir):


def get_ssh_backup_dir():
return_val = path.join(get_macprefs_dir(), 'ssh/')
return_val = path.join(get_macprefs_dir(), "ssh/")
ensure_exists(return_val)
return return_val


def get_ssh_user_dir():
return_val = path.join(get_home_dir(), '.ssh/')
return_val = path.join(get_home_dir(), ".ssh/")
return return_val


Expand All @@ -74,55 +74,51 @@ def get_user():


def get_internet_accounts_dir():
return path.join(get_home_dir(), 'Library/Accounts/')
return path.join(get_home_dir(), "Library/Accounts/")


def get_internet_accounts_backup_dir():
return_val = path.join(
get_macprefs_dir(), 'Accounts/')
return_val = path.join(get_macprefs_dir(), "Accounts/")
ensure_exists(return_val)
return return_val


def get_user_launch_agents_dir():
return path.join(get_home_dir(), 'Library/LaunchAgents/')
return path.join(get_home_dir(), "Library/LaunchAgents/")


def get_user_launch_agents_backup_dir():
return_val = path.join(
get_macprefs_dir(), 'StartupItems/LaunchAgents/User/')
return_val = path.join(get_macprefs_dir(), "StartupItems/LaunchAgents/User/")
ensure_exists(return_val)
return return_val


def get_system_launch_agents_dir():
return '/Library/LaunchAgents/'
return "/Library/LaunchAgents/"


def get_system_launch_agents_backup_dir():
return_val = path.join(
get_macprefs_dir(), 'StartupItems/LaunchAgents/AllUsers/')
return_val = path.join(get_macprefs_dir(), "StartupItems/LaunchAgents/AllUsers/")
ensure_exists(return_val)
return return_val


def get_system_launch_daemons_dir():
return '/Library/LaunchDaemons/'
return "/Library/LaunchDaemons/"


def get_system_launch_daemons_backup_dir():
return_val = path.join(
get_macprefs_dir(), 'StartupItems/LaunchDaemons/AllUsers/')
return_val = path.join(get_macprefs_dir(), "StartupItems/LaunchDaemons/AllUsers/")
ensure_exists(return_val)
return return_val


def get_app_store_preferences_dir():
return_val = path.join(get_home_dir(), 'Library/Containers/')
return_val = path.join(get_home_dir(), "Library/Containers/")
return return_val


def get_app_store_preferences_backup_dir():
return_val = path.join(get_macprefs_dir(), 'app_store_preferences/')
return_val = path.join(get_macprefs_dir(), "app_store_preferences/")
ensure_exists(return_val)
return return_val
11 changes: 6 additions & 5 deletions src/dotfiles.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
from os import path, listdir
import logging as log
from config import get_dotfiles_backup_dir, get_dotfile_excludes, get_home_dir, get_user
from os import listdir, path

from config import get_dotfile_excludes, get_dotfiles_backup_dir, get_home_dir, get_user
from utils import copy_files, ensure_files_owned_by_user


def backup():
log.info('Backing up dotfiles...')
log.info("Backing up dotfiles...")
# build file list
home_dir = get_home_dir()
excludes = get_dotfile_excludes()
Expand All @@ -15,7 +16,7 @@ def backup():


def restore():
log.info('Restoring dotfiles...')
log.info("Restoring dotfiles...")
source = get_dotfiles_backup_dir()
dest = get_home_dir()
files = get_dot_files(source)
Expand All @@ -30,6 +31,6 @@ def get_dot_files(home_dir, excludes=None):
files = []
for f in listdir(home_dir):
full_file_path = path.join(home_dir, f)
if f[0] == '.' and path.isfile(full_file_path) and f not in excludes:
if f[0] == "." and path.isfile(full_file_path) and f not in excludes:
files.append(full_file_path)
return files
Loading
Loading