diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..6c99590 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,50 @@ +name: lint + +on: + push: + branches: + - "master" + - "release-*" + pull_request: + branches: + - "master" + - "release-*" + workflow_dispatch: + +jobs: + tests: + runs-on: ${{ matrix.os }} + + strategy: + fail-fast: false + matrix: + os: [ubuntu-20.04, windows-latest, macos-latest] + pyv: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.pyv }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.pyv }} + + - name: set PY_CACHE_KEY + run: echo "PY_CACHE_KEY=$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >> $GITHUB_ENV + - name: Cache .tox + uses: actions/cache@v3 + with: + path: ${{ github.workspace }}/.tox/checkqa + key: "tox-lint|${{ matrix.os }}|${{ env.PY_CACHE_KEY }}|${{ hashFiles('tox.ini', 'setup.*') }}" + + - name: Update pip/setuptools + run: | + pip install -U pip setuptools + + - name: Install tox + run: python -m pip install tox + + - name: Version information + run: python -m pip list + + - name: Lint + run: tox -v -e checkqa diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..181e70a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,28 @@ +name: Publish package to PyPI + +on: + release: + types: + - published + workflow_dispatch: + +jobs: + deploy: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v4 + with: + python-version: "3.9" + - name: Install requirements + run: | + pip install -U pip twine build + - name: Build + run: python -m build + - run: check-manifest + - run: twine check dist/* + - name: Publish to PyPI + env: + TWINE_USERNAME: "__token__" + TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} + run: twine upload dist/* diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml new file mode 100644 index 0000000..d20936e --- /dev/null +++ b/.github/workflows/tests.yaml @@ -0,0 +1,85 @@ +name: Tests + +on: + push: + branches: + - "master" + - "release-*" + pull_request: + branches: + - "master" + - "release-*" + workflow_dispatch: + +env: + PYTEST_ADDOPTS: "-vv --cov-report=xml:coverage-ci.xml" + PIP_DISABLE_PIP_VERSION_CHECK: true + +defaults: + run: + shell: bash + +jobs: + tests: + name: Tests + runs-on: ${{ matrix.os }} + timeout-minutes: 5 + + strategy: + fail-fast: false + matrix: + os: [ubuntu-20.04, windows-latest, macos-latest] + python: ["3.8"] + tox_env: ["coverage"] + include: + - tox_env: "py39-coverage" + os: ubuntu-20.04 + python: "3.9" + - tox_env: "py310-coverage" + python: "3.10" + os: ubuntu-20.04 + - tox_env: "py311-coverage" + python: "3.11" + os: ubuntu-20.04 + + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + + - name: set PY_CACHE_KEY + run: echo "PY_CACHE_KEY=$(python -c 'import hashlib, sys;print(hashlib.sha256(sys.version.encode()+sys.executable.encode()).hexdigest())')" >> $GITHUB_ENV + - name: Cache .tox + uses: actions/cache@v3 + with: + path: ${{ github.workspace }}/.tox/${{ matrix.tox_env }} + key: "tox|${{ matrix.os }}|${{ matrix.tox_env }}|${{ env.PY_CACHE_KEY }}|${{ hashFiles('tox.ini', 'setup.*') }}" + + - name: Update tools and print info + run: | + pip install -U pip setuptools virtualenv + pip list + + - name: Install tox + run: pip install tox + + - name: Setup tox environment + id: setup_tox + run: tox --notest -v -e ${{ matrix.tox_env }} + + - name: Run tests + run: | + python -m tox -v -e ${{ matrix.tox_env }} + + - name: Report coverage + if: always() && (contains(matrix.tox_env, 'coverage') && (steps.setup_tox.outcome == 'success')) + uses: codecov/codecov-action@v3 + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + with: + files: ./coverage-ci.xml + flags: ${{ runner.os }} + name: ${{ matrix.tox_env }} + fail_ci_if_error: true diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a790198 --- /dev/null +++ b/.gitignore @@ -0,0 +1,56 @@ +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST +*.manifest +*.spec +pip-log.txt +pip-delete-this-directory.txt +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ +*.mo +*.pot +*.log + +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +.mypy_cache/ +.dmypy.json +dmypy.json + +.ruff_cache/ diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 257243e..0000000 --- a/.travis.yml +++ /dev/null @@ -1,89 +0,0 @@ -dist: xenial -language: python - -env: - global: - - PYTEST_ADDOPTS="-vv --cov --cov-report=xml" - -stages: - - name: test - if: tag IS NOT present - - name: release - if: tag IS present - -jobs: - include: - - os: windows - language: shell - env: - - PATH=/c/Python38:/c/Python38/Scripts:$PATH - - TOXENV=py38-coverage - before_install: - - choco install --no-progress python - - - os: osx - osx_image: xcode10.2 - language: generic - env: TOXENV=py37-coverage - before_install: - - ln -sfn "$(which python3)" /usr/local/bin/python - - python -V - - test $(python -c 'import sys; print("%d%d" % sys.version_info[0:2])') = 37 - - - python: '2.7' - env: TOXENV=py27-coverage - - python: '3.4' - env: TOXENV=py34-coverage - - python: '3.5' - env: TOXENV=py35-coverage - - python: '3.6' - env: TOXENV=py36-coverage - - python: '3.7' - env: TOXENV=py37-coverage - - python: '3.8' - env: TOXENV=py38-coverage - - python: 'pypy' - env: TOXENV=pypy-coverage - - python: 'pypy3' - env: TOXENV=pypy3-coverage - - - python: '3.7' - env: TOXENV=checkqa - - - stage: release - script: skip - install: skip - after_success: true - env: - deploy: - provider: pypi - distributions: sdist bdist_wheel - user: __token__ - password: - secure: "GCyMei2qFzd9AN0EmT9AGqO0zQFtab8Yff4O9zmpDn34hk7TRhQdAHqPXTj0GovYjN783y31jQdVPbEsFiXUAtEu6rfOBwTtVvNCHGVdDQ0nhZFZVwYD3NfhaV1UCq/ahs5AdUEARAPbR8lviH4PMByrMs3x+ul+bHfZ70QlD1xvC/wlkZ+C/FWc5WiKbkqM5W/CUJoOnX7C5Cx/cI/VZI8X3N77t1J7fW4CEvk3nvU9CW8gDCcuJhq4Hr4oW85PsSCcJagwo1im3WSK+5rNTFlihoE1kGYtrDlWFrNFruAwobk9LSjk+GKTZqD6PFxilON/hiKavxHNYEBwwnfvpDTK87lQHU1LuLOjNMDn8pPOj8uvvKrx9y2BgtFcJzEq9oudtJOKYcxoVpLm/tmqB4QzlTWpOKXk769Sk7lZM9n+psu6wtAd1X8GH5qFon5z0YnNmaNFew5bKs3R/L3Eav1OyskA0zi4f/h8s98apnY4AGX7ul/xxoJhp3OXiSN75fMI6SUiNZLFgRUmFNqJ6pzCqHDbV0y60EeH+5BBLIdKc/D+YsuqDZYAjkN4ze6JVzGtxSSK9tuZyKJJ7zPXT1qdZxXRF0XOHRcVTxl+tNBncCmVvrJmf8QvQ6FteShZqTu3qfWWAubCOGVrxr0aVVZkYR6izNrAsp+J2/ETs5Q=" - on: - tags: true - repo: pdbpp/fancycompleter - -install: - - pip install tox==3.12.1 - # NOTE: need to upgrade virtualenv to allow "Direct url requirement" with - # installation in tox. - - pip install virtualenv==16.6.0 - -script: - - tox - -after_script: - - | - if [[ "${TOXENV%-coverage}" != "$TOXENV" ]]; then - curl --version - curl -S -L --connect-timeout 5 --retry 6 -s https://codecov.io/bash > codecov.sh - bash codecov.sh -Z -X fix -f coverage.xml -n $TOXENV -F "${TRAVIS_OS_NAME}" - fi - -# Only master and releases. PRs are used otherwise. -branches: - only: - - master - - /^\d+\.\d+(\.\d+)?(-\S*)?$/ diff --git a/fancycompleter.py b/fancycompleter.py index f6319ca..c602730 100644 --- a/fancycompleter.py +++ b/fancycompleter.py @@ -1,39 +1,19 @@ """ fancycompleter: colorful TAB completion for Python prompt """ -from __future__ import with_statement -from __future__ import print_function +import contextlib +import os.path import rlcompleter import sys import types -import os.path from itertools import count +from typing import Optional -PY3K = sys.version_info[0] >= 3 - -# python3 compatibility -# --------------------- -try: - from itertools import izip -except ImportError: - izip = zip +izip = zip -try: - from types import ClassType -except ImportError: - ClassType = type - -try: - unicode -except NameError: - unicode = str - -# ---------------------- - - -class LazyVersion(object): +class LazyVersion: def __init__(self, pkg): self.pkg = pkg self.__version = None @@ -46,15 +26,15 @@ def version(self): def _load_version(self): try: - from pkg_resources import get_distribution, DistributionNotFound + from pkg_resources import DistributionNotFound, get_distribution except ImportError: - return 'N/A' + return "N/A" # try: return get_distribution(self.pkg).version except DistributionNotFound: # package is not installed - return 'N/A' + return "N/A" def __repr__(self): return self.version @@ -72,37 +52,34 @@ def __ne__(self, other): class Color: - black = '30' - darkred = '31' - darkgreen = '32' - brown = '33' - darkblue = '34' - purple = '35' - teal = '36' - lightgray = '37' - darkgray = '30;01' - red = '31;01' - green = '32;01' - yellow = '33;01' - blue = '34;01' - fuchsia = '35;01' - turquoise = '36;01' - white = '37;01' + black = "30" + darkred = "31" + darkgreen = "32" + brown = "33" + darkblue = "34" + purple = "35" + teal = "36" + lightgray = "37" + darkgray = "30;01" + red = "31;01" + green = "32;01" + yellow = "33;01" + blue = "34;01" + fuchsia = "35;01" + turquoise = "36;01" + white = "37;01" @classmethod def set(cls, color, string): - try: + with contextlib.suppress(AttributeError): color = getattr(cls, color) - except AttributeError: - pass - return '\x1b[%sm%s\x1b[00m' % (color, string) + return f"\x1b[{color}m{string}\x1b[00m" class DefaultConfig: - consider_getitems = True prefer_pyrepl = True - use_colors = 'auto' + use_colors = "auto" readline = None # set by setup() using_pyrepl = False # overwritten by find_pyrepl @@ -112,17 +89,12 @@ class DefaultConfig: type((42).__add__): Color.turquoise, type(int.__add__): Color.turquoise, type(str.replace): Color.turquoise, - types.FunctionType: Color.blue, types.BuiltinFunctionType: Color.blue, - - ClassType: Color.fuchsia, type: Color.fuchsia, - types.ModuleType: Color.teal, type(None): Color.lightgray, str: Color.green, - unicode: Color.green, int: Color.yellow, float: Color.yellow, complex: Color.yellow, @@ -136,12 +108,12 @@ class DefaultConfig: def find_pyrepl(self): try: - import pyrepl.readline import pyrepl.completing_reader + import pyrepl.readline except ImportError: return None self.using_pyrepl = True - if hasattr(pyrepl.completing_reader, 'stripcolor'): + if hasattr(pyrepl.completing_reader, "stripcolor"): # modern version of pyrepl return pyrepl.readline, True else: @@ -150,11 +122,12 @@ def find_pyrepl(self): def find_pyreadline(self): try: import readline + import pyreadline # noqa: F401 # XXX: needed really? from pyreadline.modes import basemode except ImportError: return None - if hasattr(basemode, 'stripcolor'): + if hasattr(basemode, "stripcolor"): # modern version of pyreadline; see: # https://github.com/pyreadline/pyreadline/pull/48 return readline, True @@ -166,22 +139,23 @@ def find_best_readline(self): result = self.find_pyrepl() if result: return result - if sys.platform == 'win32': + if sys.platform == "win32": result = self.find_pyreadline() if result: return result import readline + return readline, False # by default readline does not support colors def setup(self): self.readline, supports_color = self.find_best_readline() - if self.use_colors == 'auto': + if self.use_colors == "auto": self.use_colors = supports_color def my_execfile(filename, mydict): with open(filename) as f: - code = compile(f.read(), filename, 'exec') + code = compile(f.read(), filename, "exec") exec(code, mydict) @@ -193,7 +167,7 @@ def get_config(self, Config): if Config is not None: return Config() # try to load config from the ~/filename file - filename = '~/' + self.config_filename + filename = "~/" + self.config_filename rcfile = os.path.normpath(os.path.expanduser(filename)) if not os.path.exists(rcfile): return self.DefaultConfig() @@ -204,7 +178,7 @@ def get_config(self, Config): except Exception as exc: import traceback - sys.stderr.write("** error when importing %s: %r **\n" % (filename, exc)) + sys.stderr.write(f"** error when importing {filename}: {exc!r} **\n") traceback.print_tb(sys.exc_info()[2]) return self.DefaultConfig() @@ -216,13 +190,16 @@ def get_config(self, Config): try: return Config() except Exception as exc: - err = "error when setting up Config from %s: %s" % (filename, exc) + err = f"error when setting up Config from {filename}: {exc}" tb = sys.exc_info()[2] if tb and tb.tb_next: tb = tb.tb_next err_fname = tb.tb_frame.f_code.co_filename err_lnum = tb.tb_lineno - err += " (%s:%d)" % (err_fname, err_lnum,) + err += " (%s:%d)" % ( + err_fname, + err_lnum, + ) sys.stderr.write("** %s **\n" % err) return self.DefaultConfig() @@ -237,25 +214,25 @@ class Completer(rlcompleter.Completer, ConfigurableClass): """ DefaultConfig = DefaultConfig - config_filename = '.fancycompleterrc.py' + config_filename = ".fancycompleterrc.py" def __init__(self, namespace=None, Config=None): rlcompleter.Completer.__init__(self, namespace) self.config = self.get_config(Config) self.config.setup() readline = self.config.readline - if hasattr(readline, '_setup'): + if hasattr(readline, "_setup"): # this is needed to offer pyrepl a better chance to patch # raw_input. Usually, it does at import time, but is we are under # pytest with output captured, at import time we don't have a # terminal and thus the raw_input hook is not installed readline._setup() if self.config.use_colors: - readline.parse_and_bind('set dont-escape-ctrl-chars on') + readline.parse_and_bind("set dont-escape-ctrl-chars on") if self.config.consider_getitems: delims = readline.get_completer_delims() - delims = delims.replace('[', '') - delims = delims.replace(']', '') + delims = delims.replace("[", "") + delims = delims.replace("]", "") readline.set_completer_delims(delims) def complete(self, text, state): @@ -264,7 +241,7 @@ def complete(self, text, state): http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/496812 """ if text == "": - return ['\t', None][state] + return ["\t", None][state] else: return rlcompleter.Completer.complete(self, text, state) @@ -275,6 +252,7 @@ def _callable_postfix(self, val, word): def global_matches(self, text): import keyword + names = rlcompleter.Completer.global_matches(self, text) prefix = commonprefix(names) if prefix and prefix != text: @@ -283,7 +261,7 @@ def global_matches(self, text): names.sort() values = [] for name in names: - clean_name = name.rstrip(': ') + clean_name = name.rstrip(": ") if clean_name in keyword.kwlist: values.append(None) else: @@ -296,8 +274,8 @@ def global_matches(self, text): return names def attr_matches(self, text): - expr, attr = text.rsplit('.', 1) - if '(' in expr or ')' in expr: # don't call functions + expr, attr = text.rsplit(".", 1) + if "(" in expr or ")" in expr: # don't call functions return [] try: thisobject = eval(expr, self.namespace) @@ -308,67 +286,58 @@ def attr_matches(self, text): words = set(dir(thisobject)) words.discard("__builtins__") - if hasattr(thisobject, '__class__'): - words.add('__class__') + if hasattr(thisobject, "__class__"): + words.add("__class__") words.update(rlcompleter.get_class_members(thisobject.__class__)) names = [] values = [] n = len(attr) - if attr == '': - noprefix = '_' - elif attr == '_': - noprefix = '__' + if attr == "": + noprefix = "_" + elif attr == "_": + noprefix = "__" else: noprefix = None words = sorted(words) while True: for word in words: - if (word[:n] == attr and - not (noprefix and word[:n+1] == noprefix)): + if word[:n] == attr and not (noprefix and word[: n + 1] == noprefix): try: val = getattr(thisobject, word) except Exception: val = None # Include even if attribute not set - if not PY3K and isinstance(word, unicode): - # this is needed because pyrepl doesn't like unicode - # completions: as soon as it finds something which is not str, - # it stops. - word = word.encode('utf-8') - names.append(word) values.append(val) if names or not noprefix: break - if noprefix == '_': - noprefix = '__' - else: - noprefix = None + noprefix = "__" if noprefix == "_" else None if not names: return [] if len(names) == 1: - return ['%s.%s' % (expr, names[0])] # only option, no coloring. + return [f"{expr}.{names[0]}"] # only option, no coloring. prefix = commonprefix(names) if prefix and prefix != attr: - return ['%s.%s' % (expr, prefix)] # autocomplete prefix + return [f"{expr}.{prefix}"] # autocomplete prefix if self.config.use_colors: return self.color_matches(names, values) if prefix: - names += [' '] + names += [" "] return names def color_matches(self, names, values): - matches = [self.color_for_obj(i, name, obj) - for i, name, obj - in izip(count(), names, values)] + matches = [ + self.color_for_obj(i, name, obj) + for i, name, obj in izip(count(), names, values) + ] # We add a space at the end to prevent the automatic completion of the # common prefix, which is the ANSI ESCAPE sequence. - return matches + [' '] + return matches + [" "] def color_for_obj(self, i, name, value): t = type(value) @@ -379,19 +348,18 @@ def color_for_obj(self, i, name, value): color = _color break else: - color = '00' + color = "00" # hack: prepend an (increasing) fake escape sequence, # so that readline can sort the matches correctly. - return '\x1b[%03d;00m' % i + Color.set(color, name) + return "\x1b[%03d;00m" % i + Color.set(color, name) -def commonprefix(names, base=''): - """ return the common prefix of all 'names' starting with 'base' - """ +def commonprefix(names, base=""): + """return the common prefix of all 'names' starting with 'base'""" if base: names = [x for x in names if x.startswith(base)] if not names: - return '' + return "" s1 = min(names) s2 = max(names) for i, c in enumerate(s1): @@ -403,54 +371,54 @@ def commonprefix(names, base=''): def has_leopard_libedit(config): # Detect if we are using Leopard's libedit. # Adapted from IPython's rlineimpl.py. - if config.using_pyrepl or sys.platform != 'darwin': + if config.using_pyrepl or sys.platform != "darwin": return False # Official Python docs state that 'libedit' is in the docstring for # libedit readline. - return config.readline.__doc__ and 'libedit' in config.readline.__doc__ + return config.readline.__doc__ and "libedit" in config.readline.__doc__ -def setup(): - """ - Install fancycompleter as the default completer for readline. - """ +def setup() -> Completer: + """Install fancycompleter as the default completer for readline.""" completer = Completer() readline = completer.config.readline if has_leopard_libedit(completer.config): readline.parse_and_bind("bind ^I rl_complete") else: - readline.parse_and_bind('tab: complete') + readline.parse_and_bind("tab: complete") readline.set_completer(completer.complete) return completer def interact_pyrepl(): import sys + from pyrepl import readline from pyrepl.simple_interact import run_multiline_interactive_console - sys.modules['readline'] = readline + + sys.modules["readline"] = readline run_multiline_interactive_console() -def setup_history(completer, persist_history): +def setup_history(completer, persist_history: str): import atexit + readline = completer.config.readline - # - if isinstance(persist_history, (str, unicode)): - filename = persist_history - else: - filename = '~/.history.py' + + filename = persist_history if isinstance(persist_history, str) else "~/.history.py" + filename = os.path.expanduser(filename) if os.path.isfile(filename): readline.read_history_file(filename) def save_history(): readline.write_history_file(filename) + atexit.register(save_history) -def interact(persist_history=None): +def interact(persist_history: Optional[str] = None): """ Main entry point for fancycompleter: run an interactive Python session after installing fancycompleter. @@ -471,75 +439,80 @@ def interact(persist_history=None): By default, pyrepl is preferred and automatically used if found. """ import sys + completer = setup() - if persist_history: + if persist_history is not None: setup_history(completer, persist_history) - if completer.config.using_pyrepl and '__pypy__' not in sys.builtin_module_names: + if completer.config.using_pyrepl and "__pypy__" not in sys.builtin_module_names: # if we are on PyPy, we don't need to run a "fake" interpeter, as the # standard one is fake enough :-) interact_pyrepl() sys.exit() -class Installer(object): - """ - Helper to install fancycompleter in PYTHONSTARTUP - """ +class Installer: + """Helper to install fancycompleter in PYTHONSTARTUP""" def __init__(self, basepath, force): - fname = os.path.join(basepath, 'python_startup.py') + fname = os.path.join(basepath, "python_startup.py") self.filename = os.path.expanduser(fname) self.force = force def check(self): - PYTHONSTARTUP = os.environ.get('PYTHONSTARTUP') + PYTHONSTARTUP = os.environ.get("PYTHONSTARTUP") if PYTHONSTARTUP: - return 'PYTHONSTARTUP already defined: %s' % PYTHONSTARTUP + return "PYTHONSTARTUP already defined: %s" % PYTHONSTARTUP if os.path.exists(self.filename): - return '%s already exists' % self.filename + return "%s already exists" % self.filename def install(self): import textwrap + error = self.check() if error and not self.force: print(error) - print('Use --force to overwrite.') + print("Use --force to overwrite.") return False - with open(self.filename, 'w') as f: - f.write(textwrap.dedent(""" + with open(self.filename, "w") as f: + f.write( + textwrap.dedent( + """ import fancycompleter fancycompleter.interact(persist_history=True) - """)) + """ + ) + ) self.set_env_var() return True def set_env_var(self): - if sys.platform == 'win32': + if sys.platform == "win32": os.system('SETX PYTHONSTARTUP "%s"' % self.filename) - print('%PYTHONSTARTUP% set to', self.filename) + print("%PYTHONSTARTUP% set to", self.filename) else: - print('startup file written to', self.filename) - print('Append this line to your ~/.bashrc:') - print(' export PYTHONSTARTUP=%s' % self.filename) + print("startup file written to", self.filename) + print("Append this line to your ~/.bashrc:") + print(" export PYTHONSTARTUP=%s" % self.filename) + +if __name__ == "__main__": -if __name__ == '__main__': def usage(): - print('Usage: python -m fancycompleter install [-f|--force]') + print("Usage: python -m fancycompleter install [-f|--force]") sys.exit(1) cmd = None force = False for item in sys.argv[1:]: - if item in ('install',): + if item in ("install",): cmd = item - elif item in ('-f', '--force'): + elif item in ("-f", "--force"): force = True else: usage() # - if cmd == 'install': - installer = Installer('~', force) + if cmd == "install": + installer = Installer("~", force) installer.install() else: usage() diff --git a/misc/fancycompleterrc.py b/misc/fancycompleterrc.py index eab209c..d20589b 100644 --- a/misc/fancycompleterrc.py +++ b/misc/fancycompleterrc.py @@ -1,5 +1,6 @@ from fancycompleter import DefaultConfig + class Config(DefaultConfig): - prefer_pyrepl = False # force fancycompleter to use the standard readline - use_colors = True # you need a patched libreadline for this + prefer_pyrepl = False # force fancycompleter to use the standard readline + use_colors = True # you need a patched libreadline for this diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..942e860 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,19 @@ +[tool.ruff] +line-length = 88 + +[tool.ruff.lint] +select = [ + # pycodestyle + "E", + # Pyflakes + "F", + # pyupgrade + "UP", + # flake8-bugbear + "B", + # flake8-simplify + "SIM", + # isort + "I", +] +ignore = ["F401"] diff --git a/setup.py b/setup.py index 7d90960..31dfe36 100644 --- a/setup.py +++ b/setup.py @@ -1,34 +1,32 @@ from setuptools import setup setup( - name='fancycompleter', + name="fancycompleter", setup_requires="setupmeta", versioning="devcommit", maintainer="Daniel Hahler", - url='https://github.com/pdbpp/fancycompleter', - author='Antonio Cuni', - author_email='anto.cuni@gmail.com', - py_modules=['fancycompleter'], - license='BSD', - description='colorful TAB completion for Python prompt', - keywords='rlcompleter prompt tab color completion', + url="https://github.com/pdbpp/fancycompleter", + author="Antonio Cuni", + author_email="anto.cuni@gmail.com", + py_modules=["fancycompleter"], + license="BSD", + description="colorful TAB completion for Python prompt", + keywords="rlcompleter prompt tab color completion", classifiers=[ "Development Status :: 4 - Beta", "Environment :: Console", "License :: OSI Approved :: BSD License", - 'Programming Language :: Python :: 2.6', - 'Programming Language :: Python :: 2.7', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", "Intended Audience :: Developers", "Operating System :: POSIX", "Operating System :: Microsoft :: Windows", "Topic :: Utilities", - ], + ], install_requires=[ "pyrepl @ git+https://github.com/pdbpp/pyrepl@master#egg=pyrepl", "pyreadline;platform_system=='Windows'", - ] + ], ) diff --git a/testing/test_fancycompleter.py b/testing/test_fancycompleter.py deleted file mode 100644 index 4c2d3c8..0000000 --- a/testing/test_fancycompleter.py +++ /dev/null @@ -1,257 +0,0 @@ -import sys - -from fancycompleter import (Color, Completer, DefaultConfig, Installer, - LazyVersion, commonprefix) - - -class ConfigForTest(DefaultConfig): - use_colors = False - - -class ColorConfig(DefaultConfig): - use_colors = True - - -def test_commonprefix(): - assert commonprefix(['isalpha', 'isdigit', 'foo']) == '' - assert commonprefix(['isalpha', 'isdigit']) == 'is' - assert commonprefix(['isalpha', 'isdigit', 'foo'], base='i') == 'is' - assert commonprefix([]) == '' - assert commonprefix(['aaa', 'bbb'], base='x') == '' - - -def test_complete_attribute(): - compl = Completer({'a': None}, ConfigForTest) - assert compl.attr_matches('a.') == ['a.__'] - matches = compl.attr_matches('a.__') - assert 'a.__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('a.__class') == ['a.__class__'] - - -def test_complete_attribute_prefix(): - class C(object): - attr = 1 - _attr = 2 - __attr__attr = 3 - compl = Completer({'a': C}, ConfigForTest) - assert compl.attr_matches('a.') == ['attr', 'mro'] - assert compl.attr_matches('a._') == ['_C__attr__attr', '_attr', ' '] - matches = compl.attr_matches('a.__') - assert 'a.__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('a.__class') == ['a.__class__'] - - compl = Completer({'a': None}, ConfigForTest) - assert compl.attr_matches('a._') == ['a.__'] - - -def test_complete_attribute_colored(): - compl = Completer({'a': 42}, ColorConfig) - matches = compl.attr_matches('a.__') - assert len(matches) > 2 - expected_color = compl.config.color_by_type.get(type(compl.__class__)) - assert expected_color == '35;01' - expected_part = Color.set(expected_color, '__class__') - for match in matches: - if expected_part in match: - break - else: - assert False, matches - assert ' ' in matches - - -def test_complete_colored_single_match(): - """No coloring, via commonprefix.""" - compl = Completer({'foobar': 42}, ColorConfig) - matches = compl.global_matches('foob') - assert matches == ['foobar'] - - -def test_does_not_color_single_match(): - class obj: - msgs = [] - - compl = Completer({'obj': obj}, ColorConfig) - matches = compl.attr_matches('obj.msgs') - assert matches == ['obj.msgs'] - - -def test_complete_global(): - compl = Completer({'foobar': 1, 'foobazzz': 2}, ConfigForTest) - assert compl.global_matches('foo') == ['fooba'] - matches = compl.global_matches('fooba') - assert set(matches) == set(['foobar', 'foobazzz']) - assert compl.global_matches('foobaz') == ['foobazzz'] - assert compl.global_matches('nothing') == [] - - -def test_complete_global_colored(): - compl = Completer({'foobar': 1, 'foobazzz': 2}, ColorConfig) - assert compl.global_matches('foo') == ['fooba'] - matches = compl.global_matches('fooba') - assert set(matches) == { - ' ', - '\x1b[001;00m\x1b[33;01mfoobazzz\x1b[00m', - '\x1b[000;00m\x1b[33;01mfoobar\x1b[00m', - } - assert compl.global_matches('foobaz') == ['foobazzz'] - assert compl.global_matches('nothing') == [] - - -def test_complete_global_colored_exception(): - compl = Completer({'tryme': ValueError()}, ColorConfig) - if sys.version_info >= (3, 6): - assert compl.global_matches('try') == [ - '\x1b[000;00m\x1b[37mtry:\x1b[00m', - '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', - ' ' - ] - else: - assert compl.global_matches('try') == [ - '\x1b[000;00m\x1b[37mtry\x1b[00m', - '\x1b[001;00m\x1b[31;01mtryme\x1b[00m', - ' ' - ] - - -def test_complete_global_exception(monkeypatch): - import rlcompleter - - def rlcompleter_global_matches(self, text): - return ['trigger_exception!', 'nameerror', 'valid'] - - monkeypatch.setattr(rlcompleter.Completer, 'global_matches', - rlcompleter_global_matches) - - compl = Completer({'valid': 42}, ColorConfig) - assert compl.global_matches("") == [ - "\x1b[000;00m\x1b[31;01mnameerror\x1b[00m", - "\x1b[001;00m\x1b[31;01mtrigger_exception!\x1b[00m", - "\x1b[002;00m\x1b[33;01mvalid\x1b[00m", - " ", - ] - - -def test_color_for_obj(monkeypatch): - class Config(ColorConfig): - color_by_type = {} - - compl = Completer({}, Config) - assert compl.color_for_obj(1, "foo", "bar") == "\x1b[001;00m\x1b[00mfoo\x1b[00m" - - -def test_complete_with_indexer(): - compl = Completer({'lst': [None, 2, 3]}, ConfigForTest) - assert compl.attr_matches('lst[0].') == ['lst[0].__'] - matches = compl.attr_matches('lst[0].__') - assert 'lst[0].__class__' not in matches - assert '__class__' in matches - assert compl.attr_matches('lst[0].__class') == ['lst[0].__class__'] - - -def test_autocomplete(): - class A: - aaa = None - abc_1 = None - abc_2 = None - abc_3 = None - bbb = None - compl = Completer({'A': A}, ConfigForTest) - # - # in this case, we want to display all attributes which start with - # 'a'. MOREOVER, we also include a space to prevent readline to - # automatically insert the common prefix (which will the the ANSI escape - # sequence if we use colors) - matches = compl.attr_matches('A.a') - assert sorted(matches) == [' ', 'aaa', 'abc_1', 'abc_2', 'abc_3'] - # - # IF there is an actual common prefix, we return just it, so that readline - # will insert it into place - matches = compl.attr_matches('A.ab') - assert matches == ['A.abc_'] - # - # finally, at the next TAB, we display again all the completions available - # for this common prefix. Agai, we insert a spurious space to prevent the - # automatic completion of ANSI sequences - matches = compl.attr_matches('A.abc_') - assert sorted(matches) == [' ', 'abc_1', 'abc_2', 'abc_3'] - - -def test_complete_exception(): - compl = Completer({}, ConfigForTest) - assert compl.attr_matches('xxx.') == [] - - -def test_complete_invalid_attr(): - compl = Completer({'str': str}, ConfigForTest) - assert compl.attr_matches('str.xx') == [] - - -def test_complete_function_skipped(): - compl = Completer({'str': str}, ConfigForTest) - assert compl.attr_matches('str.split().') == [] - - -def test_unicode_in___dir__(): - class Foo(object): - def __dir__(self): - return [u'hello', 'world'] - - compl = Completer({'a': Foo()}, ConfigForTest) - matches = compl.attr_matches('a.') - assert matches == ['hello', 'world'] - assert type(matches[0]) is str - - -class MyInstaller(Installer): - env_var = 0 - - def set_env_var(self): - self.env_var += 1 - - -class TestInstaller(object): - - def test_check(self, monkeypatch, tmpdir): - installer = MyInstaller(str(tmpdir), force=False) - monkeypatch.setenv('PYTHONSTARTUP', '') - assert installer.check() is None - f = tmpdir.join('python_startup.py').ensure(file=True) - assert installer.check() == '%s already exists' % f - monkeypatch.setenv('PYTHONSTARTUP', 'foo') - assert installer.check() == 'PYTHONSTARTUP already defined: foo' - - def test_install(self, monkeypatch, tmpdir): - installer = MyInstaller(str(tmpdir), force=False) - monkeypatch.setenv('PYTHONSTARTUP', '') - assert installer.install() - assert 'fancycompleter' in tmpdir.join('python_startup.py').read() - assert installer.env_var == 1 - # - # the second time, it fails because the file already exists - assert not installer.install() - assert installer.env_var == 1 - # - # the third time, it succeeds because we set force - installer.force = True - assert installer.install() - assert installer.env_var == 2 - - -class TestLazyVersion(object): - - class MyLazyVersion(LazyVersion): - __count = 0 - - def _load_version(self): - assert self.__count == 0 - self.__count += 1 - return '0.1' - - def test_lazy_version(self): - ver = self.MyLazyVersion('foo') - assert repr(ver) == '0.1' - assert str(ver) == '0.1' - assert ver == '0.1' - assert not ver != '0.1' diff --git a/testing/__init__.py b/tests/__init__.py similarity index 100% rename from testing/__init__.py rename to tests/__init__.py diff --git a/testing/conftest.py b/tests/conftest.py similarity index 99% rename from testing/conftest.py rename to tests/conftest.py index 7951f24..4cd4765 100644 --- a/testing/conftest.py +++ b/tests/conftest.py @@ -1,6 +1,5 @@ import pytest - pytest_plugins = ["pytester"] diff --git a/testing/test_configurableclass.py b/tests/test_configurableclass.py similarity index 72% rename from testing/test_configurableclass.py rename to tests/test_configurableclass.py index a10340c..1d6b250 100644 --- a/testing/test_configurableclass.py +++ b/tests/test_configurableclass.py @@ -44,12 +44,14 @@ class MyCfg(ConfigurableClass): assert isinstance(cfg.get_config(None), DefaultCfg) out, err = capsys.readouterr() assert out == "" - LineMatcher(err.splitlines()).fnmatch_lines([ - "[*][*] error when importing ~/.mycfg: Exception('my_execfile_exc'*) [*][*]", - ' File */fancycompleter.py", line *, in get_config', - ' my_execfile(rcfile, mydict)', - ' File */fancycompleter.py", line *, in my_execfile', - ' exec(code, mydict)', - ' File "*/test_config0/.mycfg", line 1, in ', - " raise Exception('my_execfile_exc')", - ]) + LineMatcher(err.splitlines()).fnmatch_lines( + [ + "[*][*] error when importing ~/.mycfg: Exception('my_execfile_exc'*) [*][*]", + ' File */fancycompleter.py", line *, in get_config', + " my_execfile(rcfile, mydict)", + ' File */fancycompleter.py", line *, in my_execfile', + " exec(code, mydict)", + ' File "*/test_config0/.mycfg", line 1, in ', + " raise Exception('my_execfile_exc')", + ] + ) diff --git a/tests/test_fancycompleter.py b/tests/test_fancycompleter.py new file mode 100644 index 0000000..4b892d6 --- /dev/null +++ b/tests/test_fancycompleter.py @@ -0,0 +1,264 @@ +import sys + +from fancycompleter import ( + Color, + Completer, + DefaultConfig, + Installer, + LazyVersion, + commonprefix, +) + + +class ConfigForTest(DefaultConfig): + use_colors = False + + +class ColorConfig(DefaultConfig): + use_colors = True + + +def test_commonprefix(): + assert commonprefix(["isalpha", "isdigit", "foo"]) == "" + assert commonprefix(["isalpha", "isdigit"]) == "is" + assert commonprefix(["isalpha", "isdigit", "foo"], base="i") == "is" + assert commonprefix([]) == "" + assert commonprefix(["aaa", "bbb"], base="x") == "" + + +def test_complete_attribute(): + compl = Completer({"a": None}, ConfigForTest) + assert compl.attr_matches("a.") == ["a.__"] + matches = compl.attr_matches("a.__") + assert "a.__class__" not in matches + assert "__class__" in matches + assert compl.attr_matches("a.__class") == ["a.__class__"] + + +def test_complete_attribute_prefix(): + class C(object): + attr = 1 + _attr = 2 + __attr__attr = 3 + + compl = Completer({"a": C}, ConfigForTest) + assert compl.attr_matches("a.") == ["attr", "mro"] + assert compl.attr_matches("a._") == ["_C__attr__attr", "_attr", " "] + matches = compl.attr_matches("a.__") + assert "a.__class__" not in matches + assert "__class__" in matches + assert compl.attr_matches("a.__class") == ["a.__class__"] + + compl = Completer({"a": None}, ConfigForTest) + assert compl.attr_matches("a._") == ["a.__"] + + +def test_complete_attribute_colored(): + compl = Completer({"a": 42}, ColorConfig) + matches = compl.attr_matches("a.__") + assert len(matches) > 2 + expected_color = compl.config.color_by_type.get(type(compl.__class__)) + assert expected_color == "35;01" + expected_part = Color.set(expected_color, "__class__") + for match in matches: + if expected_part in match: + break + else: + assert False, matches + assert " " in matches + + +def test_complete_colored_single_match(): + """No coloring, via commonprefix.""" + compl = Completer({"foobar": 42}, ColorConfig) + matches = compl.global_matches("foob") + assert matches == ["foobar"] + + +def test_does_not_color_single_match(): + class obj: + msgs = [] + + compl = Completer({"obj": obj}, ColorConfig) + matches = compl.attr_matches("obj.msgs") + assert matches == ["obj.msgs"] + + +def test_complete_global(): + compl = Completer({"foobar": 1, "foobazzz": 2}, ConfigForTest) + assert compl.global_matches("foo") == ["fooba"] + matches = compl.global_matches("fooba") + assert set(matches) == set(["foobar", "foobazzz"]) + assert compl.global_matches("foobaz") == ["foobazzz"] + assert compl.global_matches("nothing") == [] + + +def test_complete_global_colored(): + compl = Completer({"foobar": 1, "foobazzz": 2}, ColorConfig) + assert compl.global_matches("foo") == ["fooba"] + matches = compl.global_matches("fooba") + assert set(matches) == { + " ", + "\x1b[001;00m\x1b[33;01mfoobazzz\x1b[00m", + "\x1b[000;00m\x1b[33;01mfoobar\x1b[00m", + } + assert compl.global_matches("foobaz") == ["foobazzz"] + assert compl.global_matches("nothing") == [] + + +def test_complete_global_colored_exception(): + compl = Completer({"tryme": ValueError()}, ColorConfig) + if sys.version_info >= (3, 6): + assert compl.global_matches("try") == [ + "\x1b[000;00m\x1b[37mtry:\x1b[00m", + "\x1b[001;00m\x1b[31;01mtryme\x1b[00m", + " ", + ] + else: + assert compl.global_matches("try") == [ + "\x1b[000;00m\x1b[37mtry\x1b[00m", + "\x1b[001;00m\x1b[31;01mtryme\x1b[00m", + " ", + ] + + +def test_complete_global_exception(monkeypatch): + import rlcompleter + + def rlcompleter_global_matches(self, text): + return ["trigger_exception!", "nameerror", "valid"] + + monkeypatch.setattr( + rlcompleter.Completer, "global_matches", rlcompleter_global_matches + ) + + compl = Completer({"valid": 42}, ColorConfig) + assert compl.global_matches("") == [ + "\x1b[000;00m\x1b[31;01mnameerror\x1b[00m", + "\x1b[001;00m\x1b[31;01mtrigger_exception!\x1b[00m", + "\x1b[002;00m\x1b[33;01mvalid\x1b[00m", + " ", + ] + + +def test_color_for_obj(monkeypatch): + class Config(ColorConfig): + color_by_type = {} + + compl = Completer({}, Config) + assert compl.color_for_obj(1, "foo", "bar") == "\x1b[001;00m\x1b[00mfoo\x1b[00m" + + +def test_complete_with_indexer(): + compl = Completer({"lst": [None, 2, 3]}, ConfigForTest) + assert compl.attr_matches("lst[0].") == ["lst[0].__"] + matches = compl.attr_matches("lst[0].__") + assert "lst[0].__class__" not in matches + assert "__class__" in matches + assert compl.attr_matches("lst[0].__class") == ["lst[0].__class__"] + + +def test_autocomplete(): + class A: + aaa = None + abc_1 = None + abc_2 = None + abc_3 = None + bbb = None + + compl = Completer({"A": A}, ConfigForTest) + # + # in this case, we want to display all attributes which start with + # 'a'. MOREOVER, we also include a space to prevent readline to + # automatically insert the common prefix (which will the the ANSI escape + # sequence if we use colors) + matches = compl.attr_matches("A.a") + assert sorted(matches) == [" ", "aaa", "abc_1", "abc_2", "abc_3"] + # + # IF there is an actual common prefix, we return just it, so that readline + # will insert it into place + matches = compl.attr_matches("A.ab") + assert matches == ["A.abc_"] + # + # finally, at the next TAB, we display again all the completions available + # for this common prefix. Agai, we insert a spurious space to prevent the + # automatic completion of ANSI sequences + matches = compl.attr_matches("A.abc_") + assert sorted(matches) == [" ", "abc_1", "abc_2", "abc_3"] + + +def test_complete_exception(): + compl = Completer({}, ConfigForTest) + assert compl.attr_matches("xxx.") == [] + + +def test_complete_invalid_attr(): + compl = Completer({"str": str}, ConfigForTest) + assert compl.attr_matches("str.xx") == [] + + +def test_complete_function_skipped(): + compl = Completer({"str": str}, ConfigForTest) + assert compl.attr_matches("str.split().") == [] + + +def test_unicode_in___dir__(): + class Foo(object): + def __dir__(self): + return ["hello", "world"] + + compl = Completer({"a": Foo()}, ConfigForTest) + matches = compl.attr_matches("a.") + assert matches == ["hello", "world"] + assert type(matches[0]) is str + + +class MyInstaller(Installer): + env_var = 0 + + def set_env_var(self): + self.env_var += 1 + + +class TestInstaller(object): + def test_check(self, monkeypatch, tmpdir): + installer = MyInstaller(str(tmpdir), force=False) + monkeypatch.setenv("PYTHONSTARTUP", "") + assert installer.check() is None + f = tmpdir.join("python_startup.py").ensure(file=True) + assert installer.check() == "%s already exists" % f + monkeypatch.setenv("PYTHONSTARTUP", "foo") + assert installer.check() == "PYTHONSTARTUP already defined: foo" + + def test_install(self, monkeypatch, tmpdir): + installer = MyInstaller(str(tmpdir), force=False) + monkeypatch.setenv("PYTHONSTARTUP", "") + assert installer.install() + assert "fancycompleter" in tmpdir.join("python_startup.py").read() + assert installer.env_var == 1 + # + # the second time, it fails because the file already exists + assert not installer.install() + assert installer.env_var == 1 + # + # the third time, it succeeds because we set force + installer.force = True + assert installer.install() + assert installer.env_var == 2 + + +class TestLazyVersion(object): + class MyLazyVersion(LazyVersion): + __count = 0 + + def _load_version(self): + assert self.__count == 0 + self.__count += 1 + return "0.1" + + def test_lazy_version(self): + ver = self.MyLazyVersion("foo") + assert repr(ver) == "0.1" + assert str(ver) == "0.1" + assert ver == "0.1" + assert not ver != "0.1" diff --git a/tox.ini b/tox.ini index 41830fe..2c17afe 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{27,34,35,36,37,38,py,py3}, checkqa +envlist = py{38,39,310,311,py3}, checkqa [testenv] deps = @@ -11,15 +11,15 @@ setenv = [testenv:checkqa] deps = - flake8 -commands = flake8 setup.py fancycompleter.py testing + ruff +commands = ruff check setup.py fancycompleter.py tests [pytest] addopts = -ra -testpaths = testing +testpaths = tests [coverage:run] -include = */fancycompleter.py, testing/* +include = */fancycompleter.py, tests/* parallel = 1 branch = 1 @@ -28,6 +28,3 @@ source = . */lib/python*/site-packages/ */pypy*/site-packages/ *\Lib\site-packages\ - -[flake8] -max-line-length = 88