diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dd61438..e826947 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -5,14 +5,14 @@ on: schedule: - cron: '0 8 * * 6' jobs: - test-py3: + test: env: PIP_DISABLE_PIP_VERSION_CHECK: 1 strategy: fail-fast: false matrix: os: ["ubuntu-20.04", "macos-latest"] - python: ["3.5", "3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.9"] + python: ["3.6", "3.7", "3.8", "3.9", "3.10", "pypy-3.9"] include: - os: "windows-latest" python: "3.8" @@ -38,28 +38,6 @@ jobs: python -m tox python -m tox -e coverage_report - uses: codecov/codecov-action@v3 - - test-py2: - env: - PIP_DISABLE_PIP_VERSION_CHECK: 1 - runs-on: ubuntu-20.04 - container: - image: python:2.7.18-buster - name: "Test: Python 2.7 on ubuntu-20.04" - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - - name: "Install dependencies" - run: | - python -m pip install --upgrade pip setuptools - python -m pip install tox tox-gh-actions - - name: "Run tests" - run: | - python -m tox - python -m tox -e coverage_report - - uses: codecov/codecov-action@v3 - fmt: name: Format runs-on: ubuntu-22.04 diff --git a/CHANGELOG.md b/CHANGELOG.md index 552c347..62bd51d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,49 +1,52 @@ Note: this changelog only lists feature additions, not bugfixes. For details on those, see the Git history. -* v1.19 - * Add `mode:` option for `create` - * Add `exclude:` option for `link` -* v1.18 - * Add `--only` and `--except` flags - * Add support to run with `python -m dotbot` - * Add `--force-color` option -* v1.17 - * Add `canonicalize-path:` option for `link` -* v1.16 - * Add `create` plugin -* v1.15 - * Add `quiet:` option for `shell` -* v1.14 - * Add `if:` option for `link` -* v1.13 - * Add `--no-color` flag -* v1.12 - * Add globbing support to `link` -* v1.11 - * Add force option to `clean` to remove all broken symlinks -* v1.10 - * Update `link` to support shorthand syntax for links -* v1.9 - * Add support for default options for commands -* v1.8 - * Update `link` to be able to create relative links -* v1.7 - * Add support for plugins -* v1.6 - * Update `link` to expand environment variables in paths -* v1.5 - * Update `link` to be able to automatically overwrite broken symlinks -* v1.4 - * Update `shell` to allow for selectively enabling/disabling stdin, stdout, +- v1.20 + - Drop support for Python 2 and old versions of Python 3: the minimum + version supported is now Python 3.6 +- v1.19 + - Add `mode:` option for `create` + - Add `exclude:` option for `link` +- v1.18 + - Add `--only` and `--except` flags + - Add support to run with `python -m dotbot` + - Add `--force-color` option +- v1.17 + - Add `canonicalize-path:` option for `link` +- v1.16 + - Add `create` plugin +- v1.15 + - Add `quiet:` option for `shell` +- v1.14 + - Add `if:` option for `link` +- v1.13 + - Add `--no-color` flag +- v1.12 + - Add globbing support to `link` +- v1.11 + - Add force option to `clean` to remove all broken symlinks +- v1.10 + - Update `link` to support shorthand syntax for links +- v1.9 + - Add support for default options for commands +- v1.8 + - Update `link` to be able to create relative links +- v1.7 + - Add support for plugins +- v1.6 + - Update `link` to expand environment variables in paths +- v1.5 + - Update `link` to be able to automatically overwrite broken symlinks +- v1.4 + - Update `shell` to allow for selectively enabling/disabling stdin, stdout, and stderr -* v1.3 - * Add support for YAML format configs -* v1.2 - * Update `link` to be able to force create links (deleting things that were +- v1.3 + - Add support for YAML format configs +- v1.2 + - Update `link` to be able to force create links (deleting things that were previously there) - * Update `link` to be able to create parent directories -* v1.1 - * Update `clean` to remove old broken symlinks -* v1.0 - * Initial commit + - Update `link` to be able to create parent directories +- v1.1 + - Update `clean` to remove old broken symlinks +- v1.0 + - Initial commit diff --git a/README.md b/README.md index 2af6ed4..d8b8333 100644 --- a/README.md +++ b/README.md @@ -191,13 +191,13 @@ mapped to extended configuration dictionaries. When `glob: True`, Dotbot uses [glob.glob](https://docs.python.org/3/library/glob.html#glob.glob) to resolve glob paths, expanding Unix shell-style wildcards, which are **not** the same as regular expressions; Only the following are expanded: -| Pattern | Meaning | -|:---------|:-------------------------------------------------------| -| `*` | matches anything | -| `**` | matches any **file**, recursively (Python >= 3.5 only) | -| `?` | matches any single character | -| `[seq]` | matches any character in `seq` | -| `[!seq]` | matches any character not in `seq` | +| Pattern | Meaning | +|:---------|:-----------------------------------| +| `*` | matches anything | +| `**` | matches any **file**, recursively | +| `?` | matches any single character | +| `[seq]` | matches any character in `seq` | +| `[!seq]` | matches any character not in `seq` | However, due to the design of `glob.glob`, using a glob pattern such as `config/*`, will **not** match items that begin with `.`. To specifically capture items that being with `.`, you will need to include the `.` in the pattern, like this: `config/.*`. diff --git a/bin/dotbot b/bin/dotbot index 6232a50..2fd9b86 100755 --- a/bin/dotbot +++ b/bin/dotbot @@ -7,9 +7,8 @@ # is useful because we don't know the name of the python binary. ''':' # begin python string; this line is interpreted by the shell as `:` -command -v python >/dev/null 2>&1 && exec python "$0" "$@" command -v python3 >/dev/null 2>&1 && exec python3 "$0" "$@" -command -v python2 >/dev/null 2>&1 && exec python2 "$0" "$@" +command -v python >/dev/null 2>&1 && exec python "$0" "$@" >&2 echo "error: cannot find python" exit 1 ''' @@ -18,6 +17,14 @@ exit 1 import sys, os +# this file is syntactically valid Python 2; bail out if the interpreter is Python 2 +if sys.version_info[0] < 3: + print('error: this version of Dotbot is not compatible with Python 2:\nhttps://github.com/anishathalye/dotbot/wiki/Troubleshooting#python-2') + exit(1) +if sys.version_info < (3, 6): + print('error: this version of Dotbot requires Python 3.6+') + exit(1) + PROJECT_ROOT_DIRECTORY = os.path.dirname( os.path.dirname(os.path.realpath(__file__))) @@ -25,11 +32,7 @@ def inject(lib_path): path = os.path.join(PROJECT_ROOT_DIRECTORY, 'lib', lib_path) sys.path.insert(0, path) -# version dependent libraries -if sys.version_info[0] >= 3: - inject('pyyaml/lib3') -else: - inject('pyyaml/lib') +inject('pyyaml/lib3') if os.path.exists(os.path.join(PROJECT_ROOT_DIRECTORY, 'dotbot')): if PROJECT_ROOT_DIRECTORY not in sys.path: diff --git a/dotbot/config.py b/dotbot/config.py index b076fd6..4a60bb9 100644 --- a/dotbot/config.py +++ b/dotbot/config.py @@ -6,7 +6,7 @@ import yaml from .util import string -class ConfigReader(object): +class ConfigReader: def __init__(self, config_file_path): self._config = self._read(config_file_path) diff --git a/dotbot/context.py b/dotbot/context.py index da10ba4..3ce9e6e 100644 --- a/dotbot/context.py +++ b/dotbot/context.py @@ -3,7 +3,7 @@ import os from argparse import Namespace -class Context(object): +class Context: """ Contextual data and information for plugins. """ diff --git a/dotbot/dispatcher.py b/dotbot/dispatcher.py index 76994c3..f89683d 100644 --- a/dotbot/dispatcher.py +++ b/dotbot/dispatcher.py @@ -6,7 +6,7 @@ from .messenger import Messenger from .plugin import Plugin -class Dispatcher(object): +class Dispatcher: def __init__( self, base_directory, diff --git a/dotbot/messenger/color.py b/dotbot/messenger/color.py index 58a67b7..0dc32c9 100644 --- a/dotbot/messenger/color.py +++ b/dotbot/messenger/color.py @@ -1,4 +1,4 @@ -class Color(object): +class Color: NONE = "" RESET = "\033[0m" RED = "\033[91m" diff --git a/dotbot/messenger/level.py b/dotbot/messenger/level.py index 2c361f6..b5ca195 100644 --- a/dotbot/messenger/level.py +++ b/dotbot/messenger/level.py @@ -1,4 +1,4 @@ -class Level(object): +class Level: NOTSET = 0 DEBUG = 10 LOWINFO = 15 diff --git a/dotbot/messenger/messenger.py b/dotbot/messenger/messenger.py index ddd8e39..364b585 100644 --- a/dotbot/messenger/messenger.py +++ b/dotbot/messenger/messenger.py @@ -1,10 +1,9 @@ -from ..util.compat import with_metaclass from ..util.singleton import Singleton from .color import Color from .level import Level -class Messenger(with_metaclass(Singleton, object)): +class Messenger(metaclass=Singleton): def __init__(self, level=Level.LOWINFO): self.set_level(level) self.use_color(True) diff --git a/dotbot/plugin.py b/dotbot/plugin.py index fd1fe3d..84913a9 100644 --- a/dotbot/plugin.py +++ b/dotbot/plugin.py @@ -2,7 +2,7 @@ from .context import Context from .messenger import Messenger -class Plugin(object): +class Plugin: """ Abstract base class for commands that process directives. """ diff --git a/dotbot/plugins/link.py b/dotbot/plugins/link.py index 152fd91..3a50a21 100644 --- a/dotbot/plugins/link.py +++ b/dotbot/plugins/link.py @@ -137,13 +137,7 @@ class Link(Plugin): """ Wrap `glob.glob` in a python agnostic way, catching errors in usage. """ - if sys.version_info < (3, 5) and "**" in path: - self._log.error( - 'Link cannot handle recursive glob ("**") for Python < version 3.5: "%s"' % path - ) - return [] - # call glob.glob; only python >= 3.5 supports recursive globs - found = glob.glob(path) if (sys.version_info < (3, 5)) else glob.glob(path, recursive=True) + found = glob.glob(path, recursive=True) # normalize paths to ensure cross-platform compatibility found = [os.path.normpath(p) for p in found] # if using recursive glob (`**`), filter results to return only files: diff --git a/dotbot/util/compat.py b/dotbot/util/compat.py deleted file mode 100644 index fd72417..0000000 --- a/dotbot/util/compat.py +++ /dev/null @@ -1,6 +0,0 @@ -def with_metaclass(meta, *bases): - class metaclass(meta): - def __new__(cls, name, this_bases, d): - return meta(name, bases, d) - - return type.__new__(metaclass, "temporary_class", (), {}) diff --git a/dotbot/util/module.py b/dotbot/util/module.py index 183cac2..6c2a6e3 100644 --- a/dotbot/util/module.py +++ b/dotbot/util/module.py @@ -23,23 +23,11 @@ def load(path): return plugins -if sys.version_info >= (3, 5): - import importlib.util +import importlib.util - def load_module(module_name, path): - spec = importlib.util.spec_from_file_location(module_name, path) - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module -elif sys.version_info >= (3, 3): - from importlib.machinery import SourceFileLoader - - def load_module(module_name, path): - return SourceFileLoader(module_name, path).load_module() - -else: - import imp - - def load_module(module_name, path): - return imp.load_source(module_name, path) +def load_module(module_name, path): + spec = importlib.util.spec_from_file_location(module_name, path) + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 3c6e79c..0000000 --- a/setup.cfg +++ /dev/null @@ -1,2 +0,0 @@ -[bdist_wheel] -universal=1 diff --git a/setup.py b/setup.py index f0764af..275ee14 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,4 @@ import re -from codecs import open # For a consistent encoding from os import path from setuptools import find_packages, setup @@ -38,10 +37,7 @@ setup( "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "License :: OSI Approved :: MIT License", - "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", diff --git a/tests/conftest.py b/tests/conftest.py index 2e9fb4d..2ede2e5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,11 @@ +import builtins import ctypes import json import os import shutil import sys import tempfile +import unittest.mock as mock from shutil import rmtree import pytest @@ -11,15 +13,6 @@ import yaml import dotbot.cli -try: - import builtins - import unittest.mock as mock -except ImportError: - # Python 2.7 compatibility - builtins = None - import __builtin__ - import mock # noqa: module not found - def get_long_path(path): """Get the long path for a given path.""" @@ -35,8 +28,7 @@ def get_long_path(path): return buffer.value -# Python 2.7 compatibility: -# On Linux, Python 2.7's tempfile.TemporaryFile() requires unlink access. +# On Linux, tempfile.TemporaryFile() requires unlink access. # This list is updated by a tempfile._mkstemp_inner() wrapper, # and its contents are checked by wrapped functions. allowed_tempfile_internal_unlink_calls = [] @@ -49,7 +41,6 @@ def wrap_function(function, function_path, arg_index, kwarg_key, root): else: value = args[arg_index] - # Python 2.7 compatibility: # Allow tempfile.TemporaryFile's internal unlink calls to work. if value in allowed_tempfile_internal_unlink_calls: return function(*args, **kwargs) @@ -68,11 +59,7 @@ def wrap_function(function, function_path, arg_index, kwarg_key, root): def wrap_open(root): - try: - wrapped = getattr(builtins, "open") - except AttributeError: - # Python 2.7 compatibility - wrapped = getattr(__builtin__, "open") + wrapped = getattr(builtins, "open") def wrapper(*args, **kwargs): if "file" in kwargs: @@ -207,11 +194,7 @@ def root(standardize_tmp): patches.append(mock.patch(function_path, wrapped)) # open() must be separately wrapped. - if builtins is not None: - function_path = "builtins.open" - else: - # Python 2.7 compatibility - function_path = "__builtin__.open" + function_path = "builtins.open" wrapped = wrap_open(current_root) patches.append(mock.patch(function_path, wrapped)) @@ -256,7 +239,7 @@ def home(monkeypatch, root): yield home -class Dotfiles(object): +class Dotfiles: """Create and manage a dotfiles directory for a test.""" def __init__(self, root): diff --git a/tests/test_bin_dotbot.py b/tests/test_bin_dotbot.py index aac6f14..4914c9a 100644 --- a/tests/test_bin_dotbot.py +++ b/tests/test_bin_dotbot.py @@ -1,25 +1,15 @@ import os +import shutil import subprocess import pytest -def which(name): - """Find an executable. - - Python 2.7 doesn't have shutil.which(). - """ - - for path in os.environ["PATH"].split(os.pathsep): - if os.path.isfile(os.path.join(path, name)): - return os.path.join(path, name) - - @pytest.mark.skipif( "sys.platform[:5] == 'win32'", reason="The hybrid sh/Python dotbot script doesn't run on Windows platforms", ) -@pytest.mark.parametrize("python_name", (None, "python", "python2", "python3")) +@pytest.mark.parametrize("python_name", (None, "python", "python3")) def test_find_python_executable(python_name, home, dotfiles): """Verify that the sh/Python hybrid dotbot executable can find Python.""" @@ -31,7 +21,7 @@ def test_find_python_executable(python_name, home, dotfiles): # Create a link to sh. tmp_bin = os.path.join(home, "tmp_bin") os.makedirs(tmp_bin) - sh_path = which("sh") + sh_path = shutil.which("sh") os.symlink(sh_path, os.path.join(tmp_bin, "sh")) if python_name: diff --git a/tests/test_link.py b/tests/test_link.py index e577f4d..b769c92 100644 --- a/tests/test_link.py +++ b/tests/test_link.py @@ -537,10 +537,6 @@ def test_link_glob_patterns(pattern, expect_file, home, dotfiles, run_dotbot): assert not os.path.islink(os.path.join(home, "globtest", "." + fruit)) -@pytest.mark.skipif( - "sys.version_info < (3, 5)", - reason="Python 3.5 required for ** globbing", -) def test_link_glob_recursive(home, dotfiles, run_dotbot): """Verify recursive link globbing and exclusions.""" @@ -773,19 +769,11 @@ def test_link_no_canonicalize(key, home, dotfiles, run_dotbot): dotfiles.write("f", "apple") dotfiles.write_config([{"defaults": {"link": {key: False}}}, {"link": {"~/.f": {"path": "f"}}}]) - try: - os.symlink( - dotfiles.directory, - os.path.join(home, "dotfiles-symlink"), - target_is_directory=True, - ) - except TypeError: - # Python 2 compatibility: - # target_is_directory is only consistently available after Python 3.3. - os.symlink( - dotfiles.directory, - os.path.join(home, "dotfiles-symlink"), - ) + os.symlink( + dotfiles.directory, + os.path.join(home, "dotfiles-symlink"), + target_is_directory=True, + ) run_dotbot( "-c", os.path.join(home, "dotfiles-symlink", os.path.basename(dotfiles.config_filename)), diff --git a/tests/test_shim.py b/tests/test_shim.py index 4a137ca..6923968 100644 --- a/tests/test_shim.py +++ b/tests/test_shim.py @@ -6,26 +6,11 @@ import sys import pytest -def which(name): - """Find an executable. - - Python 2.7 doesn't have shutil.which(). - shutil.which() is used, if possible, to handle Windows' case-insensitivity. - """ - - if hasattr(shutil, "which"): - return shutil.which(name) - - for path in os.environ["PATH"].split(os.pathsep): - if os.path.isfile(os.path.join(path, name)): - return os.path.join(path, name) - - def test_shim(root, home, dotfiles, run_dotbot): """Verify install shim works.""" # Skip the test if git is unavailable. - git = which("git") + git = shutil.which("git") if git is None: pytest.skip("git is unavailable") @@ -52,7 +37,7 @@ def test_shim(root, home, dotfiles, run_dotbot): # Run the shim script. env = dict(os.environ) if sys.platform[:5] == "win32": - args = [which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim] + args = [shutil.which("powershell"), "-ExecutionPolicy", "RemoteSigned", shim] env["USERPROFILE"] = home else: args = [shim] diff --git a/tox.ini b/tox.ini index 2a5ebe1..fb1c3a9 100644 --- a/tox.ini +++ b/tox.ini @@ -4,8 +4,8 @@ envlist = coverage_erase py{38, 39, 310}-all_platforms - py{27, 35, 36, 37}-most_platforms - pypy{2, 3}-most_platforms + py{36, 37}-most_platforms + pypy3-most_platforms coverage_report skip_missing_interpreters = true @@ -20,7 +20,6 @@ deps = pytest pytest-randomly pyyaml - mock; python_version == "2.7" commands = coverage run -m pytest tests/ @@ -66,10 +65,7 @@ python = 3.10: py310-all_platforms ; Run on most platforms (Linux and Mac) - pypy-2.7: pypy2-most_platforms pypy-3.9: pypy3-most_platforms - 2.7: py27-most_platforms - 3.5: py35-most_platforms 3.6: py36-most_platforms 3.7: py37-most_platforms