diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..0febad8 --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,56 @@ +name: test +run-name: Run tests + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + pre-commit: + name: Check pre-commit + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-python@v4 + - uses: pre-commit/action@v3.0.0 + + pytest-fabric2: + name: Test for Fabric 2.0 compatibility + runs-on: ubuntu-latest + needs: [ pre-commit ] + strategy: + fail-fast: false + matrix: + python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + fabric-version: [ "==2.2","<3.0" ] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install Fabric ${{ matrix.fabric-version }} and patchwork + run: pip install "fabric${{ matrix.fabric-version }}" .[test] + - name: Test with pytest + run: pytest + + pytest: + name: Run pytests + runs-on: ubuntu-latest + needs: [ pre-commit ] + strategy: + fail-fast: false + matrix: + python-version: [ "3.7", "3.8", "3.9", "3.10", "3.11" ] + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Setup patchwork + run: pip install -e .[test] + - name: Test with pytest + run: pytest diff --git a/.gitignore b/.gitignore index b7e1ca4..a51ee18 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,7 @@ docs/_build .cache .coverage +/venv +/build +/.idea +*.egg-info diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..68d1d2a --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,11 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - repo: https://github.com/python-jsonschema/check-jsonschema + rev: 0.21.0 + hooks: + - id: check-github-workflows diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 324732d..0000000 --- a/.travis.yml +++ /dev/null @@ -1,29 +0,0 @@ -sudo: false -language: python -python: - - "2.7" - - "3.4" - - "3.5" - - "3.6" - - "pypy" - - "pypy3" -matrix: - # pypy3 (as of 2.4.0) has a wacky arity issue in its source loader. Allow it - # to fail until we can test on, and require, PyPy3.3+. See invoke#358. - allow_failures: - - python: pypy3 - # Disabled per https://github.com/travis-ci/travis-ci/issues/1696 - # fast_finish: true -install: - - pip install -r dev-requirements.txt -script: - # Run tests w/ coverage first, so it uses the local-installed copy. - # (If we do this after the below installation tests, coverage will think - # nothing got covered!) - - inv coverage --report=xml - # TODO: tighten up these install test tasks so they can be one-shotted - - inv travis.test-installation --package=patchwork --sanity="inv sanity" - - inv travis.test-packaging --package=patchwork --sanity="inv sanity" - - inv docs --nitpick - - flake8 -# TODO: after_success -> codecov, once coverage sucks less XD diff --git a/dev-requirements.txt b/dev-requirements.txt deleted file mode 100644 index 767fe73..0000000 --- a/dev-requirements.txt +++ /dev/null @@ -1,15 +0,0 @@ -# Require a newer fabric than our public API does, since it includes the now -# public test helpers. Bleh. -fabric>=2.1.3,<3 -Sphinx>=1.4,<1.7 -releases>=1.6,<2.0 -alabaster==0.7.12 -wheel==0.24 -twine==1.11.0 -invocations>=1.3.0,<2.0 -pytest-relaxed==1.1.4 -coverage==4.4.2 -pytest-cov==2.4.0 -mock==1.0.1 -flake8==3.5.0 --e . diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..43c53e3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,63 @@ +[build-system] +requires = ["setuptools"] +build-backend = "setuptools.build_meta" + +[project] +name = "patchwork" +version = "2.0.0" +description = "Deployment/sysadmin operations, powered by Fabric" +authors = [{ name = "Jeff Forcier", email = "jeff@bitprophet.org" }] +maintainers = [{ name = "Jeff Forcier", email = "jeff@bitprophet.org" }] +requires-python = ">=3.7" +readme = "README.rst" +license = { file = "LICENSE" } +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Operating System :: POSIX", + "Operating System :: Unix", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python", + "Programming Language :: Python :: 3.7", + "Topic :: Software Development", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Software Distribution", + "Topic :: System :: Systems Administration", +] + +dependencies = [ + "fabric>=2.2", +] + +[project.optional-dependencies] +docs = [ + "releases", + "alabaster", + "sphinx", +] +test = [ + "invocations", + "pytest", + "mock", + "pytest-relaxed", +] +test-cov = [ + "pytest-cov", +] +dev = [ + "patchwork[test]", + "pre-commit", +] + +[project.urls] +homepage = "https://www.fabfile.org/" + +[tool.pytest.ini_options] +testpaths = ["tests"] +python_files = "*" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 56652a9..0000000 --- a/setup.cfg +++ /dev/null @@ -1,14 +0,0 @@ -[wheel] -universal = 1 - -[metadata] -license_file = LICENSE - -[flake8] -exclude = .git,build,dist -ignore = E124,E125,E128,E261,E301,E302,E303,W503 -max-line-length = 79 - -[tool:pytest] -testpaths = tests -python_files = * diff --git a/setup.py b/setup.py index 0e92cb5..bdde1c2 100644 --- a/setup.py +++ b/setup.py @@ -1,48 +1,109 @@ -#!/usr/bin/env python - -# Support setuptools only, distutils has a divergent and more annoying API and -# few folks will lack setuptools. -from setuptools import setup, find_packages - -# Version info -- read without importing -_locals = {} -with open("patchwork/_version.py") as fp: - exec(fp.read(), None, _locals) -version = _locals["__version__"] - -setup( - name="patchwork", - version=version, - description="Deployment/sysadmin operations, powered by Fabric", - license="BSD", - long_description=open("README.rst").read(), - author="Jeff Forcier", - author_email="jeff@bitprophet.org", - url="https://fabric-patchwork.readthedocs.io", - install_requires=["fabric>=2.0,<3.0"], - packages=find_packages(), - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Environment :: Console", - "Intended Audience :: Developers", - "Intended Audience :: System Administrators", - "License :: OSI Approved :: BSD License", - "Operating System :: POSIX", - "Operating System :: Unix", - "Operating System :: MacOS :: MacOS X", - "Operating System :: Microsoft :: Windows", - "Programming Language :: Python", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Topic :: Software Development", - "Topic :: Software Development :: Build Tools", - "Topic :: Software Development :: Libraries", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: System :: Software Distribution", - "Topic :: System :: Systems Administration", - ], -) +# -*- coding: utf-8 -*- + +""" +setup.py implementation, interesting because it parsed the first __init__.py and + extracts the `__author__` and `__version__` +""" + +import sys +from ast import parse +from os import path + +from setuptools import setup + +if sys.version_info[:2] > (3, 7): + from ast import Constant +else: + if sys.version_info[0] == 2: + from itertools import imap as map + + from ast import expr + + # Constant. Will never be used in Python =< 3.8 + Constant = type("Constant", (expr,), {}) + + +package_name = "patchwork" + +with open( + path.join(path.dirname(__file__), "README{extsep}rst".format(extsep=path.extsep)), + "rt", +) as fh: + long_description = fh.read() + + +def main(): + """Main function for setup.py; this actually does the installation""" + with open( + path.join( + path.abspath(path.dirname(__file__)), + "src", + "patchwork", + "_version{extsep}py".format(extsep=path.extsep), + ) + ) as f: + parsed_init = parse(f.read()) + + __version__ = ".".join( + map( + lambda node: str(node.value if isinstance(node, Constant) else node.n), + parsed_init.body[0].value.elts, + ) + ) + + setup( + name=package_name, + author="Jeff Forcier", + author_email="jeff@bitprophet.org", + version=__version__, + url="https://www.fabfile.org", + description=long_description[: long_description.find("\n")], + long_description=long_description, + long_description_content_type="text/x-rst", + classifiers=[ + "Development Status :: 5 - Production/Stable", + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: System Administrators", + "License :: OSI Approved :: BSD License", + "Operating System :: POSIX", + "Operating System :: POSIX :: Linux", + "Operating System :: Unix", + "Operating System :: MacOS :: MacOS X", + "Operating System :: Microsoft :: Windows", + "Programming Language :: Python", + "Programming Language :: Python :: 2", + "Programming Language :: Python :: 2.7", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Topic :: Software Development", + "Topic :: Software Development :: Build Tools", + "Topic :: Software Development :: Libraries", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: System :: Software Distribution", + "Topic :: System :: Systems Administration", + ], + license="BSD-2-Clause", + license_files=["LICENSE"], + install_requires=["fabric>=2.2"], + test_suite="tests", + packages=[package_name], + package_dir={package_name: path.join("src", package_name)}, + ) + + +def setup_py_main(): + """Calls main if `__name__ == '__main__'`""" + if __name__ == "__main__": + main() + + +setup_py_main() diff --git a/patchwork/__init__.py b/src/patchwork/__init__.py similarity index 100% rename from patchwork/__init__.py rename to src/patchwork/__init__.py diff --git a/patchwork/_version.py b/src/patchwork/_version.py similarity index 63% rename from patchwork/_version.py rename to src/patchwork/_version.py index ad38827..c359da6 100644 --- a/patchwork/_version.py +++ b/src/patchwork/_version.py @@ -1,2 +1,2 @@ -__version_info__ = (1, 0, 1) +__version_info__ = (2, 0, 0) __version__ = ".".join(map(str, __version_info__)) diff --git a/patchwork/environment.py b/src/patchwork/environment.py similarity index 73% rename from patchwork/environment.py rename to src/patchwork/environment.py index ce6763a..dfa96cd 100644 --- a/patchwork/environment.py +++ b/src/patchwork/environment.py @@ -7,4 +7,4 @@ def have_program(c, name): """ Returns whether connected user has program ``name`` in their ``$PATH``. """ - return c.run("which {}".format(name), hide=True, warn=True) + return c.run("which {name}".format(name=name), hide=True, warn=True) diff --git a/patchwork/files.py b/src/patchwork/files.py similarity index 81% rename from patchwork/files.py rename to src/patchwork/files.py index 1e68512..0cb9d11 100644 --- a/patchwork/files.py +++ b/src/patchwork/files.py @@ -4,8 +4,6 @@ import re -from invoke.vendor import six - from .util import set_runner @@ -16,6 +14,9 @@ def directory(c, runner, path, user=None, group=None, mode=None): :param c: `~invoke.context.Context` within to execute commands. + :param runner: + Callable runner function or method. Should ideally be a + bound method on the given context object! :param str path: File path to directory. :param str user: @@ -25,12 +26,12 @@ def directory(c, runner, path, user=None, group=None, mode=None): :param str mode: ``chmod`` compatible mode string to apply to the directory. """ - runner("mkdir -p {}".format(path)) + runner("mkdir -p {path}".format(path=path)) if user is not None: group = group or user - runner("chown {}:{} {}".format(user, group, path)) + runner("chown {user}:{group} {path}".format(user=user, group=group, path=path)) if mode is not None: - runner("chmod {} {}".format(mode, path)) + runner("chmod {mode} {path}".format(mode=mode, path=path)) @set_runner @@ -40,6 +41,9 @@ def exists(c, runner, path): :param c: `~invoke.context.Context` within to execute commands. + :param runner: + Callable runner function or method. Should ideally be a + bound method on the given context object! :param str path: Path to check for existence. """ @@ -66,6 +70,9 @@ def contains(c, runner, filename, text, exact=False, escape=True): :param c: `~invoke.context.Context` within to execute commands. + :param runner: + Callable runner function or method. Should ideally be a + bound method on the given context object! :param str filename: File path within which to check for ``text``. :param str text: @@ -78,8 +85,8 @@ def contains(c, runner, filename, text, exact=False, escape=True): if escape: text = _escape_for_regex(text) if exact: - text = "^{}$".format(text) - egrep_cmd = 'egrep "{}" "{}"'.format(text, filename) + text = "^{text}$".format(text=text) + egrep_cmd = 'egrep "{text}" "{filename}"'.format(text=text, filename=filename) return runner(egrep_cmd, hide=True, warn=True).ok @@ -105,6 +112,9 @@ def append(c, runner, filename, text, partial=False, escape=True): :param c: `~invoke.context.Context` within to execute commands. + :param runner: + Callable runner function or method. Should ideally be a + bound method on the given context object! :param str filename: File path to append onto. :param str text: @@ -116,7 +126,7 @@ def append(c, runner, filename, text, partial=False, escape=True): Whether to perform regex-oriented escaping on ``text``. """ # Normalize non-list input to be a list - if isinstance(text, six.string_types): + if isinstance(text, str): text = [text] for line in text: regex = "^" + _escape_for_regex(line) + ("" if partial else "$") @@ -127,7 +137,7 @@ def append(c, runner, filename, text, partial=False, escape=True): ): continue line = line.replace("'", r"'\\''") if escape else line - runner("echo '{}' >> {}".format(line, filename)) + runner("echo '{line}' >> {filename}".format(line=line, filename=filename)) def _escape_for_regex(text): diff --git a/patchwork/info.py b/src/patchwork/info.py similarity index 95% rename from patchwork/info.py rename to src/patchwork/info.py index d737d94..f3f981d 100644 --- a/patchwork/info.py +++ b/src/patchwork/info.py @@ -29,7 +29,7 @@ def distro_name(c): } for name, sentinels in sentinel_files.items(): for sentinel in sentinels: - if exists(c, "/etc/{}".format(sentinel)): + if exists(c, "/etc/{sentinel}".format(sentinel=sentinel)): return name return "other" diff --git a/patchwork/packages/__init__.py b/src/patchwork/packages/__init__.py similarity index 88% rename from patchwork/packages/__init__.py rename to src/patchwork/packages/__init__.py index d9065d1..af8783f 100644 --- a/patchwork/packages/__init__.py +++ b/src/patchwork/packages/__init__.py @@ -6,7 +6,7 @@ # apt/deb, rpm/yum/dnf, arch/pacman, etc etc etc. -from patchwork.info import distro_family +from ..info import distro_family def package(c, *packages): @@ -29,4 +29,4 @@ def rubygem(c, gem): """ Install a Ruby gem. """ - return c.sudo("gem install -b --no-rdoc --no-ri {}".format(gem)) + return c.sudo("gem install -b --no-rdoc --no-ri {gem}".format(gem=gem)) diff --git a/patchwork/transfers.py b/src/patchwork/transfers.py similarity index 92% rename from patchwork/transfers.py rename to src/patchwork/transfers.py index e7292da..892b7c5 100644 --- a/patchwork/transfers.py +++ b/src/patchwork/transfers.py @@ -2,8 +2,6 @@ File transfer functionality above and beyond basic ``put``/``get``. """ -from invoke.vendor import six - def rsync( c, @@ -79,7 +77,7 @@ def rsync( (rsync's ``--rsh`` flag.) """ # Turn single-string exclude into a one-item list for consistency - if isinstance(exclude, six.string_types): + if isinstance(exclude, str): exclude = [exclude] # Create --exclude options from exclude list exclude_opts = ' --exclude "{}"' * len(exclude) @@ -97,22 +95,22 @@ def rsync( # always-a-list, always-up-to-date-from-all-sources attribute to save us # from having to do this sort of thing. (may want to wait for Paramiko auth # overhaul tho!) - if isinstance(keys, six.string_types): + if isinstance(keys, str): keys = [keys] if keys: key_string = "-i " + " -i ".join(keys) # Get base cxn params user, host, port = c.user, c.host, c.port - port_string = "-p {}".format(port) + port_string = "-p {port}".format(port=port) # Remote shell (SSH) options rsh_string = "" # Strict host key checking disable_keys = "-o StrictHostKeyChecking=no" if not strict_host_keys and disable_keys not in ssh_opts: - ssh_opts += " {}".format(disable_keys) + ssh_opts += " {disable_keys}".format(disable_keys=disable_keys) rsh_parts = [key_string, port_string, ssh_opts] if any(rsh_parts): - rsh_string = "--rsh='ssh {}'".format(" ".join(rsh_parts)) + rsh_string = "--rsh='ssh {rsh_parts}'".format(rsh_parts=' '.join(rsh_parts)) # Set up options part of string options_map = { "delete": "--delete" if delete else "", @@ -120,7 +118,10 @@ def rsync( "rsh": rsh_string, "extra": rsync_opts, } - options = "{delete}{exclude} -pthrvz {extra} {rsh}".format(**options_map) + options = "{delete}{exclude} -pthrvz {extra} {rsh}".format( + delete=options_map['delete'], exclude=options_map['exclude'], + extra=options_map['extra'], rsh=options_map['rsh'] + ) # Create and run final command string # TODO: richer host object exposing stuff like .address_is_ipv6 or whatever if host.count(":") > 1: diff --git a/patchwork/util.py b/src/patchwork/util.py similarity index 89% rename from patchwork/util.py rename to src/patchwork/util.py index 705a475..e1499ee 100644 --- a/patchwork/util.py +++ b/src/patchwork/util.py @@ -1,11 +1,10 @@ """ Helpers and decorators, primarily for internal or advanced use. """ - import textwrap from functools import wraps -from inspect import getargspec, formatargspec +from inspect import signature, Parameter # TODO: calling all functions as eg directory(c, '/foo/bar/') (with initial c) @@ -126,18 +125,16 @@ def munge_docstring(f, inner): # Terrible, awful hacks to ensure Sphinx autodoc sees the intended # (modified) signature; leverages the fact that autodoc_docstring_signature # is True by default. - args, varargs, keywords, defaults = getargspec(f) + sig = signature(f) + parameters = list(sig.parameters.values()) # Nix positional version of runner arg, which is always 2nd - del args[1] - # Add new args to end in desired order - args.extend(["sudo", "runner_method", "runner"]) - # Add default values (remembering that this tuple matches the _end_ of the - # signature...) - defaults = tuple(list(defaults or []) + [False, "run", None]) + del parameters[1] + # Append new arguments + parameters.append(Parameter("sudo", Parameter.POSITIONAL_OR_KEYWORD, default=False)) + parameters.append(Parameter("runner_method", Parameter.POSITIONAL_OR_KEYWORD, default="run")) + parameters.append(Parameter("runner", Parameter.POSITIONAL_OR_KEYWORD, default=None)) + sig = sig.replace(parameters=parameters) # Get signature first line for Sphinx autodoc_docstring_signature - sigtext = "{}{}".format( - f.__name__, formatargspec(args, varargs, keywords, defaults) - ) docstring = textwrap.dedent(inner.__doc__ or "").strip() # Construct :param: list params = """:param bool sudo: @@ -147,4 +144,5 @@ def munge_docstring(f, inner): :param runner: Callable runner function or method. Should ideally be a bound method on the given context object! """ # noqa - return "{}\n{}\n\n{}".format(sigtext, docstring, params) + return "{name}{sig}\n{docstring}\n\n{params}".format(name=f.__name__, sig=sig, + docstring=docstring, params=params) diff --git a/tasks.py b/tasks.py index e26e4a1..56ee364 100644 --- a/tasks.py +++ b/tasks.py @@ -1,6 +1,7 @@ +from __future__ import print_function from importlib import import_module -from invocations import docs, travis +from invocations import docs from invocations.checks import blacken from invocations.packaging import release from invocations.pytest import test, coverage @@ -15,12 +16,12 @@ def sanity(c): """ # Doesn't need to literally import everything, but "a handful" will do. for name in ("environment", "files", "transfers"): - mod = "patchwork.{}".format(name) + mod = "patchwork.{name}".format(name=name) import_module(mod) - print("Imported {} successfully".format(mod)) + print("Imported {mod} successfully".format(mod=mod)) -ns = Collection(docs, release, travis, test, coverage, sanity, blacken) +ns = Collection(docs, release, test, coverage, sanity, blacken) ns.configure( { "packaging": {