├── AUTHORS ├── launchd ├── tests │ ├── __init__.py │ ├── cmd.py │ ├── plist.py │ └── launchctl.py ├── __init__.py ├── cmd.py ├── util.py ├── plist.py └── launchctl.py ├── .coveragerc ├── .renovaterc.json ├── .pylintrc ├── MANIFEST.in ├── .gitignore ├── requirements-style.txt ├── .pre-commit-config.yaml ├── setup.cfg ├── .github └── workflows │ └── tests.yml ├── tox.ini ├── CHANGELOG.rst ├── COPYING ├── .travis.yml ├── setup.py ├── example.py └── README.rst /AUTHORS: -------------------------------------------------------------------------------- 1 | Paul Kremer 2 | -------------------------------------------------------------------------------- /launchd/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [report] 2 | omit = launchd/tests/* 3 | -------------------------------------------------------------------------------- /.renovaterc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:base" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.pylintrc: -------------------------------------------------------------------------------- 1 | #[MESSAGES CONTROL] 2 | #disable=missing-docstring 3 | 4 | [FORMAT] 5 | max-line-length=120 6 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS 2 | include CHANGELOG.rst 3 | include COPYING 4 | include README.rst 5 | include example.py 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg 2 | *.egg-info 3 | __pycache__ 4 | .vscode 5 | .coverage 6 | .tox 7 | build 8 | dist 9 | htmlcov 10 | -------------------------------------------------------------------------------- /launchd/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | __author__ = "Paul Kremer" 4 | __email__ = "paul@spurious.biz" 5 | __version__ = "0.3.0" 6 | 7 | from .launchctl import jobs, LaunchdJob, load, unload # noqa: F401 8 | from . import plist # noqa: F401 9 | from . import util # noqa: F401 10 | -------------------------------------------------------------------------------- /requirements-style.txt: -------------------------------------------------------------------------------- 1 | pip 2 | flake8>=3.7.5 3 | pydocstyle==6.1.1 4 | flake8-bandit>=1.0.1 5 | flake8-bugbear>=17.12.0 6 | flake8-comprehensions>=1.4.1 7 | flake8_docstrings>=1.1.0 8 | flake8-tidy-imports>=1.1.0 9 | flake8-rst-docstrings>=0.0.8 10 | Pygments>=2.2.0 11 | flake8-quotes>=0.13.0 12 | flake8-print>=3.0.1 13 | flake8-mutable>=1.2.0 14 | flake8-string-format>=0.2.3 15 | check-manifest>=0.36 16 | safety>=1.6.1 17 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | repos: 3 | - repo: https://github.com/pre-commit/pre-commit-hooks 4 | rev: v4.4.0 5 | hooks: 6 | - id: check-ast 7 | - id: check-byte-order-marker 8 | - id: check-case-conflict 9 | - id: check-docstring-first 10 | - id: check-merge-conflict 11 | - id: detect-private-key 12 | - id: end-of-file-fixer 13 | - id: fix-encoding-pragma 14 | - id: mixed-line-ending 15 | - id: trailing-whitespace 16 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = D100,D101,D102,D103,D104,D200,D204,D205 3 | exclude = .git,__pycache__,build,dist,.tox,.eggs,.direnv 4 | max_line_length = 120 5 | # flake8-quotes: 6 | inline-quotes = double 7 | per-file-ignores = 8 | example.py:T201 9 | 10 | [check-manifest] 11 | # https://pypi.python.org/pypi/check-manifest 12 | ignore = 13 | .coveragerc 14 | .pre-commit-config.yaml 15 | .pylintrc 16 | .renovaterc.json 17 | .github 18 | tox.ini 19 | requirements-style.txt 20 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | --- 2 | name: unit tests 3 | 4 | on: 5 | - push 6 | - pull_request 7 | 8 | jobs: 9 | test: 10 | strategy: 11 | fail-fast: false 12 | matrix: # https://github.com/actions/runner-images#available-images 13 | os: [macos-10.15, macos-11, macos-12] 14 | runs-on: ${{ matrix.os }} 15 | steps: 16 | - uses: actions/checkout@8ade135a41bc03ea155e62e844d188df1ea18608 # v4 17 | - name: Install dependencies 18 | run: | 19 | python3 --version 20 | python3 -m pip install --upgrade pip 21 | pip3 install tox 22 | - name: Test with tox 23 | run: tox -e py,style 24 | -------------------------------------------------------------------------------- /launchd/tests/cmd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | 5 | import six 6 | 7 | from launchd import cmd 8 | 9 | 10 | class LaunchdCmdTest(unittest.TestCase): 11 | 12 | def setUp(self): 13 | unittest.TestCase.setUp(self) 14 | 15 | def tearDown(self): 16 | unittest.TestCase.tearDown(self) 17 | 18 | def testlaunchctl_invalid_args(self): 19 | self.assertRaises(ValueError, cmd.launchctl, ["foo"]) 20 | 21 | def testlaunchctl_list(self): 22 | stdout = cmd.launchctl("list").decode("utf-8") 23 | self.assertTrue(isinstance(stdout, six.string_types)) 24 | 25 | def testlaunchctl_list_x(self): 26 | label = "com.apple.Finder" 27 | stdout = cmd.launchctl("list", label).decode("utf-8") 28 | self.assertTrue(isinstance(stdout, six.string_types)) 29 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Tox (http://tox.testrun.org/) 2 | 3 | [tox] 4 | envlist = py37, py38, py39, py310, py311 5 | 6 | [testenv] 7 | deps = coverage 8 | commands = coverage run --source launchd setup.py test 9 | 10 | [testenv:style] 11 | deps = -rrequirements-style.txt 12 | commands = flake8 {posargs} --count --statistics 13 | flake8 --version 14 | check-manifest -v 15 | # Check for security issues in installed packages 16 | safety check --full-report 17 | 18 | # Release tooling 19 | [testenv:build] 20 | skip_install = true 21 | allowlist_externals = rm 22 | deps = 23 | wheel 24 | setuptools 25 | commands = 26 | rm -rf dist 27 | python setup.py -q sdist bdist_wheel 28 | 29 | [testenv:release] 30 | skip_install = true 31 | deps = 32 | {[testenv:build]deps} 33 | twine >= 1.5.0 34 | commands = 35 | {[testenv:build]commands} 36 | twine upload --skip-existing dist/* 37 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Release history 2 | --------------- 3 | 4 | 0.3.0 (June 2021) 5 | +++++++++++++++++ 6 | - changed: create directory hierarchy for plist file if not present. issue #6 7 | - improved: added automated flake8 tests, check-manifest and safety checks 8 | - changed: moved basic CI to GitHub actions 9 | 10 | 0.2.0 (March 2021) 11 | ++++++++++++++++++ 12 | - drop python 2.x, 3.2, 3.3 support 13 | - fix plistlib calls (issue #4) 14 | 15 | 0.1.2 (September 2020) 16 | ++++++++++++++++++++++ 17 | - added tox.ini for easier testing accross interpreter versions 18 | - added travis test setup 19 | - fixed incompatibility with `launchctl` in test code 20 | - fixed a typo in the README 21 | 22 | 0.1.1 (November 2013) 23 | +++++++++++++++++++++ 24 | - Fixed a bug in launchd.plist.read() when no scope was specified 25 | 26 | 0.1 (November 2013) 27 | +++++++++++++++++++ 28 | - Focus: initial public release 29 | -------------------------------------------------------------------------------- /launchd/cmd.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import subprocess # noqa: S404 4 | 5 | import six 6 | 7 | 8 | def launchctl(subcommand, *args): 9 | """ 10 | Call the launchctl binary and capture the output. 11 | 12 | :param subcommand: string 13 | """ 14 | if not isinstance(subcommand, six.string_types): 15 | raise ValueError("Argument is invalid: %r" % repr(subcommand)) 16 | if isinstance(subcommand, six.text_type): 17 | subcommand = subcommand.encode("utf-8") 18 | 19 | cmd = ["launchctl", subcommand] 20 | for arg in args: 21 | if isinstance(arg, six.string_types): 22 | if isinstance(arg, six.text_type): 23 | cmd.append(arg.encode("utf-8")) 24 | else: 25 | cmd.append(arg) 26 | else: 27 | raise ValueError("Argument is invalid: %r" % repr(arg)) 28 | return subprocess.check_output(cmd, stdin=None, stderr=subprocess.STDOUT, shell=False) # noqa: S603 29 | -------------------------------------------------------------------------------- /launchd/util.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | from Foundation import NSDictionary, NSArray 4 | from objc._pythonify import OC_PythonLong, OC_PythonFloat 5 | from objc import pyobjc_unicode 6 | 7 | 8 | def convert_NS_to_python(val): 9 | if isinstance(val, (pyobjc_unicode, str)): 10 | return str(val) 11 | elif isinstance(val, (OC_PythonLong, int)): 12 | return int(val) 13 | elif isinstance(val, (NSDictionary, dict)): 14 | return convert_NSDictionary_to_dict(val) 15 | elif isinstance(val, (NSArray, list, tuple)): 16 | return convert_NSArray_to_tuple(val) 17 | elif isinstance(val, (OC_PythonFloat,)): 18 | return float(val) 19 | else: 20 | raise TypeError("Unknown type '%s': '%r'!" % (str(type(val)), repr(val))) 21 | 22 | 23 | def convert_NSArray_to_tuple(nsarray): 24 | return ((convert_NS_to_python(val) for val in nsarray),) 25 | 26 | 27 | def convert_NSDictionary_to_dict(nsdict): 28 | return {convert_NS_to_python(k): convert_NS_to_python(nsdict[k]) for k in nsdict} 29 | -------------------------------------------------------------------------------- /COPYING: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Paul Kremer 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in 11 | all copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | matrix: 3 | include: 4 | - name: "Python 3.7.5 on macOS 10.14" 5 | os: osx 6 | osx_image: xcode10.2 7 | language: shell # 'language: python' errors on Travis CI macOS 8 | before_install: 9 | - python3 --version 10 | install: pip3 install tox --user 11 | script: tox -e py37 12 | env: PATH=/Users/travis/Library/Python/3.7/bin:$PATH 13 | - name: "Python 3.8 on macOS 10.15" 14 | os: osx 15 | osx_image: xcode12u 16 | language: shell # 'language: python' errors on Travis CI macOS 17 | before_install: 18 | - python3 --version 19 | install: pip3 install tox --user 20 | script: tox -e py38 21 | env: PATH=/Users/travis/Library/Python/3.8/bin:$PATH 22 | - name: "Python 3.9 on macOS 10.15" 23 | os: osx 24 | osx_image: xcode12.2 25 | language: shell # 'language: python' errors on Travis CI macOS 26 | before_install: 27 | - python3 --version 28 | install: pip3 install tox --user 29 | script: tox -e py39 30 | env: PATH=/Users/travis/Library/Python/3.9/bin:$PATH 31 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import re 6 | import os 7 | 8 | try: 9 | from setuptools import setup 10 | except ImportError: 11 | from distutils.core import setup 12 | 13 | classifiers = [line.strip() for line in """ 14 | Development Status :: 3 - Alpha 15 | Intended Audience :: Developers 16 | Intended Audience :: System Administrators 17 | License :: DFSG approved 18 | License :: OSI Approved 19 | License :: OSI Approved :: MIT License 20 | Topic :: Software Development :: Libraries :: Python Modules 21 | Environment :: MacOS X 22 | Natural Language :: English 23 | Operating System :: MacOS :: MacOS X 24 | Programming Language :: Python 25 | Programming Language :: Python :: 3.4 26 | Programming Language :: Python :: 3.5 27 | Programming Language :: Python :: 3.6 28 | Programming Language :: Python :: 3.7 29 | Programming Language :: Python :: 3.8 30 | Programming Language :: Python :: 3.9 31 | Programming Language :: Python :: Implementation :: CPython 32 | """.splitlines() if len(line) > 0] 33 | 34 | install_requires = ["six", "pyobjc-framework-ServiceManagement"] 35 | 36 | if "darwin" not in sys.platform: 37 | sys.stderr.write("Warning: The package 'launchd' can only be installed and run on OS X!" + os.linesep) 38 | 39 | v = open(os.path.join(os.path.dirname(__file__), "launchd", "__init__.py")) 40 | VERSION = re.compile(r".*__version__ = \"(.*?)\"", re.S).match(v.read()).group(1) 41 | v.close() 42 | LONG_DESCRIPTION = open("README.rst", "r").read() + "\n\n" + open("CHANGELOG.rst", "r").read() 43 | setup(name="launchd", 44 | packages=["launchd", "launchd.tests"], 45 | version=VERSION, 46 | author="Paul Kremer", 47 | author_email="@".join(("paul", "spurious.biz")), # avoid spam, 48 | license="MIT License", 49 | description="pythonic interface for macOS launchd", 50 | long_description=LONG_DESCRIPTION, 51 | url="https://github.com/infothrill/python-launchd", 52 | install_requires=install_requires, 53 | classifiers=classifiers, 54 | test_suite="launchd.tests", 55 | tests_require=["six"], 56 | ) 57 | -------------------------------------------------------------------------------- /example.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Example script showing how to install and remove plist based launchd jobs. 4 | """ 5 | 6 | import sys 7 | import os 8 | 9 | import launchd 10 | 11 | 12 | def install(label, plist): 13 | """ 14 | Store a new .plist file and load it. 15 | 16 | :param label: job label 17 | :param plist: a property list dictionary 18 | """ 19 | fname = launchd.plist.write(label, plist) 20 | launchd.load(fname) 21 | 22 | 23 | def uninstall(label): 24 | """ 25 | Remove a .plist file and unload it. 26 | 27 | :param label: job label 28 | """ 29 | if launchd.LaunchdJob(label).exists(): 30 | fname = launchd.plist.discover_filename(label) 31 | launchd.unload(fname) 32 | os.unlink(fname) 33 | 34 | 35 | def main(): 36 | myplist = { 37 | "Disabled": False, 38 | "Label": "testlaunchdwrapper_python", 39 | "Nice": -15, 40 | "OnDemand": True, 41 | "ProgramArguments": ["/bin/bash", "-c", "sleep 1 && echo 'Hello World' && exit 0"], 42 | "RunAtLoad": True, 43 | "ServiceDescription": "runs a sample command", 44 | "ServiceIPC": False, 45 | } 46 | 47 | import time 48 | label = myplist["Label"] 49 | job = launchd.LaunchdJob(label) 50 | if not job.exists(): 51 | print("'%s' is not loaded in launchd. Installing..." % (label)) # noqa: T201 52 | install(label, myplist) 53 | while job.pid is not None: 54 | print("Alive! PID = %s" % job.pid) # noqa: T201 55 | job.refresh() 56 | time.sleep(0.2) 57 | else: 58 | if job.pid is None: 59 | print("'%s' is loaded but not currently running" % (job.label)) # noqa: T201 60 | else: 61 | print("'%s' is loaded and currently running: PID = %s" % (job.label, job.pid)) # noqa: T201 62 | while job.pid is not None: 63 | print("Alive! PID = %s" % job.pid) # noqa: T201 64 | job.refresh() 65 | time.sleep(0.2) 66 | 67 | print("Uninstalling again...") # noqa: T201 68 | uninstall(label) 69 | return 0 70 | 71 | 72 | if __name__ == "__main__": 73 | sys.exit(main()) 74 | -------------------------------------------------------------------------------- /launchd/plist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import os 4 | import plistlib 5 | 6 | USER = 1 7 | USER_ADMIN = 2 8 | DAEMON_ADMIN = 3 9 | USER_OS = 4 10 | DAEMON_OS = 5 11 | 12 | PLIST_LOCATIONS = { 13 | USER: "~/Library/LaunchAgents", # Per-user agents provided by the user. 14 | USER_ADMIN: "/Library/LaunchAgents", # Per-user agents provided by the administrator. 15 | DAEMON_ADMIN: "/Library/LaunchDaemons", # System-wide daemons provided by the administrator. 16 | USER_OS: "/System/Library/LaunchAgents", # Per-user agents provided by Mac OS X. 17 | DAEMON_OS: "/System/Library/LaunchDaemons", # System-wide daemons provided by Mac OS X. 18 | } 19 | 20 | 21 | def compute_directory(scope): 22 | return os.path.expanduser(PLIST_LOCATIONS[scope]) 23 | 24 | 25 | def compute_filename(label, scope): 26 | return os.path.join(compute_directory(scope), label + ".plist") 27 | 28 | 29 | def discover_filename(label, scopes=None): 30 | """ 31 | Check the filesystem for the existence of a .plist file matching the job label. 32 | Optionally specify one or more scopes to search (default all). 33 | 34 | :param label: string 35 | :param scope: tuple or list or oneOf(USER, USER_ADMIN, DAEMON_ADMIN, USER_OS, DAEMON_OS) 36 | """ 37 | if scopes is None: 38 | scopes = list(PLIST_LOCATIONS) 39 | elif not isinstance(scopes, (list, tuple)): 40 | scopes = (scopes, ) 41 | for thisscope in scopes: 42 | plistfilename = compute_filename(label, thisscope) 43 | if os.path.isfile(plistfilename): 44 | return plistfilename 45 | return None 46 | 47 | 48 | def read(label, scope=None): 49 | with open(discover_filename(label, scope), "rb") as f: 50 | return plistlib.load(f) 51 | 52 | 53 | def write(label, plist, scope=USER): 54 | """ 55 | Write the property list to file on disk and return filename. 56 | 57 | Creates the underlying parent directory structure if missing. 58 | :param plist: dict 59 | :param label: string 60 | :param scope: oneOf(USER, USER_ADMIN, DAEMON_ADMIN, USER_OS, DAEMON_OS) 61 | """ 62 | os.makedirs(compute_directory(scope), mode=0o755, exist_ok=True) 63 | fname = compute_filename(label, scope) 64 | with open(fname, "wb") as f: 65 | plistlib.dump(plist, f) 66 | return fname 67 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | .. image:: https://img.shields.io/pypi/v/launchd.svg 2 | :target: https://pypi.python.org/pypi/launchd 3 | 4 | .. image:: https://github.com/infothrill/python-launchd/actions/workflows/tests.yml/badge.svg?branch=main 5 | 6 | *launchd* is a pythonic interface to interact with macOS's `launchd `_. 7 | It provides access to basic querying and interaction with launchd. It is 8 | implemented using the Objective C 9 | `ServiceManagement framework `_ 10 | as well as the `launchd` command line utility. Therefore, this python package 11 | can only be used on `macOS `_ 12 | 13 | The python objective C bridge contains some special types. This package strips 14 | off all non built-in type information and returns pure python data. 15 | 16 | Examples 17 | ======== 18 | 19 | The relevant import statement is: 20 | 21 | .. code-block:: python 22 | 23 | import launchd 24 | 25 | 26 | Listing all launchd jobs: 27 | 28 | .. code-block:: python 29 | 30 | for job in launchd.jobs(): 31 | print(job.label, job.pid, job.laststatus, job.properties, job.plistfilename) 32 | 33 | 34 | Find the pid and laststatus of a job: 35 | 36 | .. code-block:: python 37 | 38 | >>> launchd.LaunchdJob("com.apple.Finder").pid 39 | 278 40 | 41 | >>> launchd.LaunchdJob("com.apple.Finder").laststatus 42 | 0 43 | 44 | >>> launchd.LaunchdJob("com.example.fubar").pid 45 | Traceback (most recent call last): 46 | File "launchd/launchctl.py", line 78, in refresh 47 | raise ValueError("job '%s' does not exist" % self.label) 48 | ValueError: job 'com.example.fubar' does not exist 49 | 50 | Detect if a job exists: 51 | 52 | .. code-block:: python 53 | 54 | >>> launchd.LaunchdJob("com.example.fubar").exists() 55 | False 56 | 57 | launchd job properties (these come directly from launchd and NOT the .plist files): 58 | 59 | .. code-block:: python 60 | 61 | >>> launchd.LaunchdJob("com.apple.Finder").properties 62 | {'OnDemand': 1, 'PID': 278, 'PerJobMachServices': {'com.apple.coredrag': 0, 63 | 'com.apple.axserver': 0, 'com.apple.CFPasteboardClient': 0, 64 | 'com.apple.tsm.portname': 0}, 'LimitLoadToSessionType': 'Aqua', 65 | 'Program': '/System/Library/CoreServices/Finder.app/Contents/MacOS/Finder', 66 | 'TimeOut': 30, 'LastExitStatus': 0, 'Label': 'com.apple.Finder', 67 | 'MachServices': {'com.apple.finder.ServiceProvider': 10}} 68 | 69 | >>> launchd.LaunchdJob("com.apple.Finder").properties["OnDemand"] 70 | 1 71 | 72 | 73 | Find all plist filenames of currently running jobs: 74 | 75 | .. code-block:: python 76 | 77 | for job in launchd.jobs(): 78 | if job.pid is None or job.plistfilename is None: 79 | continue 80 | print(job.plistfilename) 81 | 82 | Job properties of a given job (this uses the actual .plist file): 83 | 84 | .. code-block:: python 85 | 86 | >>> launchd.plist.read("com.apple.kextd") 87 | {'ProgramArguments': ['/usr/libexec/kextd'], 'KeepAlive': {'SuccessfulExit': False}, 88 | 'POSIXSpawnType': 'Interactive', 'MachServices': {'com.apple.KernelExtensionServer': 89 | {'HostSpecialPort': 15}}, 'Label': 'com.apple.kextd'} 90 | 91 | 92 | 93 | Installation 94 | ============ 95 | 96 | .. code-block:: bash 97 | 98 | $ pip install launchd 99 | 100 | or, if you want to work using the source tarball: 101 | 102 | .. code-block:: bash 103 | 104 | $ python setup.py install 105 | 106 | 107 | Requirements 108 | ============ 109 | * OS X >= 10.6 110 | * Python 3.4+ 111 | -------------------------------------------------------------------------------- /launchd/tests/plist.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import sys 4 | import os 5 | import unittest 6 | 7 | from launchd import plist 8 | 9 | 10 | class PlistToolTest(unittest.TestCase): 11 | 12 | def setUp(self): 13 | unittest.TestCase.setUp(self) 14 | 15 | def tearDown(self): 16 | unittest.TestCase.tearDown(self) 17 | 18 | def test_compute_filename(self): 19 | fname = plist.compute_filename("fubar", plist.USER) 20 | self.assertTrue("fubar.plist" in fname) 21 | self.assertTrue("/Library/LaunchAgents" in fname) 22 | self.assertFalse(fname.startswith("/Library/LaunchAgents")) 23 | 24 | fname = plist.compute_filename("fubar", plist.USER_ADMIN) 25 | self.assertTrue("fubar.plist" in fname) 26 | self.assertTrue(fname.startswith("/Library/LaunchAgents/")) 27 | 28 | fname = plist.compute_filename("fubar", plist.USER_OS) 29 | self.assertTrue("fubar.plist" in fname) 30 | self.assertTrue(fname.startswith("/System/Library/LaunchAgents/")) 31 | 32 | fname = plist.compute_filename("fubar", plist.DAEMON_ADMIN) 33 | self.assertTrue("fubar.plist" in fname) 34 | self.assertTrue(fname.startswith("/Library/LaunchDaemons/")) 35 | 36 | fname = plist.compute_filename("fubar", plist.DAEMON_OS) 37 | self.assertTrue("fubar.plist" in fname) 38 | self.assertTrue(fname.startswith("/System/Library/LaunchDaemons/")) 39 | 40 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 41 | def test_discover_filename(self): 42 | sample_label = "com.apple.configd" 43 | fname = plist.discover_filename(sample_label, plist.DAEMON_OS) 44 | self.assertIsNotNone(fname) 45 | self.assertTrue(os.path.isfile(fname)) 46 | 47 | # no scope specified 48 | fname2 = plist.discover_filename(sample_label) 49 | self.assertIsNotNone(fname) 50 | self.assertTrue(os.path.isfile(fname2)) 51 | 52 | fname = plist.discover_filename(sample_label, plist.USER) 53 | self.assertIsNone(fname) 54 | 55 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 56 | def test_read(self): 57 | sample_label = "com.apple.configd" 58 | result = plist.read(sample_label, plist.DAEMON_OS) 59 | self.assertIsInstance(result, dict) 60 | 61 | 62 | class PlistToolPersistencyTest(unittest.TestCase): 63 | def setUp(self): 64 | self.sample_label = "com.example.unittest" 65 | self.sample_props = {"Label": "testlaunchdwrapper_python"} 66 | fname = plist.discover_filename(self.sample_label, plist.USER) 67 | if fname is not None: 68 | os.unlink(fname) 69 | unittest.TestCase.setUp(self) 70 | 71 | def tearDown(self): 72 | fname = plist.discover_filename(self.sample_label, plist.USER) 73 | if fname is not None: 74 | os.unlink(fname) 75 | unittest.TestCase.tearDown(self) 76 | 77 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 78 | def test_read_write(self): 79 | sample_label = "com.example.unittest" 80 | sample_props = {"Label": "testlaunchdwrapper_python"} 81 | 82 | fname = plist.discover_filename(sample_label, plist.USER) 83 | self.assertEqual(None, fname) 84 | plist.write(sample_label, sample_props, plist.USER) 85 | 86 | fname = plist.discover_filename(sample_label, plist.USER) 87 | self.assertTrue(os.path.isfile(fname)) 88 | props = plist.read(sample_label, plist.USER) 89 | self.assertEqual(sample_props, props) 90 | 91 | # read it without specifying the scope: 92 | props = plist.read(sample_label) 93 | self.assertEqual(sample_props, props) 94 | -------------------------------------------------------------------------------- /launchd/launchctl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import ServiceManagement 4 | 5 | from .cmd import launchctl 6 | from .plist import discover_filename 7 | from .util import convert_NSDictionary_to_dict 8 | 9 | 10 | class LaunchdJob(object): 11 | """ 12 | Class to lazily query the properties of the LaunchdJob when accessed. 13 | """ 14 | def __init__(self, label, pid=-1, laststatus=""): 15 | """ 16 | Instantiate a LaunchdJob instance. Only the label is required. 17 | If no pid or laststatus are specified, they will be queried when 18 | accessed. 19 | 20 | :param label: required string job label 21 | :param pid: optional int, if known. Can be None. 22 | :param laststatus: optional int, if known. Can be None. 23 | """ 24 | self._label = label 25 | if pid != -1: # -1 indicates no value specified 26 | self._pid = pid 27 | if laststatus != "": 28 | self._laststatus = laststatus 29 | self._properties = None 30 | self._plist_fname = None 31 | 32 | @property 33 | def label(self): 34 | return self._label 35 | 36 | @property 37 | def pid(self): 38 | try: 39 | return self._pid 40 | except AttributeError: 41 | pass 42 | self.refresh() 43 | return self._pid 44 | 45 | @property 46 | def laststatus(self): 47 | try: 48 | return self._laststatus 49 | except AttributeError: 50 | self.refresh() 51 | return self._laststatus 52 | 53 | @property 54 | def properties(self): 55 | """ 56 | Lazily load dictionary with launchd runtime information. 57 | 58 | Internally, this is retrieved using ServiceManagement.SMJobCopyDictionary(). 59 | Keep in mind that some dictionary keys are not always present (for example 'PID'). 60 | If the job specified by the label cannot be found in launchd, then 61 | this method raises a ValueError exception. 62 | """ 63 | if hasattr(self, "_nsproperties"): 64 | self._properties = convert_NSDictionary_to_dict(self._nsproperties) 65 | del self._nsproperties 66 | # self._nsproperties = None 67 | if self._properties is None: 68 | self.refresh() 69 | return self._properties 70 | 71 | def exists(self): 72 | return ServiceManagement.SMJobCopyDictionary(None, self.label) is not None 73 | 74 | def refresh(self): 75 | val = ServiceManagement.SMJobCopyDictionary(None, self.label) 76 | if val is None: 77 | raise ValueError("job '%s' does not exist" % self.label) 78 | else: 79 | self._properties = convert_NSDictionary_to_dict(val) 80 | # update pid and laststatus attributes 81 | try: 82 | self._pid = self._properties["PID"] 83 | except KeyError: 84 | self._pid = None 85 | try: 86 | self._laststatus = self._properties["LastExitStatus"] 87 | except KeyError: 88 | self._laststatus = None 89 | 90 | @property 91 | def plistfilename(self): 92 | """ 93 | Lazily detect absolute filename of the property list file. 94 | 95 | Return None if it doesn't exist. 96 | """ 97 | if self._plist_fname is None: 98 | self._plist_fname = discover_filename(self.label) 99 | return self._plist_fname 100 | 101 | 102 | def jobs(): 103 | for entry in ServiceManagement.SMCopyAllJobDictionaries(None): 104 | label = entry["Label"] 105 | if label.startswith("0x"): 106 | continue 107 | try: 108 | pid = int(entry["PID"]) 109 | except KeyError: 110 | pid = None 111 | try: 112 | laststatus = int(entry["LastExitStatus"]) 113 | except KeyError: 114 | laststatus = None 115 | job = LaunchdJob(label, pid, laststatus) 116 | job._nsproperties = entry 117 | yield job 118 | 119 | 120 | def load(*args): 121 | return launchctl("load", *args) 122 | 123 | 124 | def unload(*args): 125 | return launchctl("unload", *args) 126 | -------------------------------------------------------------------------------- /launchd/tests/launchctl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | import unittest 4 | import sys 5 | import os 6 | 7 | import six 8 | 9 | import launchd 10 | 11 | 12 | launchdtestplist = { 13 | "Disabled": False, 14 | "Label": "testlaunchdwrapper_python", 15 | "Nice": -15, 16 | "OnDemand": True, 17 | "ProgramArguments": ["/bin/bash", "-c", "echo 'Hello World' && exit 0"], 18 | "RunAtLoad": True, 19 | "ServiceDescription": "runs a sample command", 20 | "ServiceIPC": False, 21 | } 22 | 23 | 24 | class LaunchctlTestCase(unittest.TestCase): 25 | 26 | def setUp(self): 27 | unittest.TestCase.setUp(self) 28 | 29 | def tearDown(self): 30 | unittest.TestCase.tearDown(self) 31 | 32 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 33 | def test_examples(self): 34 | [job for job in launchd.jobs() if job.pid is not None] # activejobs 35 | [job for job in launchd.jobs() if job.pid is None] # inactivejobs 36 | [job for job in launchd.jobs() if job.laststatus != 0 and job.laststatus is not None] # errorjobs 37 | [job for job in launchd.jobs() if job.properties["OnDemand"] is True] # ondemandjobs 38 | 39 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 40 | def test_launchd_jobs(self): 41 | jobs = launchd.jobs() 42 | self.assertFalse(isinstance(jobs, list)) # it's a generator! 43 | count = 0 44 | for job in jobs: 45 | count += 1 46 | self.assertTrue(isinstance(job, launchd.LaunchdJob)) 47 | self.assertTrue(job.pid is None or isinstance(job.pid, int)) 48 | self.assertTrue(job.laststatus is None or isinstance(job.laststatus, int)) 49 | self.assertTrue(isinstance(job.properties, dict), "props is not a dict: %s" % (type(job.properties))) 50 | self.assertTrue(job.plistfilename is None or isinstance(job.plistfilename, six.string_types)) 51 | # the next 2 fail sometimes due to short lived processes that 52 | # have disappeared by the time we reach this test 53 | self.assertTrue("PID" in job.properties if job.pid is not None else True) 54 | self.assertTrue("PID" not in job.properties if job.pid is None else True) 55 | self.assertTrue(count > 0) 56 | 57 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 58 | def test_launchd_jobs_and_plist(self): 59 | for job in launchd.jobs(): 60 | if job.plistfilename is not None: 61 | self.assertTrue(os.path.isfile(job.plistfilename)) 62 | 63 | @unittest.skipUnless(sys.platform.startswith("darwin"), "requires OS X") 64 | def test_launchd_lazy_constructor(self): 65 | # we assume that com.apple.Finder always exists and that it is always 66 | # running and always has a laststatus. Hmmmm. 67 | label = "com.apple.Finder" 68 | job = launchd.LaunchdJob(label) 69 | self.assertTrue(job.exists()) 70 | self.assertFalse(hasattr(job, "_pid")) 71 | self.assertFalse(hasattr(job, "_laststatus")) 72 | self.assertEqual(None, job._properties) 73 | job.refresh() 74 | self.assertNotEqual(None, job._pid) 75 | self.assertNotEqual(None, job._laststatus) 76 | self.assertNotEqual(None, job._properties) 77 | 78 | job = launchd.LaunchdJob(label) 79 | self.assertTrue(job.exists()) 80 | self.assertNotEqual(None, job.pid) 81 | self.assertNotEqual(None, job.laststatus) 82 | self.assertNotEqual(None, job._properties) 83 | 84 | # let's do the same with something invalid: 85 | label = "com.apple.Nonexistant-bogus-entry" 86 | job = launchd.LaunchdJob(label, 1, 2) 87 | self.assertEqual(1, job.pid) 88 | self.assertEqual(2, job.laststatus) 89 | self.assertFalse(job.exists()) 90 | self.assertRaises(ValueError, job.refresh) 91 | # even though refresh() was called, the object remains unchanged: 92 | self.assertEqual(1, job.pid) 93 | self.assertEqual(2, job.laststatus) 94 | self.assertFalse(job.exists()) 95 | 96 | # also test "None": 97 | label = "com.apple.Nonexistant-bogus-entry2" 98 | job = launchd.LaunchdJob(label, None, None) 99 | self.assertEqual(None, job.pid) 100 | self.assertEqual(None, job.laststatus) 101 | --------------------------------------------------------------------------------