├── .coveragerc ├── .gitignore ├── .readthedocs.yaml ├── .travis.yml ├── LICENSE ├── README.rst ├── SECURITY.md ├── dev-requirements.txt ├── docs ├── api │ ├── environment.rst │ ├── files.rst │ ├── info.rst │ ├── packages.rst │ ├── transfers.rst │ └── util.rst ├── changelog.rst ├── conf.py └── index.rst ├── patchwork ├── __init__.py ├── _version.py ├── environment.py ├── files.py ├── info.py ├── packages │ └── __init__.py ├── transfers.py └── util.py ├── setup.cfg ├── setup.py ├── tasks.py └── tests ├── conftest.py ├── files.py ├── info.py ├── transfers.py └── util.py /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | branch = True 3 | include = patchwork/* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | docs/_build 2 | .cache 3 | .coverage 4 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | build: 4 | os: "ubuntu-22.04" 5 | tools: 6 | python: "3.7" 7 | 8 | python: 9 | install: 10 | - requirements: dev-requirements.txt 11 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | python: 4 | - "2.7" 5 | - "3.4" 6 | - "3.5" 7 | - "3.6" 8 | - "pypy" 9 | - "pypy3" 10 | matrix: 11 | # pypy3 (as of 2.4.0) has a wacky arity issue in its source loader. Allow it 12 | # to fail until we can test on, and require, PyPy3.3+. See invoke#358. 13 | allow_failures: 14 | - python: pypy3 15 | # Disabled per https://github.com/travis-ci/travis-ci/issues/1696 16 | # fast_finish: true 17 | install: 18 | - pip install -r dev-requirements.txt 19 | script: 20 | # Run tests w/ coverage first, so it uses the local-installed copy. 21 | # (If we do this after the below installation tests, coverage will think 22 | # nothing got covered!) 23 | - inv coverage --report=xml 24 | # TODO: tighten up these install test tasks so they can be one-shotted 25 | - inv travis.test-installation --package=patchwork --sanity="inv sanity" 26 | - inv travis.test-packaging --package=patchwork --sanity="inv sanity" 27 | - inv docs --nitpick 28 | - flake8 29 | # TODO: after_success -> codecov, once coverage sucks less XD 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Jeff Forcier 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | * Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 17 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 18 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 19 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 20 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 21 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 22 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Patchwork is a mid-level library of Unix system administration primitives such 2 | as "install package" or "create user account", interrogative functionality for 3 | introspecting system state, and other commonly useful functions built on top of 4 | the `Fabric `_ library. 5 | 6 | Specifically: 7 | 8 | - Primary API calls strive to be **idempotent**: they may be called multiple 9 | times in a row without unwanted changes piling up or causing errors. 10 | - Patchwork **is just an API**: it has no concept of "recipes", "manifests", 11 | "classes", "roles" or other high level organizational units. This is left up 12 | to the user or wrapping libraries. 13 | 14 | - This is one way Patchwork differs from larger configuration management 15 | frameworks like `Chef `_ or `Puppet 16 | `_. Patchwork is closest in nature to those tools' 17 | "resources." 18 | 19 | - It is implemented in **shell calls**, typically sent **over SSH** from a 20 | local workstation. 21 | 22 | - However, where possible, its functions expect a baseline Invoke 23 | `~invoke.context.Context` object and can thus run locally *or* remotely, 24 | depending on the specific context supplied by the caller. 25 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security contact information 2 | 3 | To report a security vulnerability, please use the 4 | [Tidelift security contact](https://tidelift.com/security). 5 | Tidelift will coordinate the fix and disclosure. 6 | -------------------------------------------------------------------------------- /dev-requirements.txt: -------------------------------------------------------------------------------- 1 | # Require a newer fabric than our public API does, since it includes the now 2 | # public test helpers. Bleh. 3 | fabric>=2.1.3,<3 4 | Sphinx>=1.4,<1.7 5 | releases>=1.6,<2.0 6 | alabaster==0.7.12 7 | wheel==0.24 8 | twine==1.11.0 9 | invocations>=1.3.0,<2.0 10 | pytest-relaxed==1.1.4 11 | coverage==4.4.2 12 | pytest-cov==2.4.0 13 | mock==1.0.1 14 | flake8==3.5.0 15 | -e . 16 | -------------------------------------------------------------------------------- /docs/api/environment.rst: -------------------------------------------------------------------------------- 1 | =============== 2 | ``environment`` 3 | =============== 4 | 5 | .. automodule:: patchwork.environment 6 | -------------------------------------------------------------------------------- /docs/api/files.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | ``files`` 3 | ========= 4 | 5 | .. automodule:: patchwork.files 6 | -------------------------------------------------------------------------------- /docs/api/info.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | ``info`` 3 | ======== 4 | 5 | .. automodule:: patchwork.info 6 | -------------------------------------------------------------------------------- /docs/api/packages.rst: -------------------------------------------------------------------------------- 1 | ============ 2 | ``packages`` 3 | ============ 4 | 5 | .. automodule:: patchwork.packages 6 | -------------------------------------------------------------------------------- /docs/api/transfers.rst: -------------------------------------------------------------------------------- 1 | ============= 2 | ``transfers`` 3 | ============= 4 | 5 | .. automodule:: patchwork.transfers 6 | -------------------------------------------------------------------------------- /docs/api/util.rst: -------------------------------------------------------------------------------- 1 | ======== 2 | ``util`` 3 | ======== 4 | 5 | .. automodule:: patchwork.util 6 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Changelog 3 | ========= 4 | 5 | - :support:`-` Publicly document the `.util` module and its `~.util.set_runner` 6 | decorator, which decorates the functions in the `.files` module (and any 7 | future applicable modules) allowing users to specify extra arguments like 8 | ``sudo=True`` or ``runner_method="local"``. 9 | 10 | As part of publicizing this, we added some docstring mutation to the 11 | decorator so returned objects expose Sphinx autodoc hints and parameter 12 | lists. This should replace the ``function(*args, **kwargs)`` signatures that 13 | used to end up in the rendered documentation. 14 | - :support:`-` Add parameter lists to the members of the `.files` module. 15 | - :release:`1.0.1 <2018-06-20>` 16 | - :bug:`23` Fix some outstanding Python 2-isms (use of ``iteritems``) in 17 | `.info.distro_name` and `.info.distro_family`, as well as modules which 18 | imported those -- such as `.packages`. 19 | 20 | Includes adding some basic tests for this functionality as well. Thanks to 21 | ``@ChaoticMind`` for the report. 22 | - :bug:`20` (via :issue:`21`) `patchwork.transfers.rsync` didn't handle its 23 | ``exclude`` parameter correctly when building the final ``rsync`` command (it 24 | came out looking like a stringified tuple). Fixed by Kegan Gan. 25 | - :bug:`-` Fix a bug in `patchwork.transfers.rsync` where it would fail with 26 | ``AttributeError`` unless your connection had ``connect_kwargs.key_filename`` 27 | defined. 28 | - :bug:`17` Missed some ``.succeeded`` attributes when porting to Fabric 2 - 29 | they should have been ``.ok``. Patch via Lucio Delelis. 30 | - :release:`1.0.0 <2018-05-08>` 31 | - :feature:`-` Pre-history. 32 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from os.path import join, abspath, dirname 3 | from os import getcwd, environ 4 | import sys 5 | 6 | 7 | # Core settings 8 | extensions = ["releases", "sphinx.ext.intersphinx", "sphinx.ext.autodoc"] 9 | templates_path = ["_templates"] 10 | source_suffix = ".rst" 11 | master_doc = "index" 12 | exclude_patterns = ["_build"] 13 | default_role = "obj" 14 | 15 | project = u"Patchwork" 16 | year = datetime.now().year 17 | copyright = u"%d Jeff Forcier" % year 18 | 19 | # Ensure project directory is on PYTHONPATH for version, autodoc access 20 | sys.path.insert(0, abspath(join(getcwd(), ".."))) 21 | 22 | # Enforce use of Alabaster (even on RTD) and configure it 23 | html_theme = "alabaster" 24 | html_theme_options = { 25 | "description": "Deployment/sysadmin operations, powered by Fabric", 26 | "github_user": "fabric", 27 | "github_repo": "patchwork", 28 | # TODO: make new UA property? only good for full domains and not RTD.io? 29 | # 'analytics_id': 'UA-18486793-X', 30 | # TODO: re-enable travis when we grow tests 31 | # 'travis_button': True, 32 | # 'codecov_button': True, # TODO: get better coverage sometime, heh 33 | "tidelift_url": "https://tidelift.com/subscription/pkg/pypi-patchwork?utm_source=pypi-patchwork&utm_medium=referral&utm_campaign=docs", # noqa 34 | } 35 | html_sidebars = { 36 | "**": ["about.html", "navigation.html", "searchbox.html", "donate.html"] 37 | } 38 | 39 | # Other extension configs 40 | autodoc_default_flags = ["members", "special-members"] 41 | releases_github_path = "fabric/patchwork" 42 | 43 | 44 | # Intersphinx 45 | # TODO: this could probably get wrapped up into invocations or some other 46 | # shared lib eh? 47 | on_rtd = environ.get("READTHEDOCS") == "True" 48 | on_travis = environ.get("TRAVIS", False) 49 | on_dev = not (on_rtd or on_travis) 50 | 51 | # Invoke 52 | inv_target = join( 53 | dirname(__file__), "..", "..", "invoke", "sites", "docs", "_build" 54 | ) 55 | if not on_dev: 56 | inv_target = "http://docs.pyinvoke.org/en/latest/" 57 | # Fabric 58 | fab_target = join( 59 | dirname(__file__), "..", "..", "fabric", "sites", "docs", "_build" 60 | ) 61 | if not on_dev: 62 | fab_target = "http://docs.fabfile.org/en/latest/" 63 | # Paramiko 64 | para_target = join( 65 | dirname(__file__), "..", "..", "paramiko", "sites", "docs", "_build" 66 | ) 67 | if not on_dev: 68 | para_target = "http://docs.paramiko.org/en/latest/" 69 | # Put them all together, + Python core 70 | intersphinx_mapping = { 71 | "python": ("http://docs.python.org/", None), 72 | "invoke": (inv_target, None), 73 | "fabric": (fab_target, None), 74 | "paramiko": (para_target, None), 75 | } 76 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | ========= 2 | Patchwork 3 | ========= 4 | 5 | .. include:: ../README.rst 6 | 7 | 8 | Roadmap 9 | ======= 10 | 11 | While Patchwork has been released with a major version number to signal 12 | adherence to semantic versioning, it's still early in development and has not 13 | fully achieved its design vision yet. 14 | 15 | We expect it to gain maturity in tandem with the adoption and development of 16 | Fabric 2. It's also highly likely that Patchwork will see a few major releases 17 | as its API (and those of its sister library, `invocations 18 | `_) matures. 19 | 20 | 21 | Contents 22 | ======== 23 | 24 | .. toctree:: 25 | :glob: 26 | :maxdepth: 1 27 | 28 | * 29 | 30 | 31 | API documentation 32 | ================= 33 | 34 | .. toctree:: 35 | :glob: 36 | 37 | api/* 38 | -------------------------------------------------------------------------------- /patchwork/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fabric/patchwork/6768f7880c23d041a6cd3a36d0f2660b50b183a4/patchwork/__init__.py -------------------------------------------------------------------------------- /patchwork/_version.py: -------------------------------------------------------------------------------- 1 | __version_info__ = (1, 0, 1) 2 | __version__ = ".".join(map(str, __version_info__)) 3 | -------------------------------------------------------------------------------- /patchwork/environment.py: -------------------------------------------------------------------------------- 1 | """ 2 | Shell environment introspection, e.g. binaries in effective $PATH, etc. 3 | """ 4 | 5 | 6 | def have_program(c, name): 7 | """ 8 | Returns whether connected user has program ``name`` in their ``$PATH``. 9 | """ 10 | return c.run("which {}".format(name), hide=True, warn=True) 11 | -------------------------------------------------------------------------------- /patchwork/files.py: -------------------------------------------------------------------------------- 1 | """ 2 | Tools for file and directory management. 3 | """ 4 | 5 | import re 6 | 7 | from invoke.vendor import six 8 | 9 | from .util import set_runner 10 | 11 | 12 | @set_runner 13 | def directory(c, runner, path, user=None, group=None, mode=None): 14 | """ 15 | Ensure a directory exists and has given user and/or mode 16 | 17 | :param c: 18 | `~invoke.context.Context` within to execute commands. 19 | :param str path: 20 | File path to directory. 21 | :param str user: 22 | Username which should own the directory. 23 | :param str group: 24 | Group which should own the directory; defaults to ``user``. 25 | :param str mode: 26 | ``chmod`` compatible mode string to apply to the directory. 27 | """ 28 | runner("mkdir -p {}".format(path)) 29 | if user is not None: 30 | group = group or user 31 | runner("chown {}:{} {}".format(user, group, path)) 32 | if mode is not None: 33 | runner("chmod {} {}".format(mode, path)) 34 | 35 | 36 | @set_runner 37 | def exists(c, runner, path): 38 | """ 39 | Return True if given path exists on the current remote host. 40 | 41 | :param c: 42 | `~invoke.context.Context` within to execute commands. 43 | :param str path: 44 | Path to check for existence. 45 | """ 46 | cmd = 'test -e "$(echo {})"'.format(path) 47 | return runner(cmd, hide=True, warn=True).ok 48 | 49 | 50 | @set_runner 51 | def contains(c, runner, filename, text, exact=False, escape=True): 52 | """ 53 | Return True if ``filename`` contains ``text`` (which may be a regex.) 54 | 55 | By default, this function will consider a partial line match (i.e. where 56 | ``text`` only makes up part of the line it's on). Specify ``exact=True`` to 57 | change this behavior so that only a line containing exactly ``text`` 58 | results in a True return value. 59 | 60 | This function leverages ``egrep`` on the remote end, so it may not follow 61 | Python regular expression syntax perfectly. 62 | 63 | If ``escape`` is False, no extra regular expression related escaping is 64 | performed (this includes overriding ``exact`` so that no ``^``/``$`` is 65 | added.) 66 | 67 | :param c: 68 | `~invoke.context.Context` within to execute commands. 69 | :param str filename: 70 | File path within which to check for ``text``. 71 | :param str text: 72 | Text to search for. 73 | :param bool exact: 74 | Whether to expect an exact match. 75 | :param bool escape: 76 | Whether to perform regex-oriented escaping on ``text``. 77 | """ 78 | if escape: 79 | text = _escape_for_regex(text) 80 | if exact: 81 | text = "^{}$".format(text) 82 | egrep_cmd = 'egrep "{}" "{}"'.format(text, filename) 83 | return runner(egrep_cmd, hide=True, warn=True).ok 84 | 85 | 86 | @set_runner 87 | def append(c, runner, filename, text, partial=False, escape=True): 88 | """ 89 | Append string (or list of strings) ``text`` to ``filename``. 90 | 91 | When a list is given, each string inside is handled independently (but in 92 | the order given.) 93 | 94 | If ``text`` is already found in ``filename``, the append is not run, and 95 | None is returned immediately. Otherwise, the given text is appended to the 96 | end of the given ``filename`` via e.g. ``echo '$text' >> $filename``. 97 | 98 | The test for whether ``text`` already exists defaults to a full line match, 99 | e.g. ``^$``, as this seems to be the most sensible approach for the 100 | "append lines to a file" use case. You may override this and force partial 101 | searching (e.g. ``^``) by specifying ``partial=True``. 102 | 103 | Because ``text`` is single-quoted, single quotes will be transparently 104 | backslash-escaped. This can be disabled with ``escape=False``. 105 | 106 | :param c: 107 | `~invoke.context.Context` within to execute commands. 108 | :param str filename: 109 | File path to append onto. 110 | :param str text: 111 | Text to append. 112 | :param bool partial: 113 | Whether to test partial line matches when determining if appending is 114 | necessary. 115 | :param bool escape: 116 | Whether to perform regex-oriented escaping on ``text``. 117 | """ 118 | # Normalize non-list input to be a list 119 | if isinstance(text, six.string_types): 120 | text = [text] 121 | for line in text: 122 | regex = "^" + _escape_for_regex(line) + ("" if partial else "$") 123 | if ( 124 | line 125 | and exists(c, filename, runner=runner) 126 | and contains(c, filename, regex, escape=False, runner=runner) 127 | ): 128 | continue 129 | line = line.replace("'", r"'\\''") if escape else line 130 | runner("echo '{}' >> {}".format(line, filename)) 131 | 132 | 133 | def _escape_for_regex(text): 134 | """Escape ``text`` to allow literal matching using egrep""" 135 | regex = re.escape(text) 136 | # Seems like double escaping is needed for \ 137 | regex = regex.replace("\\\\", "\\\\\\") 138 | # Triple-escaping seems to be required for $ signs 139 | regex = regex.replace(r"\$", r"\\\$") 140 | # Whereas single quotes should not be escaped 141 | regex = regex.replace(r"\'", "'") 142 | return regex 143 | -------------------------------------------------------------------------------- /patchwork/info.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions for interrogating a system to determine general attributes. 3 | 4 | Specifically, anything that doesn't fit better in other modules where we also 5 | manipulate this information (such as `.packages`). For example, detecting 6 | operating system family and version. 7 | """ 8 | 9 | from .files import exists 10 | 11 | 12 | def distro_name(c): 13 | """ 14 | Return simple Linux distribution name identifier, e.g. ``"ubuntu"``. 15 | 16 | Uses tools like ``/etc/issue``, and ``lsb_release`` and fits the remote 17 | system into one of the following: 18 | 19 | * ``fedora`` 20 | * ``rhel`` 21 | * ``centos`` 22 | * ``ubuntu`` 23 | * ``debian`` 24 | * ``other`` 25 | """ 26 | sentinel_files = { 27 | "fedora": ("fedora-release",), 28 | "centos": ("centos-release",), 29 | } 30 | for name, sentinels in sentinel_files.items(): 31 | for sentinel in sentinels: 32 | if exists(c, "/etc/{}".format(sentinel)): 33 | return name 34 | return "other" 35 | 36 | 37 | def distro_family(c): 38 | """ 39 | Returns basic "family" ID for the remote system's distribution. 40 | 41 | Currently, options include: 42 | 43 | * ``debian`` 44 | * ``redhat`` 45 | 46 | If the system falls outside these categories, its specific family or 47 | release name will be returned instead. 48 | """ 49 | families = { 50 | "debian": "debian ubuntu".split(), 51 | "redhat": "rhel centos fedora".split(), 52 | } 53 | distro = distro_name(c) 54 | for family, members in families.items(): 55 | if distro in members: 56 | return family 57 | return distro 58 | -------------------------------------------------------------------------------- /patchwork/packages/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | Management of various (usually binary) package types - OS, language, etc. 3 | """ 4 | 5 | # TODO: intent is to have various submodules for the various package managers - 6 | # apt/deb, rpm/yum/dnf, arch/pacman, etc etc etc. 7 | 8 | 9 | from patchwork.info import distro_family 10 | 11 | 12 | def package(c, *packages): 13 | """ 14 | Installs one or more ``packages`` using the system package manager. 15 | 16 | Specifically, this function calls a package manager like ``apt-get`` or 17 | ``yum`` once per package given. 18 | """ 19 | # Try to suppress interactive prompts, assume 'yes' to all questions 20 | apt = "DEBIAN_FRONTEND=noninteractive apt-get install -y {}" 21 | # Run from cache vs updating package lists every time; assume 'yes'. 22 | yum = "yum install -y %s" 23 | manager = apt if distro_family(c) == "debian" else yum 24 | for package in packages: 25 | c.sudo(manager.format(package)) 26 | 27 | 28 | def rubygem(c, gem): 29 | """ 30 | Install a Ruby gem. 31 | """ 32 | return c.sudo("gem install -b --no-rdoc --no-ri {}".format(gem)) 33 | -------------------------------------------------------------------------------- /patchwork/transfers.py: -------------------------------------------------------------------------------- 1 | """ 2 | File transfer functionality above and beyond basic ``put``/``get``. 3 | """ 4 | 5 | from invoke.vendor import six 6 | 7 | 8 | def rsync( 9 | c, 10 | source, 11 | target, 12 | exclude=(), 13 | delete=False, 14 | strict_host_keys=True, 15 | rsync_opts="", 16 | ssh_opts="", 17 | ): 18 | """ 19 | Convenient wrapper around your friendly local ``rsync``. 20 | 21 | Specifically, it calls your local ``rsync`` program via a subprocess, and 22 | fills in its arguments with Fabric's current target host/user/port. It 23 | provides Python level keyword arguments for some common rsync options, and 24 | allows you to specify custom options via a string if required (see below.) 25 | 26 | For details on how ``rsync`` works, please see its manpage. ``rsync`` must 27 | be installed on both the invoking system and the target in order for this 28 | function to work correctly. 29 | 30 | .. note:: 31 | This function transparently honors the given 32 | `~fabric.connection.Connection`'s connection parameters such as port 33 | number and SSH key path. 34 | 35 | .. note:: 36 | For reference, the approximate ``rsync`` command-line call that is 37 | constructed by this function is the following:: 38 | 39 | rsync [--delete] [--exclude exclude[0][, --exclude[1][, ...]]] \\ 40 | -pthrvz [rsync_opts] : 41 | 42 | :param c: 43 | `~fabric.connection.Connection` object upon which to operate. 44 | :param str source: 45 | The local path to copy from. Actually a string passed verbatim to 46 | ``rsync``, and thus may be a single directory (``"my_directory"``) or 47 | multiple directories (``"dir1 dir2"``). See the ``rsync`` documentation 48 | for details. 49 | :param str target: 50 | The path to sync with on the remote end. Due to how ``rsync`` is 51 | implemented, the exact behavior depends on the value of ``source``: 52 | 53 | - If ``source`` ends with a trailing slash, the files will be dropped 54 | inside of ``target``. E.g. ``rsync(c, "foldername/", 55 | "/home/username/project")`` will drop the contents of ``foldername`` 56 | inside of ``/home/username/project``. 57 | - If ``source`` does **not** end with a trailing slash, ``target`` is 58 | effectively the "parent" directory, and a new directory named after 59 | ``source`` will be created inside of it. So ``rsync(c, "foldername", 60 | "/home/username")`` would create a new directory 61 | ``/home/username/foldername`` (if needed) and place the files there. 62 | 63 | :param exclude: 64 | Optional, may be a single string or an iterable of strings, and is 65 | used to pass one or more ``--exclude`` options to ``rsync``. 66 | :param bool delete: 67 | A boolean controlling whether ``rsync``'s ``--delete`` option is used. 68 | If True, instructs ``rsync`` to remove remote files that no longer 69 | exist locally. Defaults to False. 70 | :param bool strict_host_keys: 71 | Boolean determining whether to enable/disable the SSH-level option 72 | ``StrictHostKeyChecking`` (useful for frequently-changing hosts such as 73 | virtual machines or cloud instances.) Defaults to True. 74 | :param str rsync_opts: 75 | An optional, arbitrary string which you may use to pass custom 76 | arguments or options to ``rsync``. 77 | :param str ssh_opts: 78 | Like ``rsync_opts`` but specifically for the SSH options string 79 | (rsync's ``--rsh`` flag.) 80 | """ 81 | # Turn single-string exclude into a one-item list for consistency 82 | if isinstance(exclude, six.string_types): 83 | exclude = [exclude] 84 | # Create --exclude options from exclude list 85 | exclude_opts = ' --exclude "{}"' * len(exclude) 86 | # Double-backslash-escape 87 | exclusions = tuple([str(s).replace('"', '\\\\"') for s in exclude]) 88 | # Honor SSH key(s) 89 | key_string = "" 90 | # TODO: seems plausible we need to look in multiple places if there's too 91 | # much deferred evaluation going on in how we eg source SSH config files 92 | # and so forth, re: connect_kwargs 93 | # TODO: we could get VERY fancy here by eg generating a tempfile from any 94 | # in-memory-only keys...but that's also arguably a security risk, so... 95 | keys = c.connect_kwargs.get("key_filename", []) 96 | # TODO: would definitely be nice for Connection/FabricConfig to expose an 97 | # always-a-list, always-up-to-date-from-all-sources attribute to save us 98 | # from having to do this sort of thing. (may want to wait for Paramiko auth 99 | # overhaul tho!) 100 | if isinstance(keys, six.string_types): 101 | keys = [keys] 102 | if keys: 103 | key_string = "-i " + " -i ".join(keys) 104 | # Get base cxn params 105 | user, host, port = c.user, c.host, c.port 106 | port_string = "-p {}".format(port) 107 | # Remote shell (SSH) options 108 | rsh_string = "" 109 | # Strict host key checking 110 | disable_keys = "-o StrictHostKeyChecking=no" 111 | if not strict_host_keys and disable_keys not in ssh_opts: 112 | ssh_opts += " {}".format(disable_keys) 113 | rsh_parts = [key_string, port_string, ssh_opts] 114 | if any(rsh_parts): 115 | rsh_string = "--rsh='ssh {}'".format(" ".join(rsh_parts)) 116 | # Set up options part of string 117 | options_map = { 118 | "delete": "--delete" if delete else "", 119 | "exclude": exclude_opts.format(*exclusions), 120 | "rsh": rsh_string, 121 | "extra": rsync_opts, 122 | } 123 | options = "{delete}{exclude} -pthrvz {extra} {rsh}".format(**options_map) 124 | # Create and run final command string 125 | # TODO: richer host object exposing stuff like .address_is_ipv6 or whatever 126 | if host.count(":") > 1: 127 | # Square brackets are mandatory for IPv6 rsync address, 128 | # even if port number is not specified 129 | cmd = "rsync {} {} [{}@{}]:{}" 130 | else: 131 | cmd = "rsync {} {} {}@{}:{}" 132 | cmd = cmd.format(options, source, user, host, target) 133 | return c.local(cmd) 134 | -------------------------------------------------------------------------------- /patchwork/util.py: -------------------------------------------------------------------------------- 1 | """ 2 | Helpers and decorators, primarily for internal or advanced use. 3 | """ 4 | 5 | import textwrap 6 | 7 | from functools import wraps 8 | from inspect import getargspec, formatargspec 9 | 10 | 11 | # TODO: calling all functions as eg directory(c, '/foo/bar/') (with initial c) 12 | # will probably get old; but what's better? 13 | # - Phrasing them as methods on mixin classes to pile on top of Context (so: 14 | # c.directory('/foo/bar/')) pushes us farther towards a god object antipattern, 15 | # though it seems clearly the most practical, especially if we reimplement 16 | # implicit global context; 17 | # - Have each patchwork module as a property of Context itself might help a 18 | # _little_ bit (so it becomes: c.files.directory('/foo/bar/')) but only a 19 | # little, and it almost too-strongly ties us to the module organization (it's 20 | # much easier to change a 'from x import y' than to change a bunch of 21 | # references to a variable) 22 | # - Standalone objects that encapsulate a Context would help to some degree, 23 | # but only in terms of scaling up to many per-module calls (i.e. filemanager = 24 | # files.Manager(c); filemanager.directory('/foo/bar'); if 25 | # filemanager.exists('blah'): xyz; etc) and seems to scale poorly across 26 | # modules (if you want to use eg 1-2 funcs from each module you've got a 27 | # bunch of objects to keep track of) 28 | # - What other patterns are good? 29 | 30 | # TODO: how much of the stuff in here should be in Invoke or Fabric core? For 31 | # now, it's ok to leave here because it's highly experimental, but expect a 2.0 32 | # or 3.0 release to see it move elsewhere once patterns are established. 33 | 34 | 35 | def set_runner(f): 36 | """ 37 | Set 2nd posarg of decorated function to some callable ``runner``. 38 | 39 | The final value of ``runner`` depends on other args given to the decorated 40 | function (**note:** *not* the decorator itself!) as follows: 41 | 42 | - By default, ``runner`` is set to the ``run`` method of the first 43 | positional arg, which is expected to be a `~invoke.context.Context` (or 44 | subclass). Thus the default runner is `Context.run 45 | ` (or, if the function was given a Fabric 46 | `~fabric.connection.Connection`, `Connection.run 47 | `). 48 | - You can override which method on the context is selected, by handing an 49 | attribute name string to ``runner_method``. 50 | - Since the common case for overriding the runner is to trigger use of 51 | `~invoke.context.Context.sudo`, there is a convenient shorthand: giving 52 | ``sudo=True``. 53 | - Finally, you may give a callable object to ``runner`` directly, in which 54 | case nothing special really happens (it's largely as if you called the 55 | function undecorated, albeit with a kwarg instead of a positional 56 | argument). This is mostly useful for cases where you're calling one 57 | decorated function from within another. 58 | 59 | Given this ``function``:: 60 | 61 | @set_runner 62 | def function(c, runner, arg1, arg2=None): 63 | runner("some command based on arg1 and arg2") 64 | 65 | one may call it without any runner-related arguments, in which case 66 | ``runner`` ends up being a reference to ``c.run``:: 67 | 68 | function(c, "my-arg1", arg2="my-arg2") 69 | 70 | or one may specify ``sudo`` to trigger use of ``c.sudo``:: 71 | 72 | function(c, "my-arg1", arg2="my-arg2", sudo=True) 73 | 74 | If one is using a custom Context subclass with other command runner 75 | methods, one may give ``runner_method`` explicitly:: 76 | 77 | class AdminContext(Context): 78 | def run_admin(self, *args, **kwargs): 79 | kwargs["user"] = "admin" 80 | return self.sudo(*args, **kwargs) 81 | 82 | function(AdminContext(), "my-arg1", runner_method="run_admin") 83 | 84 | As noted above, you can always give ``runner`` (as a kwarg) directly to 85 | avoid most special processing:: 86 | 87 | function(c, "my-arg1", runner=some_existing_runner_object) 88 | 89 | .. note:: 90 | If more than one of the ``runner_method``, ``sudo`` or ``runner`` 91 | kwargs are given simultaneously, only one will win, in the following 92 | order: ``runner``, then ``runner_method``, then ``sudo``. 93 | 94 | .. note:: 95 | As part of the signature modification, `set_runner` also modifies the 96 | resulting value's docstring as follows: 97 | 98 | - Prepends a Sphinx autodoc compatible signature string, which is 99 | stripped out automatically on doc builds; see the Sphinx 100 | ``autodoc_docstring_signature`` setting. 101 | - Adds trailing ``:param:`` annotations for the extra args as well. 102 | """ 103 | 104 | @wraps(f) 105 | def inner(*args, **kwargs): 106 | args = list(args) 107 | # Pop all useful kwargs (either to prevent clash with real ones, or to 108 | # remove ones not intended for wrapped function) 109 | runner = kwargs.pop("runner", None) 110 | sudo = kwargs.pop("sudo", False) 111 | runner_method = kwargs.pop("runner_method", None) 112 | # Figure out what gets applied and potentially overwrite runner 113 | if not runner: 114 | method = runner_method 115 | if not method: 116 | method = "sudo" if sudo else "run" 117 | runner = getattr(args[0], method) 118 | args.insert(1, runner) 119 | return f(*args, **kwargs) 120 | 121 | inner.__doc__ = munge_docstring(f, inner) 122 | return inner 123 | 124 | 125 | def munge_docstring(f, inner): 126 | # Terrible, awful hacks to ensure Sphinx autodoc sees the intended 127 | # (modified) signature; leverages the fact that autodoc_docstring_signature 128 | # is True by default. 129 | args, varargs, keywords, defaults = getargspec(f) 130 | # Nix positional version of runner arg, which is always 2nd 131 | del args[1] 132 | # Add new args to end in desired order 133 | args.extend(["sudo", "runner_method", "runner"]) 134 | # Add default values (remembering that this tuple matches the _end_ of the 135 | # signature...) 136 | defaults = tuple(list(defaults or []) + [False, "run", None]) 137 | # Get signature first line for Sphinx autodoc_docstring_signature 138 | sigtext = "{}{}".format( 139 | f.__name__, formatargspec(args, varargs, keywords, defaults) 140 | ) 141 | docstring = textwrap.dedent(inner.__doc__ or "").strip() 142 | # Construct :param: list 143 | params = """:param bool sudo: 144 | Whether to run shell commands via ``sudo``. 145 | :param str runner_method: 146 | Name of context method to use when running shell commands. 147 | :param runner: 148 | Callable runner function or method. Should ideally be a bound method on the given context object! 149 | """ # noqa 150 | return "{}\n{}\n\n{}".format(sigtext, docstring, params) 151 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [metadata] 5 | license_file = LICENSE 6 | 7 | [flake8] 8 | exclude = .git,build,dist 9 | ignore = E124,E125,E128,E261,E301,E302,E303,W503 10 | max-line-length = 79 11 | 12 | [tool:pytest] 13 | testpaths = tests 14 | python_files = * 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | # Support setuptools only, distutils has a divergent and more annoying API and 4 | # few folks will lack setuptools. 5 | from setuptools import setup, find_packages 6 | 7 | # Version info -- read without importing 8 | _locals = {} 9 | with open("patchwork/_version.py") as fp: 10 | exec(fp.read(), None, _locals) 11 | version = _locals["__version__"] 12 | 13 | setup( 14 | name="patchwork", 15 | version=version, 16 | description="Deployment/sysadmin operations, powered by Fabric", 17 | license="BSD", 18 | long_description=open("README.rst").read(), 19 | author="Jeff Forcier", 20 | author_email="jeff@bitprophet.org", 21 | url="https://fabric-patchwork.readthedocs.io", 22 | install_requires=["fabric>=2.0,<3.0"], 23 | packages=find_packages(), 24 | classifiers=[ 25 | "Development Status :: 5 - Production/Stable", 26 | "Environment :: Console", 27 | "Intended Audience :: Developers", 28 | "Intended Audience :: System Administrators", 29 | "License :: OSI Approved :: BSD License", 30 | "Operating System :: POSIX", 31 | "Operating System :: Unix", 32 | "Operating System :: MacOS :: MacOS X", 33 | "Operating System :: Microsoft :: Windows", 34 | "Programming Language :: Python", 35 | "Programming Language :: Python :: 2", 36 | "Programming Language :: Python :: 2.7", 37 | "Programming Language :: Python :: 3", 38 | "Programming Language :: Python :: 3.4", 39 | "Programming Language :: Python :: 3.5", 40 | "Programming Language :: Python :: 3.6", 41 | "Topic :: Software Development", 42 | "Topic :: Software Development :: Build Tools", 43 | "Topic :: Software Development :: Libraries", 44 | "Topic :: Software Development :: Libraries :: Python Modules", 45 | "Topic :: System :: Software Distribution", 46 | "Topic :: System :: Systems Administration", 47 | ], 48 | ) 49 | -------------------------------------------------------------------------------- /tasks.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from invocations import docs, travis 4 | from invocations.checks import blacken 5 | from invocations.packaging import release 6 | from invocations.pytest import test, coverage 7 | 8 | from invoke import Collection, task 9 | 10 | 11 | @task 12 | def sanity(c): 13 | """ 14 | Quick sanity check to ensure we're installed successfully. Mostly for CI. 15 | """ 16 | # Doesn't need to literally import everything, but "a handful" will do. 17 | for name in ("environment", "files", "transfers"): 18 | mod = "patchwork.{}".format(name) 19 | import_module(mod) 20 | print("Imported {} successfully".format(mod)) 21 | 22 | 23 | ns = Collection(docs, release, travis, test, coverage, sanity, blacken) 24 | ns.configure( 25 | { 26 | "packaging": { 27 | "sign": True, 28 | "wheel": True, 29 | "check_desc": True, 30 | "changelog_file": "docs/changelog.rst", 31 | } 32 | } 33 | ) 34 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # flake8: noqa 2 | from fabric.testing.fixtures import cxn 3 | -------------------------------------------------------------------------------- /tests/files.py: -------------------------------------------------------------------------------- 1 | from mock import call 2 | 3 | from patchwork.files import directory 4 | 5 | 6 | class files: 7 | 8 | class directory_: 9 | 10 | def base_case_creates_dir_with_dash_p(self, cxn): 11 | directory(cxn, "/some/dir") 12 | cxn.run.assert_called_once_with("mkdir -p /some/dir") 13 | 14 | def user_sets_owner_and_group(self, cxn): 15 | directory(cxn, "/some/dir", user="user") 16 | cxn.run.assert_any_call("chown user:user /some/dir") 17 | 18 | def group_may_be_given_to_change_group(self, cxn): 19 | directory(cxn, "/some/dir", user="user", group="admins") 20 | cxn.run.assert_any_call("chown user:admins /some/dir") 21 | 22 | def mode_adds_a_chmod(self, cxn): 23 | directory(cxn, "/some/dir", mode="0700") 24 | cxn.run.assert_any_call("chmod 0700 /some/dir") 25 | 26 | def all_args_in_play(self, cxn): 27 | directory( 28 | cxn, "/some/dir", user="user", group="admins", mode="0700" 29 | ) 30 | assert cxn.run.call_args_list == [ 31 | call("mkdir -p /some/dir"), 32 | call("chown user:admins /some/dir"), 33 | call("chmod 0700 /some/dir"), 34 | ] 35 | -------------------------------------------------------------------------------- /tests/info.py: -------------------------------------------------------------------------------- 1 | from fabric import Result 2 | from mock import patch 3 | 4 | from patchwork.info import distro_name, distro_family 5 | 6 | 7 | class info: 8 | 9 | class distro_name: 10 | 11 | def returns_other_by_default(self, cxn): 12 | # Sentinels don't exist -> yields default 13 | # TODO: refactor with module contents in feature branch, eg tease 14 | # out sentinel file definitions to module level data 15 | # TODO: make a Session-like thing (or just allow reuse of Session?) 16 | # that works at this level instead of what it currently does in 17 | # MockRemote, mocking lower level stuff that is doubtlessly slower 18 | # and not really necessary? 19 | cxn.run.return_value = Result(connection=cxn, exited=1) 20 | assert distro_name(cxn) == "other" 21 | 22 | def returns_fedora_if_fedora_release_exists(self, cxn): 23 | 24 | def fedora_exists(*args, **kwargs): 25 | # TODO: this is crazy fragile, indicates we want to refactor 26 | # the guts of exists() or just mock exists() itself instead 27 | if args[0] == 'test -e "$(echo /etc/fedora-release)"': 28 | return Result(connection=cxn, exited=0) 29 | return Result(connection=cxn, exited=1) 30 | 31 | cxn.run.side_effect = fedora_exists 32 | assert distro_name(cxn) == "fedora" 33 | 34 | def returns_centos_if_centos_release_exists(self, cxn): 35 | 36 | def centos_exists(*args, **kwargs): 37 | # TODO: this is crazy fragile, indicates we want to refactor 38 | # the guts of exists() or just mock exists() itself instead 39 | if args[0] == 'test -e "$(echo /etc/centos-release)"': 40 | return Result(connection=cxn, exited=0) 41 | return Result(connection=cxn, exited=1) 42 | 43 | cxn.run.side_effect = centos_exists 44 | assert distro_name(cxn) == "centos" 45 | 46 | # TODO: might as well document & test for the part where we test the 47 | # sentinels in order, so a system with (somehow) >1 sentinel will 48 | # appear to be the first one in our list/structure. 49 | 50 | class distro_family: 51 | 52 | @patch("patchwork.info.distro_name") 53 | def returns_distro_name_when_not_part_of_known_family( 54 | self, mock_dn, cxn 55 | ): 56 | # TODO: do we actually want this behavior? Seems like it'd set one 57 | # up for backwards compat issues anytime you want to add new family 58 | # definitions... 59 | mock_dn.return_value = "no-clue" 60 | assert distro_family(cxn) == "no-clue" 61 | 62 | @patch("patchwork.info.distro_name") 63 | def returns_redhat_for_rhel_centos_or_fedora(self, mock_dn, cxn): 64 | for fake in ("rhel", "centos", "fedora"): 65 | mock_dn.return_value = fake 66 | assert distro_family(cxn) == "redhat" 67 | 68 | @patch("patchwork.info.distro_name") 69 | def returns_debian_for_debian_or_ubuntu(self, mock_dn, cxn): 70 | for fake in ("debian", "ubuntu"): 71 | mock_dn.return_value = fake 72 | assert distro_family(cxn) == "debian" 73 | -------------------------------------------------------------------------------- /tests/transfers.py: -------------------------------------------------------------------------------- 1 | from patchwork.transfers import rsync 2 | 3 | 4 | local = "localpath" 5 | remote = "remotepath" 6 | flags = "-pthrvz" 7 | base = "rsync {}".format(flags) 8 | end = "{} user@host:{}".format(local, remote) 9 | 10 | 11 | class transfers: 12 | 13 | class rsync_: 14 | 15 | def _expect(self, cxn, expected, kwargs=None): 16 | if kwargs is None: 17 | kwargs = {} 18 | rsync(cxn, local, remote, **kwargs) 19 | command = cxn.local.call_args[0][0] 20 | assert expected == command 21 | 22 | def base_case(self, cxn): 23 | expected = "{} --rsh='ssh -p 22 ' {}".format(base, end) 24 | self._expect(cxn, expected) 25 | 26 | def single_key_filename_honored(self, cxn): 27 | cxn.connect_kwargs.key_filename = "secret.key" 28 | expected = "{} --rsh='ssh -i secret.key -p 22 ' {}".format( 29 | base, end 30 | ) 31 | self._expect(cxn, expected) 32 | 33 | def multiple_key_filenames_honored(self, cxn): 34 | cxn.connect_kwargs.key_filename = ["secret1.key", "secret2.key"] 35 | keys = "-i secret1.key -i secret2.key" 36 | expected = "{} --rsh='ssh {} -p 22 ' {}".format(base, keys, end) 37 | self._expect(cxn, expected) 38 | 39 | def single_exclusion_honored(self, cxn): 40 | exclusions = '--exclude "foo"' 41 | expected = "rsync {} {} --rsh='ssh -p 22 ' {}".format( 42 | exclusions, flags, end 43 | ) 44 | self._expect(cxn, expected, kwargs=dict(exclude="foo")) 45 | 46 | def multiple_exclusions_honored(self, cxn): 47 | exclusions = '--exclude "foo" --exclude "bar"' 48 | expected = "rsync {} {} --rsh='ssh -p 22 ' {}".format( 49 | exclusions, flags, end 50 | ) 51 | self._expect(cxn, expected, kwargs=dict(exclude=["foo", "bar"])) 52 | -------------------------------------------------------------------------------- /tests/util.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from invoke import Context 4 | 5 | from patchwork.util import set_runner 6 | 7 | 8 | class util: 9 | 10 | class set_runner_: 11 | # NOTE: using runner == c.run/whatever in these tests because 'is' is 12 | # always False for some reason despite both args being the same object, 13 | # same id() etc. Not sure wtf. Probably my fault somehow. 14 | def defaults_to_run(self): 15 | 16 | @set_runner 17 | def myfunc(c, runner): 18 | assert runner == c.run 19 | 20 | myfunc(Context()) 21 | 22 | def does_not_tweak_other_args(self): 23 | 24 | @set_runner 25 | def myfunc(c, runner, other, args="values", default="default"): 26 | assert isinstance(c, Context) 27 | assert runner == c.run 28 | assert other == "otherval" 29 | assert args == "override" 30 | assert default == "default" 31 | 32 | myfunc(Context(), "otherval", args="override") 33 | 34 | def allows_overriding_runner_method(self): 35 | 36 | @set_runner 37 | def myfunc(c, runner, other, args="values", default="default"): 38 | assert isinstance(c, Context) 39 | assert runner == c.sudo 40 | assert other == "otherval" 41 | assert args == "override" 42 | assert default == "default" 43 | 44 | myfunc( 45 | Context(), "otherval", args="override", runner_method="sudo" 46 | ) 47 | 48 | def sudo_is_bool_shorthand_for_sudo_runner(self): 49 | 50 | @set_runner 51 | def myfunc(c, runner, other, args="values", default="default"): 52 | assert isinstance(c, Context) 53 | assert runner == c.sudo 54 | assert other == "otherval" 55 | assert args == "override" 56 | assert default == "default" 57 | 58 | myfunc(Context(), "otherval", args="override", sudo=True) 59 | 60 | def may_give_runner_directly_for_no_magic(self): 61 | 62 | def whatever(): 63 | pass 64 | 65 | @set_runner 66 | def myfunc(c, runner, other, args="values", default="default"): 67 | assert isinstance(c, Context) 68 | assert runner is whatever 69 | assert other == "otherval" 70 | assert args == "override" 71 | assert default == "default" 72 | 73 | myfunc(Context(), "otherval", args="override", runner=whatever) 74 | 75 | def runner_wins_over_runner_method(self): 76 | 77 | def whatever(): 78 | pass 79 | 80 | @set_runner 81 | def myfunc(c, runner): 82 | assert runner is whatever 83 | 84 | myfunc(Context(), runner=whatever, runner_method="run") 85 | 86 | def runner_wins_over_sudo(self): 87 | 88 | def whatever(): 89 | pass 90 | 91 | @set_runner 92 | def myfunc(c, runner): 93 | assert runner is whatever 94 | 95 | myfunc(Context(), runner=whatever, sudo=True) 96 | 97 | def runner_method_wins_over_sudo(self): 98 | 99 | @set_runner 100 | def myfunc(c, runner): 101 | assert runner == c.run 102 | 103 | myfunc(Context(), runner_method="run", sudo=True) 104 | 105 | class modified_signature_prepended_to_docstring: 106 | 107 | # NOTE: This is the main canary test with full, exact expectations. 108 | # The rest use shortcuts for developer sanity. 109 | def multi_line(self): 110 | 111 | @set_runner 112 | def myfunc(c, runner, foo, bar="biz"): 113 | """ 114 | whatever 115 | 116 | seriously, whatever 117 | """ 118 | pass 119 | 120 | expected = """myfunc(c, foo, bar='biz', sudo=False, runner_method='run', runner=None) 121 | whatever 122 | 123 | seriously, whatever 124 | 125 | :param bool sudo: 126 | Whether to run shell commands via ``sudo``. 127 | :param str runner_method: 128 | Name of context method to use when running shell commands. 129 | :param runner: 130 | Callable runner function or method. Should ideally be a bound method on the given context object! 131 | """ # noqa 132 | assert myfunc.__doc__ == expected 133 | 134 | def none(self): 135 | 136 | @set_runner 137 | def myfunc(c, runner, foo, bar="biz"): 138 | pass 139 | 140 | assert re.match( 141 | r"myfunc\(.*, sudo=False.*\)\n.*:param str runner_method:.*\n", # noqa 142 | myfunc.__doc__, 143 | re.DOTALL, 144 | ) 145 | 146 | def empty(self): 147 | 148 | @set_runner 149 | def myfunc(c, runner, foo, bar="biz"): 150 | "" 151 | pass 152 | 153 | assert re.match( 154 | r"myfunc\(.*, sudo=False.*\)\n.*:param str runner_method:.*\n", # noqa 155 | myfunc.__doc__, 156 | re.DOTALL, 157 | ) 158 | 159 | def single_line_no_newlines(self): 160 | 161 | @set_runner 162 | def myfunc(c, runner, foo, bar="biz"): 163 | "whatever" 164 | pass 165 | 166 | assert re.match( 167 | r"myfunc\(.*, sudo=False.*\)\nwhatever\n\n.*:param str runner_method:.*\n", # noqa 168 | myfunc.__doc__, 169 | re.DOTALL, 170 | ) 171 | 172 | def single_line_with_newlines(self): 173 | 174 | @set_runner 175 | def myfunc(c, runner, foo, bar="biz"): 176 | """ 177 | whatever 178 | """ 179 | pass 180 | 181 | assert re.match( 182 | r"myfunc\(.*, sudo=False.*\)\nwhatever\n\n.*:param str runner_method:.*\n", # noqa 183 | myfunc.__doc__, 184 | re.DOTALL, 185 | ) 186 | --------------------------------------------------------------------------------