├── .editorconfig ├── .envrc ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .vscode ├── launch.json └── settings.json ├── CHANGELOG.md ├── LICENSE ├── README.md ├── TODO.txt ├── dev-reqs.txt ├── notes.txt ├── prefsniff ├── __about__.py ├── __init__.py ├── changetypes.py ├── exceptions.py ├── prefsniff.py └── version.py ├── requirements.txt ├── scripts ├── deletebranch ├── functions.sh ├── gc_about ├── gc_changelog ├── project_settings.sh ├── release └── tag ├── setup.cfg └── setup.py /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | root = true 4 | 5 | [*] 6 | indent_style = space 7 | indent_size = 4 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | indent_size = 2 14 | 15 | [*.yaml] 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /.envrc: -------------------------------------------------------------------------------- 1 | layout python3 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | 53 | # Translations 54 | *.mo 55 | *.pot 56 | 57 | # Django stuff: 58 | *.log 59 | local_settings.py 60 | db.sqlite3 61 | db.sqlite3-journal 62 | 63 | # Flask stuff: 64 | instance/ 65 | .webassets-cache 66 | 67 | # Scrapy stuff: 68 | .scrapy 69 | 70 | # Sphinx documentation 71 | docs/_build/ 72 | 73 | # PyBuilder 74 | target/ 75 | 76 | # Jupyter Notebook 77 | .ipynb_checkpoints 78 | 79 | # IPython 80 | profile_default/ 81 | ipython_config.py 82 | 83 | # pyenv 84 | .python-version 85 | 86 | # pipenv 87 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 88 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 89 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 90 | # install all needed dependencies. 91 | #Pipfile.lock 92 | 93 | # celery beat schedule file 94 | celerybeat-schedule 95 | 96 | # SageMath parsed files 97 | *.sage.py 98 | 99 | # Environments 100 | .env 101 | .venv 102 | env/ 103 | venv/ 104 | ENV/ 105 | env.bak/ 106 | venv.bak/ 107 | 108 | # Spyder project settings 109 | .spyderproject 110 | .spyproject 111 | 112 | # Rope project settings 113 | .ropeproject 114 | 115 | # mkdocs documentation 116 | /site 117 | 118 | # mypy 119 | .mypy_cache/ 120 | .dmypy.json 121 | dmypy.json 122 | 123 | # Pyre type checker 124 | .pyre/ 125 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "submodules/repo-mgmt-scripts"] 2 | path = submodules/repo-mgmt-scripts 3 | url = https://github.com/zcutlip/repo-mgmt-scripts.git 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-json 6 | exclude: ^\.vscode\/.*$ 7 | - id: check-yaml 8 | - id: check-merge-conflict 9 | - repo: https://github.com/PyCQA/flake8 10 | rev: 6.0.0 11 | hooks: 12 | - id: flake8 13 | - repo: https://github.com/pre-commit/mirrors-autopep8 14 | rev: 'v2.0.1' 15 | hooks: 16 | - id: autopep8 17 | - repo: https://github.com/pycqa/isort 18 | rev: 5.12.0 19 | hooks: 20 | - id: isort 21 | name: isort (python) 22 | - id: isort 23 | name: isort (cython) 24 | types: [cython] 25 | - id: isort 26 | name: isort (pyi) 27 | types: [pyi] 28 | ci: 29 | autoupdate_branch: 'development' 30 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "prefsniff foo.plist", 9 | "type": "python", 10 | "request": "launch", 11 | "module": "prefsniff.prefsniff", 12 | "args": [ 13 | "./foo.plist" 14 | ] 15 | }, 16 | { 17 | "name": "prefsniff hotkeys", 18 | "type": "python", 19 | "request": "launch", 20 | "module": "prefsniff.prefsniff", 21 | "args": [ 22 | "~/Library/Preferences/com.apple.symbolichotkeys.plist" 23 | ] 24 | }, 25 | { 26 | "name": "prefsniff two files", 27 | "type": "python", 28 | "request": "launch", 29 | "module": "prefsniff.prefsniff", 30 | "args": [ 31 | "./com.apple.symbolichotkeys_1.plist", 32 | "--plist2", 33 | "./com.apple.symbolichotkeys_2.plist" 34 | 35 | ] 36 | } 37 | ] 38 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | ## [0.3.1] - 2024-01-19 6 | 7 | ### Summary 8 | 9 | Fix crash when an unimplented change type is detected 10 | 11 | ## [0.2.2] - 2023-02-13 12 | 13 | ### Summary 14 | 15 | Updated README.md installation instructions 16 | 17 | ### Misc 18 | 19 | - Add .pre-commit-config.yaml 20 | - Reformat python code 21 | - Replace project scripts with `repo-mgmt-scripts` submodule 22 | - Add .editorconfig 23 | - Replace `master` branch with `main` 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Zachary Cutlip 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Prefsniff 2 | ========= 3 | 4 | *Author:* Zachary Cutlip, uid000 at gmail 5 | 6 | `prefsniff` is a utility to watch macOS plist files for changes, and then autogenerate the `defaults` command to apply those changes. Its intended use is to have `prefsniff` watch a plist file while setting a system or application preference. The resulting defaults command can then be added to a shell script or incorporated into a configuration management system such as Ansible. 7 | 8 | Installing 9 | ---------- 10 | If you're here to simply use `prefsniff` and not to hack on it, there's no need to clone the git repo. You may simply install from PyPI via `pip`: 11 | 12 | $ pip3 install prefsniff 13 | 14 | Using 15 | ----- 16 | `prefsniff` has two modes of operation; directory mode and file mode. 17 | 18 | - Directory mode: watch a directory (non-recursively) for plist files that are unlinked and replaced in order to observe what file backs a particular configuration setting. 19 | - File mode: watch a plist file in order to represent its changes as one or more `defaults` command. 20 | 21 | Directory mode example: 22 | 23 | $ prefsniff ~/Library/Preferences 24 | PREFSNIFF version 0.1.0b3 25 | Watching directory: /Users/zach/Library/Preferences 26 | Detected change: [deleted] /Users/zach/Library/Preferences/com.apple.dock.plist 27 | Detected change: [created] /Users/zach/Library/Preferences/com.apple.dock.plist 28 | 29 | File mode example: 30 | 31 | $ prefsniff ~/Library/Preferences/com.apple.dock.plist 32 | PREFSNIFF version 0.1.0b3 33 | Watching prefs file: /Users/zach/Library/Preferences/com.apple.dock.plist 34 | ***************************** 35 | 36 | defaults write com.apple.dock orientation -string right 37 | 38 | ***************************** 39 | 40 | 41 | Additional Reading 42 | ------------------ 43 | 44 | [Advanced `defaults(1)` Usage](https://shadowfile.inode.link/blog/2018/06/advanced-defaults1-usage/) 45 | 46 | An introduction to plist files and the `defaults(1)` command. Includes detailed explanation of each plist type and how to manipulate them with `defaults`. 47 | 48 | [Defaults Non-obvious Locations](https://shadowfile.inode.link/blog/2018/08/defaults-non-obvious-locations/) 49 | 50 | An explanation of various defaults domains and where their corresponding plist files can be found on disk. 51 | 52 | 53 | [Autogenerating `defaults(1)` Commands](https://shadowfile.inode.link/blog/2018/08/autogenerating-defaults1-commands/) 54 | 55 | An introduction to this tool, `prefsniff`, and how to use it to autogenerate `defaults` commands. 56 | 57 | TODO 58 | ---- 59 | 60 | - Implement `data` and `date` plist types 61 | - Clean up output so that it can be redirected to a shell script or similar 62 | - Add additional output options (such as the name of a shell script to create) 63 | - Split utility & API 64 | - Make prefsniff into a python module that exports API 65 | - Make a separate `prefsniff` command-line utility that uses the API 66 | 67 | -------------------------------------------------------------------------------- /TODO.txt: -------------------------------------------------------------------------------- 1 | - Implement `data` and `date` plist types 2 | - Clean up output so that it can be redirected to a shell script or similar 3 | - Add additional output options (such as the name of a shell script to create) 4 | - Split utility & API 5 | - Make prefsniff into a python module that exports API 6 | - Make a separate `prefsniff` command-line utility that uses the API -------------------------------------------------------------------------------- /dev-reqs.txt: -------------------------------------------------------------------------------- 1 | --find-links=file:./deps 2 | wheel 3 | autopep8 4 | colored-traceback 5 | flake8 6 | ipython 7 | pycodestyle 8 | -e . 9 | -r requirements.txt 10 | -------------------------------------------------------------------------------- /notes.txt: -------------------------------------------------------------------------------- 1 | Pulling out from xml plist: 2 | import xml.etree.ElementTree as ET 3 | >>> ET.tostring(root) 4 | '\n\n\t1\n\ta\n\t2\n\tb\n\n' 5 | >>> root.find("dict") 6 | 7 | >>> ET.tostring(root.find("dict")) 8 | '\n\t1\n\ta\n\t2\n\tb\n\n' 9 | 10 | ****************************************************************************************** 11 | 12 | To create the following foo.bar preference under the key "mydict": 13 | { 14 | mydict = { 15 | enabled=1; 16 | }; 17 | } 18 | We can do any of the following: 19 | - Don't specify the type at all, and use an xml represenation of the dictionary 20 | defaults write foo.bar mydict "enabled1" 21 | - Specify the type as -dict, followed by the key you want to add to mydict: 22 | defaults write foo.bar mydict -dict mysubdict "somekey1" 23 | defaults write foo.bar mydict -dict enabled "1" 24 | 25 | This creates or replaces 'mydict' 26 | - Specify -dict-add in order to add to create mydict or add to it if it already exists 27 | defaults write foo.bar mydict -dict-add mysubdict "somekey1" 28 | defaults write foo.bar mydict -dict-add enabled "1" 29 | 30 | ****************************************************************************************** 31 | 32 | from: http://krypted.com/mac-os-x/defaults-symbolichotkeys/ 33 | 34 | . Now, let’s look at setting that symbolichotkeys property list to set the Front Row (Dictionary 73 within Dictionary AppleSymbolicHotKeys to disable, by changing the enabled key to 0, and then leaving the value dictionary as is by copying it back in with the same values (if you care: delimited from the enabled key with a ; and defined as a a dictionary based on the content between the {} with an array inside of it, defined using parenthesis to start and stop the array, followed with another semicolon to delimit the end of that key followed by the type keypair followed by yet another semicolon to end each open key). 35 | 36 | defaults write com.apple.symbolichotkeys AppleSymbolicHotKeys -dict-add 73 “{enabled = 0; value = { parameters = (65535, 53, 1048576); type = ‘standard’; }; }” 37 | 38 | To then re-enable: 39 | 40 | defaults write com.apple.symbolichotkeys AppleSymbolicHotKeys -dict-add 73 "{enabled = 1; value = { parameters = (65535, 53, 1048576); type = 'standard'; }; }" 41 | 42 | You could also map different keystrokes by sending different numerical values (some are shown above) into the parameters array. 43 | 44 | 45 | ****************************************************************************************** 46 | Writing to something root owned like PowerManagement prefs in /Library/Preferences/ 47 | - Evidently you can't simply run defaults as admin or with sudo to make it stick. 48 | - You need to provide the explict path to the literal plist file as the domain (not just com.apple.PowerManagement) 49 | - sudo defaults write /Library/Preferences/com.apple.root-owned.plist 'somekey' -bool True 50 | 51 | 52 | ****************************************************************************************** 53 | Modifying trackpad settings 54 | The trackpad prefpane changes settings for external bluetooth trackpads as well as a MacBook's built-in trackpads. In addition there are a couple of NSGlobalDomain preferences that get set. 55 | 56 | All of the following appear necessary (for example to enable tap-to-click): 57 | # Internal trackpad 58 | - defaults write com.apple.AppleMultitouchTrackpad Clicking -bool True 59 | # Bluetooth trackpad 60 | - defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool True 61 | # Tap-to-click seems to work, but the prefpane UI doesn't update without this 62 | # this changes ~/Library/Preferences/ByHost/.GlobalPreferences.SOME-LONG-GUID.plist 63 | - defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 64 | # I didn't need this, but the interenet says it's important 65 | # This changes ~/Library/Preferences/.GlobalPreferences.plist 66 | - defaults write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 67 | 68 | 69 | 70 | ****************************************************************************************** 71 | EXAMPLES 72 | http://osxdaily.com/2012/10/09/best-defaults-write-commands-mac-os-x/ 73 | https://github.com/pawelgrzybek/dotfiles/blob/master/setup-macos.sh -------------------------------------------------------------------------------- /prefsniff/__about__.py: -------------------------------------------------------------------------------- 1 | __title__ = "prefsniff" 2 | __version__ = "0.3.1" 3 | __summary__ = "macOS defaults(1) command generator" 4 | -------------------------------------------------------------------------------- /prefsniff/__init__.py: -------------------------------------------------------------------------------- 1 | from .__about__ import __summary__, __title__, __version__ 2 | 3 | __all__ = [ 4 | "__version__", 5 | "__title__", 6 | "__summary__" 7 | ] 8 | -------------------------------------------------------------------------------- /prefsniff/changetypes.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import plistlib 3 | import xml.etree.ElementTree as ET 4 | from abc import ABCMeta 5 | from shlex import quote as cmd_quote 6 | from typing import Dict 7 | 8 | from py_dict_repr.py_dict_repr import DictRepr 9 | 10 | from .exceptions import ( 11 | PSChangeTypeException, 12 | PSChangeTypeNotImplementedException 13 | ) 14 | 15 | 16 | class PSChangeTypeRegistry(type): 17 | REGISTERED_CHANGE_TYPES = {} 18 | 19 | def __new__(cls, clsname, bases, dct, *args, **kwargs): 20 | newclass = super(PSChangeTypeRegistry, cls).__new__( 21 | cls, clsname, bases, dct 22 | ) 23 | if newclass.CHANGE_TYPE: 24 | ch_type = newclass.CHANGE_TYPE 25 | if ch_type in cls.REGISTERED_CHANGE_TYPES: 26 | raise Exception( 27 | "class {:s} attempting to register previously registered preference change type: {}".format( 28 | cls.__name__, ch_type)) 29 | cls.REGISTERED_CHANGE_TYPES[ch_type] = newclass 30 | return newclass 31 | 32 | @classmethod 33 | def ch_type_class_lookup(cls, ch_type: str): 34 | ch_type_class = cls.REGISTERED_CHANGE_TYPES[ch_type] 35 | return ch_type_class 36 | 37 | 38 | PSChangeTypeMeta = type( 39 | 'SBHeaderMeta', (ABCMeta, PSChangeTypeRegistry), {}) 40 | 41 | 42 | class PSChangeTypeFactory: 43 | 44 | @classmethod 45 | def ps_change_type_from_dict(cls, ch_type_dict: Dict): 46 | ch_type = ch_type_dict["change_type"] 47 | ch_type_class = PSChangeTypeRegistry.ch_type_class_lookup(ch_type) 48 | obj = ch_type_class.from_dict(ch_type_dict) 49 | return obj 50 | 51 | 52 | class PSChangeTypeBase(DictRepr, metaclass=PSChangeTypeMeta): 53 | CHANGE_TYPE = None 54 | COMMAND = "defaults" 55 | ACTION = None 56 | TYPE = None 57 | 58 | def __init__(self, domain, byhost, key, value): 59 | if self.ACTION is None: 60 | raise NotImplementedError( 61 | "Need to sublclass and override cls.ACTION") 62 | self.command = self.COMMAND 63 | self.action = self.ACTION 64 | self.domain = domain 65 | self.key = key 66 | self.type = self.TYPE 67 | self.value = value 68 | self.converted_value = value 69 | self.byhost = byhost 70 | 71 | def keys(self): 72 | _keys = ["change_type", "command", "action", 73 | "domain", "key", "type", "value", "byhost"] 74 | return _keys 75 | 76 | @classmethod 77 | def from_dict(cls, ch_type_dict: Dict): 78 | domain = ch_type_dict["domain"] 79 | byhost = ch_type_dict["byhost"] 80 | key = ch_type_dict["key"] 81 | value = ch_type_dict["value"] 82 | obj = cls(domain, byhost, key, value) 83 | return obj 84 | 85 | @property 86 | def change_type(self): 87 | return self.CHANGE_TYPE 88 | 89 | def _quote(self, value, quote=True): 90 | if quote: 91 | value = cmd_quote(value) 92 | return value 93 | 94 | def argv(self, quote=True): 95 | argv = [self.command] 96 | if self.byhost: 97 | argv.append("-currentHost") 98 | argv.append(self._quote(self.action, quote=quote)) 99 | argv.append(self._quote(self.domain, quote=quote)) 100 | argv.append(self._quote(self.key, quote=quote)) 101 | if self.type is not None: 102 | type_arg = f"-{self.type}" 103 | argv.append(self._quote(type_arg, quote=quote)) 104 | value_argv = self._value_argv(quote=quote) 105 | 106 | if value_argv is not None: 107 | argv.extend(value_argv) 108 | 109 | return argv 110 | 111 | def _value_argv(self, quote=True): 112 | 113 | value_argv = None 114 | if self.converted_value is not None: 115 | if isinstance(self.converted_value, (list, tuple)): 116 | value_argv = [self._quote(v, quote=quote) 117 | for v in self.converted_value] 118 | else: 119 | if isinstance(self.converted_value, str): 120 | value_string = self.converted_value 121 | else: 122 | value_string = str(self.converted_value) 123 | value_argv = [self._quote(value_string, quote=quote)] 124 | 125 | return value_argv 126 | 127 | def shell_command(self): 128 | argv = self.argv(quote=True) 129 | command = ' '.join(argv) 130 | return command 131 | 132 | 133 | class PSChangeTypeString(PSChangeTypeBase): 134 | CHANGE_TYPE = "string" 135 | ACTION = "write" 136 | TYPE = "string" 137 | 138 | 139 | class PSChangeTypeKeyDeleted(PSChangeTypeString): 140 | CHANGE_TYPE = "deleted" 141 | ACTION = "delete" 142 | TYPE = None 143 | 144 | def __init__(self, domain, byhost, key, *args): 145 | super().__init__(domain, byhost, key, None) 146 | 147 | 148 | class PSChangeTypeFloat(PSChangeTypeString): 149 | CHANGE_TYPE = "float" 150 | TYPE = "float" 151 | 152 | def __init__(self, domain, byhost, key, value): 153 | self.type = "-float" 154 | 155 | if not isinstance(value, float): 156 | raise PSChangeTypeException( 157 | "Float required for -float prefs change.") 158 | 159 | super().__init__(domain, byhost, key, value) 160 | 161 | 162 | class PSChangeTypeInt(PSChangeTypeString): 163 | CHANGE_TYPE = "int" 164 | TYPE = "int" 165 | 166 | def __init__(self, domain, byhost, key, value): 167 | 168 | if not isinstance(value, int): 169 | raise PSChangeTypeException( 170 | "Integer required for -int prefs change.") 171 | 172 | super().__init__(domain, byhost, key, value) 173 | 174 | 175 | class PSChangeTypeBool(PSChangeTypeString): 176 | CHANGE_TYPE = "bool" 177 | TYPE = "bool" 178 | 179 | def __init__(self, domain, byhost, key, value): 180 | if not isinstance(value, bool): 181 | raise PSChangeTypeException( 182 | "Boolean required for -bool prefs change.") 183 | super().__init__(domain, byhost, key, value) 184 | 185 | 186 | class PSChangeTypeCompositeBase(PSChangeTypeBase): 187 | CHANGE_TYPE = None 188 | TYPE = None 189 | 190 | def to_xmlfrag(self, value): 191 | 192 | # create plist-serialized form of changed objects 193 | plist_str = plistlib.dumps(value, fmt=plistlib.FMT_XML).decode('utf-8') 194 | 195 | # remove newlines and tabs from plist 196 | plist_str = "".join([line.strip() for line in plist_str.splitlines()]) 197 | # parse the plist xml doc, so we can pull out the important parts. 198 | tree = ET.ElementTree(ET.fromstring(plist_str)) 199 | # get elements inside 200 | children = list(tree.getroot()) 201 | # there can only be one element inside 202 | if len(children) < 1: 203 | fn = inspect.getframeinfo(inspect.currentframe()).function 204 | raise PSChangeTypeException( 205 | "%s: Empty dictionary for key %s" % (fn, str(self.key))) 206 | if len(children) > 1: 207 | fn = inspect.getframeinfo(inspect.currentframe()).function 208 | raise PSChangeTypeException( 209 | "%s: Something went wrong for key %s. Can only support one dictionary for dict change." % (fn, self.dict_key)) 210 | # extract changed objects out of the plist element 211 | # python 2 & 3 compat 212 | # https://stackoverflow.com/questions/15304229/convert-python-elementtree-to-string#15304351 213 | xmlfrag = ET.tostring(children[0]).decode() 214 | return xmlfrag 215 | 216 | 217 | class PSChangeTypeArray(PSChangeTypeCompositeBase): 218 | CHANGE_TYPE = "array" 219 | ACTION = "write" 220 | TYPE = None 221 | 222 | def __init__(self, domain, byhost, key, value): 223 | if not isinstance(value, list): 224 | raise PSChangeTypeException( 225 | "PSChangeTypeArray requires a list value type.") 226 | super().__init__(domain, byhost, key, value) 227 | self.converted_value = self.to_xmlfrag(value) 228 | 229 | 230 | class PSChangeTypeDict(PSChangeTypeCompositeBase): 231 | CHANGE_TYPE = "dict" 232 | ACTION = "write" 233 | # We have to omit the -dict type 234 | # And just let defaults interpet the xml dict string 235 | TYPE = None 236 | 237 | def __init__(self, domain, byhost, key, value): 238 | if not isinstance(value, dict): 239 | raise PSChangeTypeException( 240 | "Dict required for -dict prefs change.") 241 | super().__init__(domain, byhost, key, value) 242 | self.converted_value = self.to_xmlfrag(value) 243 | 244 | 245 | class PSChangeTypeDictAdd(PSChangeTypeCompositeBase): 246 | CHANGE_TYPE = "dict-add" 247 | ACTION = "write" 248 | TYPE = "dict-add" 249 | 250 | def __init__(self, domain, byhost, key, subkey, value): 251 | super().__init__(domain, byhost, key, value) 252 | self.subkey = subkey 253 | self.converted_value = self._generate_value_string(subkey, value) 254 | 255 | def _generate_value_string(self, subkey, value): 256 | xmlfrag = self.to_xmlfrag(value) 257 | return (subkey, xmlfrag) 258 | 259 | def keys(self): 260 | _keys = super().keys() 261 | _keys.extend(["subkey"]) 262 | return _keys 263 | 264 | @classmethod 265 | def from_dict(cls, ch_type_dict: Dict): 266 | domain = ch_type_dict["domain"] 267 | byhost = ch_type_dict["byhost"] 268 | key = ch_type_dict["key"] 269 | subkey = ch_type_dict["subkey"] 270 | value = ch_type_dict["value"] 271 | obj = cls(domain, byhost, key, subkey, value) 272 | return obj 273 | 274 | # def __str__(self): 275 | # # hopefully generate something like: 276 | # #"dict-key 'val-to-add-to-dict'" 277 | # # so we can generate a command like 278 | # # defaults write foo -dict-add dict-key 'value' 279 | # xmlfrag = self.xmlfrag 280 | # return " %s '%s'" % (self.dict_key, xmlfrag) 281 | 282 | 283 | class PSChangeTypeArrayAdd(PSChangeTypeArray): 284 | CHANGE_TYPE = "array-add" 285 | TYPE = "array-add" 286 | 287 | def __init__(self, domain, key, byhost, value): 288 | super().__init__(domain, byhost, key, value) 289 | self.converted_value = self._generate_value_string(value) 290 | 291 | def _generate_value_string(self, value): 292 | values = [] 293 | for v in value: 294 | values.append(self.to_xmlfrag(v)) 295 | return values 296 | 297 | 298 | class PSChangeTypeData(PSChangeTypeString): 299 | CHANGE_TYPE = "data" 300 | 301 | def __init__(self, domain, byhost, key, value): 302 | raise PSChangeTypeNotImplementedException( 303 | "%s not implemented" % self.__class__.__name__) 304 | 305 | 306 | class PSChangeTypeDate(PSChangeTypeString): 307 | CHANGE_TYPE = "date" 308 | 309 | def __init__(self, domain, byhost, key, value): 310 | raise PSChangeTypeNotImplementedException( 311 | "%s not implemented" % self.__class__.__name__) 312 | -------------------------------------------------------------------------------- /prefsniff/exceptions.py: -------------------------------------------------------------------------------- 1 | class PSniffException(Exception): 2 | pass 3 | 4 | 5 | class PSChangeTypeException(PSniffException): 6 | pass 7 | 8 | 9 | class PSChangeTypeNotImplementedException(PSChangeTypeException): 10 | pass 11 | -------------------------------------------------------------------------------- /prefsniff/prefsniff.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import argparse 4 | import datetime 5 | import difflib 6 | import os 7 | import plistlib 8 | import re 9 | import subprocess 10 | import sys 11 | from pwd import getpwuid 12 | from queue import Empty as QueueEmpty 13 | from queue import Queue 14 | from typing import List 15 | 16 | from watchdog.events import FileSystemEventHandler 17 | from watchdog.observers import Observer 18 | 19 | from .changetypes import ( 20 | PSChangeTypeArray, 21 | PSChangeTypeArrayAdd, 22 | PSChangeTypeBase, 23 | PSChangeTypeBool, 24 | PSChangeTypeData, 25 | PSChangeTypeDate, 26 | PSChangeTypeDict, 27 | PSChangeTypeDictAdd, 28 | PSChangeTypeFactory, 29 | PSChangeTypeFloat, 30 | PSChangeTypeInt, 31 | PSChangeTypeKeyDeleted, 32 | PSChangeTypeString 33 | ) 34 | from .exceptions import PSChangeTypeNotImplementedException 35 | from .version import PrefsniffAbout 36 | 37 | STARS = "*****************************" 38 | 39 | 40 | class PSChangeTypeErrorMessage(str): 41 | def __new__(cls, err_msg, *args, **kwargs): 42 | return super().__new__(cls, err_msg) 43 | 44 | 45 | def parse_args(argv): 46 | parser = argparse.ArgumentParser() 47 | parser.add_argument( 48 | "watchpath", help="Directory or plist file to watch for changes.") 49 | parser.add_argument( 50 | "--version", 51 | help="Show version and exit.", 52 | action='version', 53 | version=str(PrefsniffAbout())) 54 | parser.add_argument( 55 | "--show-diffs", help="Show diff of changed plist files.", action="store_true") 56 | parser.add_argument("--plist2", 57 | help="Optionally compare WATCHPATH against this plist rather than waiting for changes to the original." 58 | ) 59 | args = parser.parse_args(argv) 60 | return args 61 | 62 | 63 | class PrefSniff: 64 | STANDARD_PATHS = ["~/Library/Preferences", 65 | "/Library/Preferences"] 66 | 67 | CHANGE_TYPES = {int: PSChangeTypeInt, 68 | float: PSChangeTypeFloat, 69 | str: PSChangeTypeString, 70 | bool: PSChangeTypeBool, 71 | dict: PSChangeTypeDict, 72 | list: PSChangeTypeArray, 73 | bytes: PSChangeTypeData, 74 | datetime.datetime: PSChangeTypeDate} 75 | 76 | @classmethod 77 | def is_nsglobaldomain(cls, plistpath): 78 | nsglobaldomain = False 79 | base = os.path.basename(plistpath) 80 | if base.startswith(".GlobalPreferences"): 81 | nsglobaldomain = True 82 | 83 | return nsglobaldomain 84 | 85 | @classmethod 86 | def is_byhost(cls, plistpath): 87 | byhost = False 88 | dirname = os.path.dirname(plistpath) 89 | immediate_parent = os.path.basename(dirname) 90 | if "ByHost" == immediate_parent: 91 | byhost = True 92 | 93 | return byhost 94 | 95 | @classmethod 96 | def is_root_owned(cls, plistpath): 97 | return getpwuid(os.stat(plistpath).st_uid).pw_name == 'root' 98 | 99 | @classmethod 100 | def standard_path(cls, plistpath: str): 101 | standard = False 102 | path: str 103 | for path in cls.STANDARD_PATHS: 104 | path_real = os.path.expanduser(path) 105 | if plistpath.startswith(path): 106 | standard = True 107 | break 108 | elif plistpath.startswith(path_real): 109 | standard = True 110 | break 111 | 112 | return standard 113 | 114 | @classmethod 115 | def getdomain(cls, plistpath, byhost=False): 116 | domain = None 117 | 118 | globaldomain = cls.is_nsglobaldomain(plistpath) 119 | root_owned = cls.is_root_owned(plistpath) 120 | standard_path = cls.standard_path(plistpath) 121 | real_path = os.path.realpath(plistpath) 122 | # if root owned (like in /Library/Preferences), need to specify fully qualified 123 | # literal filename rather than a namespace 124 | if root_owned: 125 | domain = real_path 126 | elif not standard_path: 127 | domain = real_path 128 | elif globaldomain: 129 | domain = "NSGlobalDomain" 130 | elif byhost: 131 | # e.g., 132 | # '~/Library/Preferences/ByHost/com.apple.windowserver.000E4DFD-62C8-5DC5-A2A4-42AFE04AAB87.plist 133 | # get just the filename 134 | base = os.path.basename(plistpath) 135 | # strip off .plist 136 | base = os.path.splitext(base)[0] 137 | # strip off UUID, leaving e.g., com.apple.windowserver 138 | domain = os.path.splitext(base)[0] 139 | else: 140 | base = os.path.basename(plistpath) 141 | domain = os.path.splitext(base)[0] 142 | 143 | return domain 144 | 145 | def __init__(self, plistpath, plistpath2=None): 146 | self.plist_dir = os.path.dirname(plistpath) 147 | self.plist_base = os.path.basename(plistpath) 148 | self.byhost = self.is_byhost(plistpath) 149 | self.pref_domain = self.getdomain(plistpath, byhost=self.byhost) 150 | 151 | self.plistpath = plistpath 152 | 153 | # Read the preference file before it changed 154 | with open(plistpath, 'rb') as f: 155 | pref1 = plistlib.load(f) 156 | 157 | if plistpath2 is None: 158 | self.plistpath2 = plistpath 159 | self._wait_for_prefchange() 160 | else: 161 | self.plistpath2 = plistpath2 162 | 163 | # Read the preference file after it changed 164 | with open(self.plistpath2, 'rb') as f: 165 | pref2 = plistlib.load(f) 166 | 167 | added, removed, modified, same = self._dict_compare(pref1, pref2) 168 | self.removed = {} 169 | self.added = {} 170 | self.modified = {} 171 | 172 | # At this stage, added and removed would be 173 | # a key:value added or removed from the top-level 174 | # of the plist 175 | if len(added): 176 | self.added = added 177 | if len(removed): 178 | self.removed = removed 179 | if len(modified): 180 | self.modified = modified 181 | 182 | self.changes = self._generate_changes() 183 | self.diff = self._unified_diff(pref1, pref2, plistpath) 184 | 185 | def _dict_compare(self, d1, d2): 186 | d1_keys = set(d1.keys()) 187 | d2_keys = set(d2.keys()) 188 | intersect_keys = d1_keys.intersection(d2_keys) 189 | added_keys = d2_keys - d1_keys 190 | added = {o: d2[o] for o in added_keys} 191 | removed = d1_keys - d2_keys 192 | modified = {o: (d1[o], d2[o]) 193 | for o in intersect_keys if d1[o] != d2[o]} 194 | 195 | same = set(o for o in intersect_keys if d1[o] == d2[o]) 196 | return added, removed, modified, same 197 | 198 | def _list_compare(self, list1, list2): 199 | list_diffs = {"same": False, "append_to_l1": None, 200 | "subtract_from_l1": None} 201 | if list1 == list2: 202 | list_diffs["same"] = True 203 | return list_diffs 204 | if len(list2) > len(list1): 205 | if list1 == list2[:len(list1)]: 206 | list_diffs["append_to_l1"] = list2[len(list1):] 207 | 208 | return list_diffs 209 | elif len(list1) > len(list2): 210 | if list2 == list1[:len(list2)]: 211 | list_diffs["subtract_from_l1"] = list1[len(list2):] 212 | 213 | return list_diffs 214 | 215 | return list_diffs 216 | 217 | def _unified_diff(self, frompref, topref, path): 218 | # Convert both preferences to XML format 219 | fromxml = plistlib.dumps( 220 | frompref, fmt=plistlib.FMT_XML).decode('utf-8') 221 | toxml = plistlib.dumps( 222 | topref, fmt=plistlib.FMT_XML).decode('utf-8') 223 | 224 | fromlines, tolines = fromxml.splitlines(), toxml.splitlines() 225 | return difflib.unified_diff(fromlines, tolines, path, path) 226 | 227 | def _wait_for_prefchange(self): 228 | event_queue = Queue() 229 | event_handler = PrefChangedEventHandler(self.plist_base, event_queue) 230 | observer = Observer() 231 | observer.schedule(event_handler, self.plist_dir, recursive=False) 232 | observer.start() 233 | pref_updated = False 234 | try: 235 | while not pref_updated: 236 | try: 237 | event = event_queue.get(True, 0.5) 238 | if event[0] == "moved" and os.path.basename(event[1].dest_path) == self.plist_base: 239 | pref_updated = True 240 | if event[0] == "modified" and os.path.basename(event[1].src_path) == self.plist_base: 241 | pref_updated = True 242 | if event[0] == "created" and os.path.basename(event[1].src_path) == self.plist_base: 243 | pref_updated = True 244 | except QueueEmpty: 245 | pass 246 | except KeyboardInterrupt: 247 | observer.stop() 248 | raise 249 | observer.stop() 250 | observer.join() 251 | 252 | def _change_type_lookup(self, cls): 253 | try: 254 | change_type = self.CHANGE_TYPES[cls] 255 | except (KeyError, TypeError): 256 | change_type = self._change_type_slow_search(cls) 257 | 258 | return change_type 259 | 260 | def _change_type_slow_search(self, cls): 261 | for base, change_type in self.CHANGE_TYPES.items(): 262 | if issubclass(cls, base): 263 | return change_type 264 | 265 | return None 266 | 267 | def _generate_changes(self) -> List[PSChangeTypeArray]: 268 | change: PSChangeTypeBase = None 269 | changes = [] 270 | # sub-dictionaries that must be rewritten because 271 | # something was removed. 272 | rewrite_dictionaries = {} 273 | 274 | # we can only append to existing arrays 275 | # if an array changes in any other way, we have to rewrite it 276 | rewrite_lists = {} 277 | domain = self.pref_domain 278 | for k, v in self.added.items(): 279 | # pprint(v) 280 | change_type = self._change_type_lookup(v.__class__) 281 | if not change_type: 282 | print(v.__class__) 283 | try: 284 | change = change_type(domain, self.byhost, k, v) 285 | except PSChangeTypeNotImplementedException as e: 286 | err_msg = f"key: {k}, {e}" 287 | change = PSChangeTypeErrorMessage(err_msg) 288 | 289 | changes.append(change) 290 | 291 | for k in self.removed: 292 | change = PSChangeTypeKeyDeleted(domain, self.byhost, k) 293 | changes.append(change) 294 | 295 | for key, val in self.modified.items(): 296 | if isinstance(val[1], dict): 297 | added, removed, modified, same = self._dict_compare( 298 | val[0], val[1]) 299 | if len(removed): 300 | # There is no -dict-delete so we have to 301 | # rewrite this sub-dictionary 302 | rewrite_dictionaries[key] = val[1] 303 | continue 304 | for subkey, subval in added.items(): 305 | change = PSChangeTypeDictAdd( 306 | domain, self.byhost, key, subkey, subval) 307 | changes.append(change) 308 | for subkey, subval_tuple in modified.items(): 309 | change = PSChangeTypeDictAdd( 310 | domain, self.byhost, key, subkey, subval_tuple[1]) 311 | changes.append(change) 312 | elif isinstance(val[1], list): 313 | list_diffs = self._list_compare(val[0], val[1]) 314 | if list_diffs["same"]: 315 | continue 316 | elif list_diffs["append_to_l1"]: 317 | append = list_diffs["append_to_l1"] 318 | change = PSChangeTypeArrayAdd(domain, key, append) 319 | changes.append(change) 320 | else: 321 | rewrite_lists[key] = val[1] 322 | else: 323 | # for modified keys that aren't dictionaries, we treat them 324 | # like adds 325 | change_type = self._change_type_lookup(val[1].__class__) 326 | try: 327 | change = change_type(domain, self.byhost, key, val[1]) 328 | except PSChangeTypeNotImplementedException as e: 329 | err_msg = f"key: {key}, {e}" 330 | change = PSChangeTypeErrorMessage(err_msg) 331 | changes.append(change) 332 | 333 | for key, val in rewrite_dictionaries.items(): 334 | change = PSChangeTypeDict(domain, self.byhost, key, val) 335 | changes.append(change) 336 | 337 | for key, val in rewrite_lists.items(): 338 | change = PSChangeTypeArray(domain, self.byhost, key, val) 339 | changes.append(change) 340 | 341 | return changes 342 | 343 | @property 344 | def commands(self): 345 | _commands = [ch.shell_command() for ch in self.changes] 346 | return _commands 347 | 348 | def execute(self, args, stdout=None): 349 | subprocess.check_call(args, stdout=stdout) 350 | 351 | 352 | class PrefsWatcher: 353 | class _PrefsWatchFilter: 354 | 355 | def __init__(self, pattern_string, pattern_is_regex=False, negative_match=False): 356 | self.pattern = pattern_string 357 | self.regex = None 358 | if pattern_is_regex: 359 | self.regex = re.compile(pattern_string) 360 | self.negative_match = negative_match 361 | 362 | def passes_filter(self, input_string): 363 | match = False 364 | passes = False 365 | if not self.regex: 366 | match = self.pattern_string in input_string 367 | else: 368 | re_match = self.regex.match(input_string) 369 | if re_match is not None: 370 | match = True 371 | 372 | if self.negative_match: 373 | passes = (not match) 374 | else: 375 | passes = match 376 | 377 | return passes 378 | 379 | def __init__(self, prefsdir): 380 | self.prefsdir = prefsdir 381 | self.filters = [self._PrefsWatchFilter( 382 | r".*\.plist$", pattern_is_regex=True)] 383 | self._watch_prefsdir() 384 | 385 | def _watch_prefsdir(self): 386 | event_queue = Queue() 387 | event_handler = PrefChangedEventHandler(None, event_queue) 388 | observer = Observer() 389 | observer.schedule(event_handler, self.prefsdir, recursive=False) 390 | observer.start() 391 | 392 | while True: 393 | try: 394 | changed = event_queue.get(True, 0.5) 395 | src_path = changed[1].src_path 396 | passes = True 397 | for _filter in self.filters: 398 | if not _filter.passes_filter(src_path): 399 | passes = False 400 | break 401 | if not passes: 402 | continue 403 | print("Detected change: [%s] %s" % 404 | (changed[0], changed[1].src_path)) 405 | except QueueEmpty: 406 | pass 407 | except KeyboardInterrupt: 408 | break 409 | observer.stop() 410 | observer.join() 411 | 412 | 413 | class PrefChangedEventHandler(FileSystemEventHandler): 414 | 415 | def __init__(self, file_base_name, event_queue): 416 | super(self.__class__, self).__init__() 417 | if file_base_name is None: 418 | file_base_name = "" 419 | self.file_base_name = file_base_name 420 | self.event_queue = event_queue 421 | 422 | def on_created(self, event): 423 | if self.file_base_name not in os.path.basename(event.src_path): 424 | return 425 | self.event_queue.put(("created", event)) 426 | 427 | def on_deleted(self, event): 428 | if self.file_base_name not in os.path.basename(event.src_path): 429 | return 430 | self.event_queue.put(("deleted", event)) 431 | 432 | def on_modified(self, event): 433 | if self.file_base_name not in os.path.basename(event.src_path): 434 | return 435 | self.event_queue.put(("modified", event)) 436 | 437 | def on_moved(self, event): 438 | if self.file_base_name not in os.path.basename(event.src_path): 439 | return 440 | self.event_queue.put(("moved", event)) 441 | 442 | 443 | def test_dict_add(domain, key, subkey, value): 444 | prefchange = PSChangeTypeDictAdd(domain, key, subkey, value) 445 | print(str(prefchange)) 446 | 447 | 448 | def test_dict_add_dict(args): 449 | domain = args[0] 450 | key = args[1] 451 | subkey = args[2] 452 | value = {"mykey1": 2.0, "mykey2": 7} 453 | test_dict_add(domain, key, subkey, value) 454 | 455 | 456 | def test_dict_add_float(args): 457 | domain = args[0] 458 | key = args[1] 459 | subkey = args[2] 460 | value = 2.0 461 | test_dict_add(domain, key, subkey, value) 462 | 463 | 464 | def test_write_dict(args): 465 | domain = args[0] 466 | key = args[1] 467 | value = {"dictkey1": 2.0, "dictkey2": {"subkey": '7'}} 468 | prefchange = PSChangeTypeDict(domain, key, value) 469 | print(str(prefchange)) 470 | 471 | 472 | def parse_test_args(argv): 473 | if "test-dict-add-float" == argv[1]: 474 | test_dict_add_float(argv[2:]) 475 | exit(0) 476 | 477 | if "test-dict-add-dict" == argv[1]: 478 | test_dict_add_dict(argv[2:]) 479 | exit(0) 480 | 481 | if "test-write-dict" == argv[1]: 482 | test_write_dict(argv[2:]) 483 | exit(0) 484 | 485 | 486 | def main(): 487 | args = parse_args(sys.argv[1:]) 488 | monitor_dir_events = False 489 | show_diffs = False 490 | 491 | plistpath = args.watchpath 492 | if os.path.isdir(plistpath): 493 | monitor_dir_events = True 494 | elif not os.path.isfile(plistpath): 495 | print("Error: %s is not a directory or file, or does not exist." % plistpath) 496 | exit(1) 497 | 498 | if args.show_diffs: 499 | show_diffs = True 500 | print("{} version {}".format( 501 | PrefsniffAbout.TITLE.upper(), PrefsniffAbout.VERSION)) 502 | if monitor_dir_events: 503 | print("Watching directory: {}".format(plistpath)) 504 | PrefsWatcher(plistpath) 505 | else: 506 | print("Watching prefs file: %s" % plistpath) 507 | done = False 508 | while not done: 509 | plist2 = None 510 | if args.plist2: 511 | plist2 = args.plist2 512 | done = True 513 | 514 | try: 515 | diffs = PrefSniff(plistpath, plistpath2=plist2) 516 | except KeyboardInterrupt: 517 | print("Exiting.") 518 | exit(0) 519 | 520 | print(STARS) 521 | print("") 522 | for ch in diffs.changes: 523 | if isinstance(ch, PSChangeTypeErrorMessage): 524 | print(f"ERROR: {ch}", file=sys.stderr) 525 | continue 526 | try: 527 | ch_dict = dict(ch) 528 | except ValueError: 529 | print(f"type(ch): {type(ch)}") 530 | print(ch) 531 | new_ch = PSChangeTypeFactory.ps_change_type_from_dict(ch_dict) 532 | print(new_ch.shell_command()) 533 | print("") 534 | if show_diffs: 535 | print('\n'.join(diffs.diff)) 536 | print(STARS) 537 | 538 | 539 | if __name__ == '__main__': 540 | main() 541 | -------------------------------------------------------------------------------- /prefsniff/version.py: -------------------------------------------------------------------------------- 1 | from . import __summary__, __title__, __version__ 2 | 3 | 4 | class PrefsniffAbout(object): 5 | VERSION = __version__ 6 | TITLE = __title__ 7 | SUMMARY = __summary__ 8 | 9 | def __str__(self): 10 | return "%s: %s version %s" % (self.TITLE, self.SUMMARY, self.VERSION) 11 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/zcutlip/prefsniff/d29dd0ea7842d480c1421f9ccb9205fee5a570e7/requirements.txt -------------------------------------------------------------------------------- /scripts/deletebranch: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/deletebranch -------------------------------------------------------------------------------- /scripts/functions.sh: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/functions.sh -------------------------------------------------------------------------------- /scripts/gc_about: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/gc_about -------------------------------------------------------------------------------- /scripts/gc_changelog: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/gc_changelog -------------------------------------------------------------------------------- /scripts/project_settings.sh: -------------------------------------------------------------------------------- 1 | # shellcheck shell=sh 2 | # shellcheck disable=SC2034 3 | # if this is used it should be copied to the project's directory next to 4 | # the symlinks/copies of these repo management scripts, and renamed to project_settings.sh 5 | # e.g., project/scripts/project_settings.sh 6 | 7 | # if python3 ./setup.py --name produces the wrong PyPI distribution name 8 | # you maybe override it, setting the proper name here 9 | DISTRIBUTION_NAME="prefsniff" 10 | 11 | # for python projects, if the root package is named differently 12 | # than the project/distribution name, override that here 13 | # e.g., mock-op vs mock_op 14 | # This will get used for scripts that try to locate files *within* the project 15 | # e.g., mock_op/__about__.py 16 | # ROOT_PACKAGE_NAME="prefsniff" 17 | 18 | # Either don't set, or set to "1" to enable 19 | # if set at all and not set to "1" twine upload will not happen 20 | TWINE_UPLOAD_ENABLED="1" 21 | -------------------------------------------------------------------------------- /scripts/release: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/release -------------------------------------------------------------------------------- /scripts/tag: -------------------------------------------------------------------------------- 1 | ../submodules/repo-mgmt-scripts/src/tag -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | # extend-ignore = E501, W504, E302, E221 3 | extend-ignore = E501 4 | 5 | [isort] 6 | multi_line_output = 3 7 | 8 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | about = {} 4 | with open("README.md", "r") as fh: 5 | long_description = fh.read() 6 | 7 | with open("prefsniff/__about__.py") as fp: 8 | exec(fp.read(), about) 9 | 10 | setup(name=about["__title__"], 11 | version=about["__version__"], 12 | description=about["__summary__"], 13 | long_description=long_description, 14 | long_description_content_type="text/markdown", 15 | url="https://github.com/zcutlip/prefsniff", 16 | packages=['prefsniff'], 17 | entry_points={ 18 | 'console_scripts': ['prefsniff=prefsniff.prefsniff:main'], }, 19 | python_requires='>= 3.7', 20 | install_requires=['watchdog>=1.0.2', 'py-dict-repr'], 21 | ) 22 | --------------------------------------------------------------------------------