diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index a55ee36c..d47929d4 100644 --- a/.github/workflows/build.yaml +++ b/.github/workflows/build.yaml @@ -4,51 +4,51 @@ on: push: pull_request: schedule: - # run at 7:00 on the first of every month - - cron: '0 7 1 * *' + # run at 7:00 on the first of every month + - cron: "0 7 1 * *" jobs: build: runs-on: ubuntu-latest - continue-on-error: ${{ matrix.python-version == 'pypy-3.8' }} + continue-on-error: ${{ matrix.python-version == 'pypy-3.9' }} strategy: fail-fast: false matrix: python-version: - - "3.8" - - "3.9" - - "3.10" - - "3.11" - - "pypy-3.8" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + - "pypy-3.10" steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install urwid twisted watchdog "jedi >=0.16" babel "sphinx >=1.5,<7" - pip install pytest pytest-cov numpy - - name: Build with Python ${{ matrix.python-version }} - run: | - python setup.py build - - name: Build documentation - run: | - python setup.py build_sphinx - python setup.py build_sphinx_man - - name: Test with pytest - run: | - pytest --cov=bpython --cov-report=xml -v - - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 - env: - PYTHON_VERSION: ${{ matrix.python-version }} - with: - file: ./coverage.xml - env_vars: PYTHON_VERSION - if: ${{ always() }} + - uses: actions/checkout@v5 + with: + fetch-depth: 0 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + pip install "urwid < 3.0" twisted watchdog "jedi >=0.16" babel "sphinx >=1.5" + pip install pytest pytest-cov numpy + - name: Build with Python ${{ matrix.python-version }} + run: | + python setup.py build + - name: Build documentation + run: | + python setup.py build_sphinx + python setup.py build_sphinx_man + - name: Test with pytest + run: | + pytest --cov=bpython --cov-report=xml -v + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + env: + PYTHON_VERSION: ${{ matrix.python-version }} + with: + file: ./coverage.xml + env_vars: PYTHON_VERSION + if: ${{ always() }} diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml index d02dd2df..ec4c156f 100644 --- a/.github/workflows/lint.yaml +++ b/.github/workflows/lint.yaml @@ -8,38 +8,38 @@ jobs: black: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install black codespell - - name: Check with black - run: black --check . + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v5 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install black codespell + - name: Check with black + run: black --check . codespell: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: codespell-project/actions-codespell@master - with: - skip: '*.po' - ignore_words_list: ba,te,deltion,dedent,dedented + - uses: actions/checkout@v5 + - uses: codespell-project/actions-codespell@master + with: + skip: "*.po,encoding_latin1.py,test_repl.py" + ignore_words_list: ba,te,deltion,dedent,dedented,assertIn mypy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v4 - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install mypy - pip install -r requirements.txt - pip install urwid twisted watchdog "jedi >=0.16" babel "sphinx >=1.5" numpy - pip install types-backports types-requests types-setuptools types-toml types-pygments - - name: Check with mypy - # for now only run on a few files to avoid slipping backward - run: mypy + - uses: actions/checkout@v5 + - name: Set up Python + uses: actions/setup-python@v5 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy + pip install -r requirements.txt + pip install urwid twisted watchdog "jedi >=0.16" babel "sphinx >=1.5" numpy + pip install types-backports types-requests types-setuptools types-toml types-pygments + - name: Check with mypy + # for now only run on a few files to avoid slipping backward + run: mypy diff --git a/CHANGELOG.rst b/CHANGELOG.rst index d7ecb3ab..f55fe76f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,23 +1,50 @@ Changelog ========= +0.26 +---- + +General information: + + +New features: + + +Fixes: + + +Changes to dependencies: + + + 0.25 ---- General information: -* The bpython-cli rendering backend has been removed following deprecation in +* The `bpython-cli` rendering backend has been removed following deprecation in version 0.19. - +* This release is focused on Python 3.13 support. New features: Fixes: +* Fix __signature__ support + Thanks to gpotter2 +* #995: Fix handling of `SystemExit` +* #996: Improve order of completion results + Thanks to gpotter2 +* Fix build of documentation and manpages with Sphinx >= 7 +* #1001: Do not fail if modules don't have __version__ Changes to dependencies: +* Remove use of distutils + Thanks to Anderson Bravalheri + +Support for Python 3.12 and 3.13 has been added. Support for Python 3.7 and 3.8 has been dropped. 0.24 ---- @@ -37,7 +64,7 @@ Fixes: Changes to dependencies: -* wheel is no required as part of pyproject.toml's build dependencies +* wheel is not required as part of pyproject.toml's build dependencies Support for Python 3.11 has been added. diff --git a/LICENSE b/LICENSE index 72d02ff6..46f642f2 100644 --- a/LICENSE +++ b/LICENSE @@ -72,3 +72,31 @@ products or services of Licensee, or any third party. 8. By copying, installing or otherwise using Python, Licensee agrees to be bound by the terms and conditions of this License Agreement. + + +BuildDoc in setup.py is licensed under the BSD-2 license: + +Copyright 2007-2021 Sebastian Wiesner + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/bpdb/debugger.py b/bpdb/debugger.py index 3e5bbc91..38469541 100644 --- a/bpdb/debugger.py +++ b/bpdb/debugger.py @@ -27,24 +27,24 @@ class BPdb(pdb.Pdb): """PDB with BPython support.""" - def __init__(self, *args, **kwargs): + def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.prompt = "(BPdb) " self.intro = 'Use "B" to enter bpython, Ctrl-d to exit it.' - def postloop(self): + def postloop(self) -> None: # We only want to show the intro message once. self.intro = None super().postloop() # cmd.Cmd commands - def do_Bpython(self, arg): + def do_Bpython(self, arg: str) -> None: locals_ = self.curframe.f_globals.copy() locals_.update(self.curframe.f_locals) bpython.embed(locals_, ["-i"]) - def help_Bpython(self): + def help_Bpython(self) -> None: print("B(python)") print("") print( diff --git a/bpython/__init__.py b/bpython/__init__.py index dff06c0f..26fa3e63 100644 --- a/bpython/__init__.py +++ b/bpython/__init__.py @@ -21,6 +21,7 @@ # THE SOFTWARE. import os.path +from typing import Any try: from ._version import __version__ as version # type: ignore @@ -30,13 +31,13 @@ __author__ = ( "Bob Farrell, Andreas Stuehrk, Sebastian Ramacher, Thomas Ballinger, et al." ) -__copyright__ = f"(C) 2008-2023 {__author__}" +__copyright__ = f"(C) 2008-2024 {__author__}" __license__ = "MIT" __version__ = version package_dir = os.path.abspath(os.path.dirname(__file__)) -def embed(locals_=None, args=None, banner=None): +def embed(locals_=None, args=None, banner=None) -> Any: if args is None: args = ["-i", "-q"] diff --git a/bpython/_typing_compat.py b/bpython/_typing_compat.py new file mode 100644 index 00000000..5d9a3607 --- /dev/null +++ b/bpython/_typing_compat.py @@ -0,0 +1,27 @@ +# The MIT License +# +# Copyright (c) 2024 Sebastian Ramacher +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in +# all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +# THE SOFTWARE. + +try: + # introduced in Python 3.11 + from typing import Never +except ImportError: + from typing_extensions import Never # type: ignore diff --git a/bpython/args.py b/bpython/args.py index d1037e1c..35fd3e7b 100644 --- a/bpython/args.py +++ b/bpython/args.py @@ -36,11 +36,14 @@ import os import sys from pathlib import Path -from typing import Tuple, List, Optional, NoReturn, Callable +from typing import Tuple, List, Optional +from collections.abc import Callable +from types import ModuleType from . import __version__, __copyright__ from .config import default_config_path, Config from .translations import _ +from ._typing_compat import Never logger = logging.getLogger(__name__) @@ -50,7 +53,7 @@ class ArgumentParserFailed(ValueError): class RaisingArgumentParser(argparse.ArgumentParser): - def error(self, msg: str) -> NoReturn: + def error(self, msg: str) -> Never: raise ArgumentParserFailed() @@ -67,14 +70,18 @@ def copyright_banner() -> str: return _("{} See AUTHORS.rst for details.").format(__copyright__) -Options = Tuple[str, str, Callable[[argparse._ArgumentGroup], None]] +def log_version(module: ModuleType, name: str) -> None: + logger.info("%s: %s", name, module.__version__ if hasattr(module, "__version__") else "unknown version") # type: ignore + + +Options = tuple[str, str, Callable[[argparse._ArgumentGroup], None]] def parse( - args: Optional[List[str]], - extras: Optional[Options] = None, + args: list[str] | None, + extras: Options | None = None, ignore_stdin: bool = False, -) -> Tuple[Config, argparse.Namespace, List[str]]: +) -> tuple[Config, argparse.Namespace, list[str]]: """Receive an argument list - if None, use sys.argv - parse all args and take appropriate action. Also receive optional extra argument: this should be a tuple of (title, description, callback) @@ -211,27 +218,27 @@ def callback(group): try: import curtsies - logger.info("curtsies: %s", curtsies.__version__) + log_version(curtsies, "curtsies") except ImportError: # may happen on Windows logger.info("curtsies: not available") - logger.info("cwcwidth: %s", cwcwidth.__version__) - logger.info("greenlet: %s", greenlet.__version__) - logger.info("pygments: %s", pygments.__version__) # type: ignore - logger.info("pyxdg: %s", xdg.__version__) # type: ignore - logger.info("requests: %s", requests.__version__) + log_version(cwcwidth, "cwcwidth") + log_version(greenlet, "greenlet") + log_version(pygments, "pygments") + log_version(xdg, "pyxdg") + log_version(requests, "requests") # versions of optional dependencies try: import pyperclip - logger.info("pyperclip: %s", pyperclip.__version__) # type: ignore + log_version(pyperclip, "pyperclip") except ImportError: logger.info("pyperclip: not available") try: import jedi - logger.info("jedi: %s", jedi.__version__) + log_version(jedi, "jedi") except ImportError: logger.info("jedi: not available") try: @@ -250,7 +257,7 @@ def callback(group): def exec_code( - interpreter: code.InteractiveInterpreter, args: List[str] + interpreter: code.InteractiveInterpreter, args: list[str] ) -> None: """ Helper to execute code in a given interpreter, e.g. to implement the behavior of python3 [-i] file.py diff --git a/bpython/autocomplete.py b/bpython/autocomplete.py index 000fbde9..4fb62f72 100644 --- a/bpython/autocomplete.py +++ b/bpython/autocomplete.py @@ -40,13 +40,13 @@ from typing import ( Any, Dict, - Iterator, List, Optional, - Sequence, Set, Tuple, ) +from collections.abc import Iterator, Sequence + from . import inspection from . import line as lineparts from .line import LinePart @@ -236,7 +236,7 @@ def __init__( @abc.abstractmethod def matches( self, cursor_offset: int, line: str, **kwargs: Any - ) -> Optional[Set[str]]: + ) -> set[str] | None: """Returns a list of possible matches given a line and cursor, or None if this completion type isn't applicable. @@ -255,7 +255,7 @@ def matches( raise NotImplementedError @abc.abstractmethod - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: """Returns a Linepart namedtuple instance or None given cursor and line A Linepart namedtuple contains a start, stop, and word. None is @@ -268,7 +268,7 @@ def format(self, word: str) -> str: def substitute( self, cursor_offset: int, line: str, match: str - ) -> Tuple[int, str]: + ) -> tuple[int, str]: """Returns a cursor offset and line with match swapped in""" lpart = self.locate(cursor_offset, line) assert lpart @@ -299,7 +299,7 @@ def __init__( super().__init__(True, mode) - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: for completer in self._completers: return_value = completer.locate(cursor_offset, line) if return_value is not None: @@ -311,7 +311,7 @@ def format(self, word: str) -> str: def matches( self, cursor_offset: int, line: str, **kwargs: Any - ) -> Optional[Set[str]]: + ) -> set[str] | None: return_value = None all_matches = set() for completer in self._completers: @@ -336,10 +336,10 @@ def __init__( def matches( self, cursor_offset: int, line: str, **kwargs: Any - ) -> Optional[Set[str]]: + ) -> set[str] | None: return self.module_gatherer.complete(cursor_offset, line) - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_word(cursor_offset, line) def format(self, word: str) -> str: @@ -356,7 +356,7 @@ def __init__(self, mode: AutocompleteModes = AutocompleteModes.SIMPLE): def matches( self, cursor_offset: int, line: str, **kwargs: Any - ) -> Optional[Set[str]]: + ) -> set[str] | None: cs = lineparts.current_string(cursor_offset, line) if cs is None: return None @@ -371,7 +371,7 @@ def matches( matches.add(filename) return matches - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_string(cursor_offset, line) def format(self, filename: str) -> str: @@ -389,9 +389,9 @@ def matches( cursor_offset: int, line: str, *, - locals_: Optional[Dict[str, Any]] = None, + locals_: dict[str, Any] | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: r = self.locate(cursor_offset, line) if r is None: return None @@ -414,14 +414,14 @@ def matches( if _few_enough_underscores(r.word.split(".")[-1], m.split(".")[-1]) } - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_dotted_attribute(cursor_offset, line) def format(self, word: str) -> str: return _after_last_dot(word) def attr_matches( - self, text: str, namespace: Dict[str, Any] + self, text: str, namespace: dict[str, Any] ) -> Iterator[str]: """Taken from rlcompleter.py and bent to my will.""" @@ -460,7 +460,7 @@ def attr_lookup(self, obj: Any, expr: str, attr: str) -> Iterator[str]: if self.method_match(word, n, attr) and word != "__builtins__" ) - def list_attributes(self, obj: Any) -> List[str]: + def list_attributes(self, obj: Any) -> list[str]: # TODO: re-implement dir without AttrCleaner here # # Note: accessing `obj.__dir__` via `getattr_static` is not side-effect free. @@ -474,9 +474,9 @@ def matches( cursor_offset: int, line: str, *, - locals_: Optional[Dict[str, Any]] = None, + locals_: dict[str, Any] | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: if locals_ is None: return None @@ -500,7 +500,7 @@ def matches( else: return None - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_dict_key(cursor_offset, line) def format(self, match: str) -> str: @@ -513,10 +513,10 @@ def matches( cursor_offset: int, line: str, *, - current_block: Optional[str] = None, - complete_magic_methods: Optional[bool] = None, + current_block: str | None = None, + complete_magic_methods: bool | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: if ( current_block is None or complete_magic_methods is None @@ -531,7 +531,7 @@ def matches( return None return {name for name in MAGIC_METHODS if name.startswith(r.word)} - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_method_definition_name(cursor_offset, line) @@ -541,9 +541,9 @@ def matches( cursor_offset: int, line: str, *, - locals_: Optional[Dict[str, Any]] = None, + locals_: dict[str, Any] | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: """Compute matches when text is a simple name. Return a list of all keywords, built-in functions and names currently defined in self.namespace that match. @@ -571,7 +571,7 @@ def matches( matches.add(_callable_postfix(val, word)) return matches if matches else None - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_single_word(cursor_offset, line) @@ -581,9 +581,9 @@ def matches( cursor_offset: int, line: str, *, - funcprops: Optional[inspection.FuncProps] = None, + funcprops: inspection.FuncProps | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: if funcprops is None: return None @@ -603,7 +603,7 @@ def matches( ) return matches if matches else None - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: r = lineparts.current_word(cursor_offset, line) if r and r.word[-1] == "(": # if the word ends with a (, it's the parent word with an empty @@ -614,7 +614,7 @@ def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: class ExpressionAttributeCompletion(AttrCompletion): # could replace attr completion as a more general case with some work - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return lineparts.current_expression_attribute(cursor_offset, line) def matches( @@ -622,9 +622,9 @@ def matches( cursor_offset: int, line: str, *, - locals_: Optional[Dict[str, Any]] = None, + locals_: dict[str, Any] | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: if locals_ is None: locals_ = __main__.__dict__ @@ -648,26 +648,26 @@ def matches( class MultilineJediCompletion(BaseCompletionType): # type: ignore [no-redef] def matches( self, cursor_offset: int, line: str, **kwargs: Any - ) -> Optional[Set[str]]: + ) -> set[str] | None: return None - def locate(self, cursor_offset: int, line: str) -> Optional[LinePart]: + def locate(self, cursor_offset: int, line: str) -> LinePart | None: return None else: class MultilineJediCompletion(BaseCompletionType): # type: ignore [no-redef] - _orig_start: Optional[int] + _orig_start: int | None def matches( self, cursor_offset: int, line: str, *, - current_block: Optional[str] = None, - history: Optional[List[str]] = None, + current_block: str | None = None, + history: list[str] | None = None, **kwargs: Any, - ) -> Optional[Set[str]]: + ) -> set[str] | None: if ( current_block is None or history is None @@ -725,12 +725,12 @@ def get_completer( cursor_offset: int, line: str, *, - locals_: Optional[Dict[str, Any]] = None, - argspec: Optional[inspection.FuncProps] = None, - history: Optional[List[str]] = None, - current_block: Optional[str] = None, - complete_magic_methods: Optional[bool] = None, -) -> Tuple[List[str], Optional[BaseCompletionType]]: + locals_: dict[str, Any] | None = None, + argspec: inspection.FuncProps | None = None, + history: list[str] | None = None, + current_block: str | None = None, + complete_magic_methods: bool | None = None, +) -> tuple[list[str], BaseCompletionType | None]: """Returns a list of matches and an applicable completer If no matches available, returns a tuple of an empty list and None @@ -747,7 +747,7 @@ def get_completer( double underscore methods like __len__ in method signatures """ - def _cmpl_sort(x: str) -> Tuple[bool, str]: + def _cmpl_sort(x: str) -> tuple[bool, str]: """ Function used to sort the matches. """ @@ -784,7 +784,7 @@ def _cmpl_sort(x: str) -> Tuple[bool, str]: def get_default_completer( mode: AutocompleteModes, module_gatherer: ModuleGatherer -) -> Tuple[BaseCompletionType, ...]: +) -> tuple[BaseCompletionType, ...]: return ( ( DictKeyCompletion(mode=mode), diff --git a/bpython/config.py b/bpython/config.py index 5123ec22..27af8740 100644 --- a/bpython/config.py +++ b/bpython/config.py @@ -31,7 +31,8 @@ from configparser import ConfigParser from itertools import chain from pathlib import Path -from typing import MutableMapping, Mapping, Any, Dict +from typing import Any, Dict +from collections.abc import MutableMapping, Mapping from xdg import BaseDirectory from .autocomplete import AutocompleteModes @@ -115,7 +116,7 @@ class Config: "right_arrow_suggestion": "K", } - defaults: Dict[str, Dict[str, Any]] = { + defaults: dict[str, dict[str, Any]] = { "general": { "arg_spec": True, "auto_display_list": True, diff --git a/bpython/curtsies.py b/bpython/curtsies.py index 6dc8d1f7..b57e47a9 100644 --- a/bpython/curtsies.py +++ b/bpython/curtsies.py @@ -23,40 +23,35 @@ from typing import ( Any, - Callable, Dict, - Generator, List, Optional, Protocol, - Sequence, Tuple, Union, ) +from collections.abc import Callable, Generator, Sequence logger = logging.getLogger(__name__) class SupportsEventGeneration(Protocol): def send( - self, timeout: Optional[float] - ) -> Union[str, curtsies.events.Event, None]: - ... + self, timeout: float | None + ) -> str | curtsies.events.Event | None: ... - def __iter__(self) -> "SupportsEventGeneration": - ... + def __iter__(self) -> "SupportsEventGeneration": ... - def __next__(self) -> Union[str, curtsies.events.Event, None]: - ... + def __next__(self) -> str | curtsies.events.Event | None: ... class FullCurtsiesRepl(BaseRepl): def __init__( self, config: Config, - locals_: Optional[Dict[str, Any]] = None, - banner: Optional[str] = None, - interp: Optional[Interp] = None, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + interp: Interp | None = None, ) -> None: self.input_generator = curtsies.input.Input( keynames="curtsies", sigint_event=True, paste_threshold=None @@ -69,9 +64,9 @@ def __init__( extra_bytes_callback=self.input_generator.unget_bytes, ) - self._request_refresh_callback: Callable[ - [], None - ] = self.input_generator.event_trigger(events.RefreshRequestEvent) + self._request_refresh_callback: Callable[[], None] = ( + self.input_generator.event_trigger(events.RefreshRequestEvent) + ) self._schedule_refresh_callback = ( self.input_generator.scheduled_event_trigger( events.ScheduledRefreshRequestEvent @@ -114,7 +109,7 @@ def interrupting_refresh(self) -> None: def request_undo(self, n: int = 1) -> None: return self._request_undo_callback(n=n) - def get_term_hw(self) -> Tuple[int, int]: + def get_term_hw(self) -> tuple[int, int]: return self.window.get_term_hw() def get_cursor_vertical_diff(self) -> int: @@ -133,7 +128,7 @@ def after_suspend(self) -> None: self.interrupting_refresh() def process_event_and_paint( - self, e: Union[str, curtsies.events.Event, None] + self, e: str | curtsies.events.Event | None ) -> None: """If None is passed in, just paint the screen""" try: @@ -155,7 +150,7 @@ def process_event_and_paint( def mainloop( self, interactive: bool = True, - paste: Optional[curtsies.events.PasteEvent] = None, + paste: curtsies.events.PasteEvent | None = None, ) -> None: if interactive: # Add custom help command @@ -182,10 +177,10 @@ def mainloop( def main( - args: Optional[List[str]] = None, - locals_: Optional[Dict[str, Any]] = None, - banner: Optional[str] = None, - welcome_message: Optional[str] = None, + args: list[str] | None = None, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + welcome_message: str | None = None, ) -> Any: """ banner is displayed directly after the version information. @@ -212,7 +207,7 @@ def curtsies_arguments(parser: argparse._ArgumentGroup) -> None: interp = None paste = None - exit_value: Tuple[Any, ...] = () + exit_value: tuple[Any, ...] = () if exec_args: if not options: raise ValueError("don't pass in exec_args without options") @@ -253,7 +248,7 @@ def curtsies_arguments(parser: argparse._ArgumentGroup) -> None: def _combined_events( event_provider: SupportsEventGeneration, paste_threshold: int -) -> Generator[Union[str, curtsies.events.Event, None], Optional[float], None]: +) -> Generator[str | curtsies.events.Event | None, float | None, None]: """Combines consecutive keypress events into paste events.""" timeout = yield "nonsense_event" # so send can be used immediately queue: collections.deque = collections.deque() diff --git a/bpython/curtsiesfrontend/_internal.py b/bpython/curtsiesfrontend/_internal.py index 0480c1b0..8c070b34 100644 --- a/bpython/curtsiesfrontend/_internal.py +++ b/bpython/curtsiesfrontend/_internal.py @@ -34,9 +34,9 @@ def __enter__(self): def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: pydoc.pager = self._orig_pager return False @@ -52,8 +52,8 @@ def __init__(self, repl=None): super().__init__() - def pager(self, output): - self._repl.pager(output) + def pager(self, output, title=""): + self._repl.pager(output, title) def __call__(self, *args, **kwargs): if self._repl.reevaluating: diff --git a/bpython/curtsiesfrontend/events.py b/bpython/curtsiesfrontend/events.py index 26f105dc..4f9c13e5 100644 --- a/bpython/curtsiesfrontend/events.py +++ b/bpython/curtsiesfrontend/events.py @@ -1,7 +1,7 @@ """Non-keyboard events used in bpython curtsies REPL""" import time -from typing import Sequence +from collections.abc import Sequence import curtsies.events diff --git a/bpython/curtsiesfrontend/filewatch.py b/bpython/curtsiesfrontend/filewatch.py index e70325ab..2822db6d 100644 --- a/bpython/curtsiesfrontend/filewatch.py +++ b/bpython/curtsiesfrontend/filewatch.py @@ -1,6 +1,7 @@ import os from collections import defaultdict -from typing import Callable, Dict, Iterable, Sequence, Set, List +from typing import Dict, Set, List +from collections.abc import Callable, Iterable, Sequence from .. import importcompletion @@ -20,9 +21,9 @@ def __init__( paths: Iterable[str], on_change: Callable[[Sequence[str]], None], ) -> None: - self.dirs: Dict[str, Set[str]] = defaultdict(set) + self.dirs: dict[str, set[str]] = defaultdict(set) self.on_change = on_change - self.modules_to_add_later: List[str] = [] + self.modules_to_add_later: list[str] = [] self.observer = Observer() self.started = False self.activated = False diff --git a/bpython/curtsiesfrontend/interpreter.py b/bpython/curtsiesfrontend/interpreter.py index 82e28091..280c56ed 100644 --- a/bpython/curtsiesfrontend/interpreter.py +++ b/bpython/curtsiesfrontend/interpreter.py @@ -1,6 +1,7 @@ import sys from codeop import CommandCompiler -from typing import Any, Dict, Iterable, Optional, Tuple, Union +from typing import Any, Dict, Optional, Tuple, Union +from collections.abc import Iterable from pygments.token import Generic, Token, Keyword, Name, Comment, String from pygments.token import Error, Literal, Number, Operator, Punctuation @@ -47,8 +48,8 @@ class BPythonFormatter(Formatter): def __init__( self, - color_scheme: Dict[_TokenType, str], - **options: Union[str, bool, None], + color_scheme: dict[_TokenType, str], + **options: str | bool | None, ) -> None: self.f_strings = {k: f"\x01{v}" for k, v in color_scheme.items()} # FIXME: mypy currently fails to handle this properly @@ -67,7 +68,7 @@ def format(self, tokensource, outfile): class Interp(ReplInterpreter): def __init__( self, - locals: Optional[Dict[str, Any]] = None, + locals: dict[str, Any] | None = None, ) -> None: """Constructor. @@ -78,7 +79,7 @@ def __init__( # typically changed after being instantiated # but used when interpreter used corresponding REPL - def write(err_line: Union[str, FmtStr]) -> None: + def write(err_line: str | FmtStr) -> None: """Default stderr handler for tracebacks Accepts FmtStrs so interpreters can output them""" @@ -121,7 +122,7 @@ def format(self, tbtext: str, lexer: Any) -> None: def code_finished_will_parse( s: str, compiler: CommandCompiler -) -> Tuple[bool, bool]: +) -> tuple[bool, bool]: """Returns a tuple of whether the buffer could be complete and whether it will parse diff --git a/bpython/curtsiesfrontend/parse.py b/bpython/curtsiesfrontend/parse.py index 88a149a6..28b32e64 100644 --- a/bpython/curtsiesfrontend/parse.py +++ b/bpython/curtsiesfrontend/parse.py @@ -1,6 +1,7 @@ import re from functools import partial -from typing import Any, Callable, Dict, Tuple +from typing import Any, Dict, Tuple +from collections.abc import Callable from curtsies.formatstring import fmtstr, FmtStr from curtsies.termformatconstants import ( @@ -60,7 +61,7 @@ def parse(s: str) -> FmtStr: ) -def fs_from_match(d: Dict[str, Any]) -> FmtStr: +def fs_from_match(d: dict[str, Any]) -> FmtStr: atts = {} color = "default" if d["fg"]: @@ -99,7 +100,7 @@ def fs_from_match(d: Dict[str, Any]) -> FmtStr: ) -def peel_off_string(s: str) -> Tuple[Dict[str, Any], str]: +def peel_off_string(s: str) -> tuple[dict[str, Any], str]: m = peel_off_string_re.match(s) assert m, repr(s) d = m.groupdict() diff --git a/bpython/curtsiesfrontend/preprocess.py b/bpython/curtsiesfrontend/preprocess.py index 5e59dd49..f48a79bf 100644 --- a/bpython/curtsiesfrontend/preprocess.py +++ b/bpython/curtsiesfrontend/preprocess.py @@ -2,7 +2,7 @@ etc)""" from codeop import CommandCompiler -from typing import Match +from re import Match from itertools import tee, islice, chain from ..lazyre import LazyReCompile diff --git a/bpython/curtsiesfrontend/repl.py b/bpython/curtsiesfrontend/repl.py index e4819e19..2a304312 100644 --- a/bpython/curtsiesfrontend/repl.py +++ b/bpython/curtsiesfrontend/repl.py @@ -14,16 +14,15 @@ from types import FrameType, TracebackType from typing import ( Any, - Iterable, Dict, List, Literal, Optional, - Sequence, Tuple, Type, Union, ) +from collections.abc import Iterable, Sequence import greenlet from curtsies import ( @@ -113,7 +112,7 @@ def __init__( self, coderunner: CodeRunner, repl: "BaseRepl", - configured_edit_keys: Optional[AbstractEdits] = None, + configured_edit_keys: AbstractEdits | None = None, ): self.coderunner = coderunner self.repl = repl @@ -121,13 +120,13 @@ def __init__( self.current_line = "" self.cursor_offset = 0 self.old_num_lines = 0 - self.readline_results: List[str] = [] + self.readline_results: list[str] = [] if configured_edit_keys is not None: self.rl_char_sequences = configured_edit_keys else: self.rl_char_sequences = edit_keys - def process_event(self, e: Union[events.Event, str]) -> None: + def process_event(self, e: events.Event | str) -> None: assert self.has_focus logger.debug("fake input processing event %r", e) @@ -195,7 +194,7 @@ def readline(self, size: int = -1) -> str: self.readline_results.append(value) return value if size <= -1 else value[:size] - def readlines(self, size: Optional[int] = -1) -> List[str]: + def readlines(self, size: int | None = -1) -> list[str]: if size is None: # the default readlines implementation also accepts None size = -1 @@ -236,7 +235,8 @@ def close(self) -> None: @property def encoding(self) -> str: - return sys.__stdin__.encoding + # `encoding` is new in py39 + return sys.__stdin__.encoding # type: ignore # TODO write a read() method? @@ -337,10 +337,10 @@ def __init__( self, config: Config, window: CursorAwareWindow, - locals_: Optional[Dict[str, Any]] = None, - banner: Optional[str] = None, - interp: Optional[Interp] = None, - orig_tcattrs: Optional[List[Any]] = None, + locals_: dict[str, Any] | None = None, + banner: str | None = None, + interp: Interp | None = None, + orig_tcattrs: list[Any] | None = None, ): """ locals_ is a mapping of locals to pass into the interpreter @@ -398,12 +398,12 @@ def __init__( self._current_line = "" # current line of output - stdout and stdin go here - self.current_stdouterr_line: Union[str, FmtStr] = "" + self.current_stdouterr_line: str | FmtStr = "" # this is every line that's been displayed (input and output) # as with formatting applied. Logical lines that exceeded the terminal width # at the time of output are split across multiple entries in this list. - self.display_lines: List[FmtStr] = [] + self.display_lines: list[FmtStr] = [] # this is every line that's been executed; it gets smaller on rewind self.history = [] @@ -414,11 +414,11 @@ def __init__( # - the first element the line (string, not fmtsr) # - the second element is one of 2 global constants: "input" or "output" # (use LineType.INPUT or LineType.OUTPUT to avoid typing these strings) - self.all_logical_lines: List[Tuple[str, LineType]] = [] + self.all_logical_lines: list[tuple[str, LineType]] = [] # formatted version of lines in the buffer kept around so we can # unhighlight parens using self.reprint_line as called by bpython.Repl - self.display_buffer: List[FmtStr] = [] + self.display_buffer: list[FmtStr] = [] # how many times display has been scrolled down # because there wasn't room to display everything @@ -427,7 +427,7 @@ def __init__( # cursor position relative to start of current_line, 0 is first char self._cursor_offset = 0 - self.orig_tcattrs: Optional[List[Any]] = orig_tcattrs + self.orig_tcattrs: list[Any] | None = orig_tcattrs self.coderunner = CodeRunner(self.interp, self.request_refresh) @@ -459,7 +459,7 @@ def __init__( # some commands act differently based on the prev event # this list doesn't include instances of event.Event, # only keypress-type events (no refresh screen events etc.) - self.last_events: List[Optional[str]] = [None] * 50 + self.last_events: list[str | None] = [None] * 50 # displays prev events in a column on the right hand side self.presentation_mode = False @@ -600,9 +600,9 @@ def __enter__(self): def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: sys.stdin = self.orig_stdin sys.stdout = self.orig_stdout @@ -616,7 +616,7 @@ def __exit__( sys.meta_path = self.orig_meta_path return False - def sigwinch_handler(self, signum: int, frame: Optional[FrameType]) -> None: + def sigwinch_handler(self, signum: int, frame: FrameType | None) -> None: old_rows, old_columns = self.height, self.width self.height, self.width = self.get_term_hw() cursor_dy = self.get_cursor_vertical_diff() @@ -632,7 +632,7 @@ def sigwinch_handler(self, signum: int, frame: Optional[FrameType]) -> None: self.scroll_offset, ) - def sigtstp_handler(self, signum: int, frame: Optional[FrameType]) -> None: + def sigtstp_handler(self, signum: int, frame: FrameType | None) -> None: self.scroll_offset = len(self.lines_for_display) self.__exit__(None, None, None) self.on_suspend() @@ -647,7 +647,7 @@ def clean_up_current_line_for_exit(self): self.unhighlight_paren() # Event handling - def process_event(self, e: Union[events.Event, str]) -> Optional[bool]: + def process_event(self, e: events.Event | str) -> bool | None: """Returns True if shutting down, otherwise returns None. Mostly mutates state of Repl object""" @@ -660,7 +660,7 @@ def process_event(self, e: Union[events.Event, str]) -> Optional[bool]: self.process_key_event(e) return None - def process_control_event(self, e: events.Event) -> Optional[bool]: + def process_control_event(self, e: events.Event) -> bool | None: if isinstance(e, bpythonevents.ScheduledRefreshRequestEvent): # This is a scheduled refresh - it's really just a refresh (so nop) pass @@ -1560,7 +1560,7 @@ def paint( user_quit=False, try_preserve_history_height=30, min_infobox_height=5, - ) -> Tuple[FSArray, Tuple[int, int]]: + ) -> tuple[FSArray, tuple[int, int]]: """Returns an array of min_height or more rows and width columns, plus cursor position @@ -1801,9 +1801,11 @@ def move_screen_up(current_line_start_row): self.current_match, self.docstring, self.config, - self.matches_iter.completer.format - if self.matches_iter.completer - else None, + ( + self.matches_iter.completer.format + if self.matches_iter.completer + else None + ), ) if ( @@ -2098,10 +2100,10 @@ def focus_on_subprocess(self, args): finally: signal.signal(signal.SIGWINCH, prev_sigwinch_handler) - def pager(self, text: str) -> None: - """Runs an external pager on text + def pager(self, text: str, title: str = "") -> None: + """Runs an external pager on text""" - text must be a str""" + # TODO: make less handle title command = get_pager_command() with tempfile.NamedTemporaryFile() as tmp: tmp.write(text.encode(getpreferredencoding())) @@ -2232,9 +2234,7 @@ def compress_paste_event(paste_event): return None -def just_simple_events( - event_list: Iterable[Union[str, events.Event]] -) -> List[str]: +def just_simple_events(event_list: Iterable[str | events.Event]) -> list[str]: simple_events = [] for e in event_list: if isinstance(e, events.Event): @@ -2251,7 +2251,7 @@ def just_simple_events( return simple_events -def is_simple_event(e: Union[str, events.Event]) -> bool: +def is_simple_event(e: str | events.Event) -> bool: if isinstance(e, events.Event): return False return ( diff --git a/bpython/curtsiesfrontend/replpainter.py b/bpython/curtsiesfrontend/replpainter.py index 00675451..3b63ca4c 100644 --- a/bpython/curtsiesfrontend/replpainter.py +++ b/bpython/curtsiesfrontend/replpainter.py @@ -74,9 +74,11 @@ def matches_lines(rows, columns, matches, current, config, match_format): result = [ fmtstr(" ").join( - color(m.ljust(max_match_width)) - if m != current - else highlight_color(m.ljust(max_match_width)) + ( + color(m.ljust(max_match_width)) + if m != current + else highlight_color(m.ljust(max_match_width)) + ) for m in matches[i : i + words_wide] ) for i in range(0, len(matches), words_wide) diff --git a/bpython/filelock.py b/bpython/filelock.py index 11f575b6..b8eb11ff 100644 --- a/bpython/filelock.py +++ b/bpython/filelock.py @@ -56,9 +56,9 @@ def __enter__(self) -> "BaseLock": def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: if self.locked: self.release() @@ -122,7 +122,7 @@ def release(self) -> None: def FileLock( - fileobj: IO, mode: int = 0, filename: Optional[str] = None + fileobj: IO, mode: int = 0, filename: str | None = None ) -> BaseLock: if has_fcntl: return UnixFileLock(fileobj, mode) diff --git a/bpython/formatter.py b/bpython/formatter.py index f216f213..8e74ac2c 100644 --- a/bpython/formatter.py +++ b/bpython/formatter.py @@ -28,7 +28,8 @@ # mypy: disallow_untyped_calls=True -from typing import Any, MutableMapping, Iterable, TextIO +from typing import Any, TextIO +from collections.abc import MutableMapping, Iterable from pygments.formatter import Formatter from pygments.token import ( _TokenType, diff --git a/bpython/history.py b/bpython/history.py index 13dbb5b7..b58309b5 100644 --- a/bpython/history.py +++ b/bpython/history.py @@ -25,7 +25,8 @@ from pathlib import Path import stat from itertools import islice, chain -from typing import Iterable, Optional, List, TextIO +from typing import Optional, List, TextIO +from collections.abc import Iterable from .translations import _ from .filelock import FileLock @@ -36,7 +37,7 @@ class History: def __init__( self, - entries: Optional[Iterable[str]] = None, + entries: Iterable[str] | None = None, duplicates: bool = True, hist_size: int = 100, ) -> None: @@ -55,7 +56,7 @@ def __init__( def append(self, line: str) -> None: self.append_to(self.entries, line) - def append_to(self, entries: List[str], line: str) -> None: + def append_to(self, entries: list[str], line: str) -> None: line = line.rstrip("\n") if line: if not self.duplicates: @@ -77,7 +78,7 @@ def back( self, start: bool = True, search: bool = False, - target: Optional[str] = None, + target: str | None = None, include_current: bool = False, ) -> str: """Move one step back in the history.""" @@ -100,7 +101,7 @@ def entry(self) -> str: return self.entries[-self.index] if self.index else self.saved_line @property - def entries_by_index(self) -> List[str]: + def entries_by_index(self) -> list[str]: return list(chain((self.saved_line,), reversed(self.entries))) def find_match_backward( @@ -127,7 +128,7 @@ def forward( self, start: bool = True, search: bool = False, - target: Optional[str] = None, + target: str | None = None, include_current: bool = False, ) -> str: """Move one step forward in the history.""" @@ -196,8 +197,8 @@ def load(self, filename: Path, encoding: str) -> None: with FileLock(hfile, filename=str(filename)): self.entries = self.load_from(hfile) - def load_from(self, fd: TextIO) -> List[str]: - entries: List[str] = [] + def load_from(self, fd: TextIO) -> list[str]: + entries: list[str] = [] for line in fd: self.append_to(entries, line) return entries if len(entries) else [""] @@ -213,7 +214,7 @@ def save(self, filename: Path, encoding: str, lines: int = 0) -> None: self.save_to(hfile, self.entries, lines) def save_to( - self, fd: TextIO, entries: Optional[List[str]] = None, lines: int = 0 + self, fd: TextIO, entries: list[str] | None = None, lines: int = 0 ) -> None: if entries is None: entries = self.entries diff --git a/bpython/importcompletion.py b/bpython/importcompletion.py index 9df140c6..570996d4 100644 --- a/bpython/importcompletion.py +++ b/bpython/importcompletion.py @@ -27,7 +27,8 @@ import warnings from dataclasses import dataclass from pathlib import Path -from typing import Optional, Set, Generator, Sequence, Iterable, Union +from typing import Optional, Set, Union +from collections.abc import Generator, Sequence, Iterable from .line import ( current_word, @@ -62,16 +63,16 @@ class _LoadedInode: class ModuleGatherer: def __init__( self, - paths: Optional[Iterable[Union[str, Path]]] = None, - skiplist: Optional[Sequence[str]] = None, + paths: Iterable[str | Path] | None = None, + skiplist: Sequence[str] | None = None, ) -> None: """Initialize module gatherer with all modules in `paths`, which should be a list of directory names. If `paths` is not given, `sys.path` will be used.""" # Cached list of all known modules - self.modules: Set[str] = set() + self.modules: set[str] = set() # Set of (st_dev, st_ino) to compare against so that paths are not repeated - self.paths: Set[_LoadedInode] = set() + self.paths: set[_LoadedInode] = set() # Patterns to skip self.skiplist: Sequence[str] = ( skiplist if skiplist is not None else tuple() @@ -86,7 +87,7 @@ def __init__( Path(p).resolve() if p else Path.cwd() for p in paths ) - def module_matches(self, cw: str, prefix: str = "") -> Set[str]: + def module_matches(self, cw: str, prefix: str = "") -> set[str]: """Modules names to replace cw with""" full = f"{prefix}.{cw}" if prefix else cw @@ -102,7 +103,7 @@ def module_matches(self, cw: str, prefix: str = "") -> Set[str]: def attr_matches( self, cw: str, prefix: str = "", only_modules: bool = False - ) -> Set[str]: + ) -> set[str]: """Attributes to replace name with""" full = f"{prefix}.{cw}" if prefix else cw module_name, _, name_after_dot = full.rpartition(".") @@ -126,11 +127,11 @@ def attr_matches( return matches - def module_attr_matches(self, name: str) -> Set[str]: + def module_attr_matches(self, name: str) -> set[str]: """Only attributes which are modules to replace name with""" return self.attr_matches(name, only_modules=True) - def complete(self, cursor_offset: int, line: str) -> Optional[Set[str]]: + def complete(self, cursor_offset: int, line: str) -> set[str] | None: """Construct a full list of possibly completions for imports.""" tokens = line.split() if "from" not in tokens and "import" not in tokens: @@ -166,7 +167,7 @@ def complete(self, cursor_offset: int, line: str) -> Optional[Set[str]]: else: return None - def find_modules(self, path: Path) -> Generator[Optional[str], None, None]: + def find_modules(self, path: Path) -> Generator[str | None, None, None]: """Find all modules (and packages) for a given directory.""" if not path.is_dir(): # Perhaps a zip file diff --git a/bpython/inspection.py b/bpython/inspection.py index e97a272b..63f0e2d3 100644 --- a/bpython/inspection.py +++ b/bpython/inspection.py @@ -28,7 +28,6 @@ from dataclasses import dataclass from typing import ( Any, - Callable, Optional, Type, Dict, @@ -36,6 +35,7 @@ ContextManager, Literal, ) +from collections.abc import Callable from types import MemberDescriptorType, TracebackType from pygments.token import Token @@ -62,13 +62,13 @@ def __repr__(self) -> str: @dataclass class ArgSpec: - args: List[str] - varargs: Optional[str] - varkwargs: Optional[str] - defaults: Optional[List[_Repr]] - kwonly: List[str] - kwonly_defaults: Optional[Dict[str, _Repr]] - annotations: Optional[Dict[str, Any]] + args: list[str] + varargs: str | None + varkwargs: str | None + defaults: list[_Repr] | None + kwonly: list[str] + kwonly_defaults: dict[str, _Repr] | None + annotations: dict[str, Any] | None @dataclass @@ -118,9 +118,9 @@ def __enter__(self) -> None: def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: """Restore an object's magic methods.""" type_ = type(self._obj) @@ -134,10 +134,10 @@ def __exit__( return False -def parsekeywordpairs(signature: str) -> Dict[str, str]: +def parsekeywordpairs(signature: str) -> dict[str, str]: preamble = True stack = [] - substack: List[str] = [] + substack: list[str] = [] parendepth = 0 annotation = False for token, value in Python3Lexer().get_tokens(signature): @@ -224,7 +224,7 @@ def _fix_default_values(f: Callable, argspec: ArgSpec) -> ArgSpec: ) -def _getpydocspec(f: Callable) -> Optional[ArgSpec]: +def _getpydocspec(f: Callable) -> ArgSpec | None: try: argspec = pydoc.getdoc(f) except NameError: @@ -267,7 +267,7 @@ def _getpydocspec(f: Callable) -> Optional[ArgSpec]: ) -def getfuncprops(func: str, f: Callable) -> Optional[FuncProps]: +def getfuncprops(func: str, f: Callable) -> FuncProps | None: # Check if it's a real bound method or if it's implicitly calling __init__ # (i.e. FooClass(...) and not FooClass.__init__(...) -- the former would # not take 'self', the latter would: diff --git a/bpython/keys.py b/bpython/keys.py index cfcac86b..1068a4f2 100644 --- a/bpython/keys.py +++ b/bpython/keys.py @@ -28,7 +28,7 @@ class KeyMap(Generic[T]): def __init__(self, default: T) -> None: - self.map: Dict[str, T] = {} + self.map: dict[str, T] = {} self.default = default def __getitem__(self, key: str) -> T: @@ -42,14 +42,14 @@ def __getitem__(self, key: str) -> T: f"Configured keymap ({key}) does not exist in bpython.keys" ) - def __delitem__(self, key: str): + def __delitem__(self, key: str) -> None: del self.map[key] - def __setitem__(self, key: str, value: T): + def __setitem__(self, key: str, value: T) -> None: self.map[key] = value -cli_key_dispatch: KeyMap[Tuple[str, ...]] = KeyMap(tuple()) +cli_key_dispatch: KeyMap[tuple[str, ...]] = KeyMap(tuple()) urwid_key_dispatch = KeyMap("") # fill dispatch with letters diff --git a/bpython/lazyre.py b/bpython/lazyre.py index 0ca5b9ff..1d903616 100644 --- a/bpython/lazyre.py +++ b/bpython/lazyre.py @@ -21,12 +21,10 @@ # THE SOFTWARE. import re -from typing import Optional, Pattern, Match, Optional - -try: - from functools import cached_property -except ImportError: - from backports.cached_property import cached_property # type: ignore [no-redef] +from collections.abc import Iterator +from functools import cached_property +from re import Pattern, Match +from typing import Optional, Optional class LazyReCompile: @@ -43,13 +41,13 @@ def __init__(self, regex: str, flags: int = 0) -> None: def compiled(self) -> Pattern[str]: return re.compile(self.regex, self.flags) - def finditer(self, *args, **kwargs): + def finditer(self, *args, **kwargs) -> Iterator[Match[str]]: return self.compiled.finditer(*args, **kwargs) - def search(self, *args, **kwargs) -> Optional[Match[str]]: + def search(self, *args, **kwargs) -> Match[str] | None: return self.compiled.search(*args, **kwargs) - def match(self, *args, **kwargs) -> Optional[Match[str]]: + def match(self, *args, **kwargs) -> Match[str] | None: return self.compiled.match(*args, **kwargs) def sub(self, *args, **kwargs) -> str: diff --git a/bpython/line.py b/bpython/line.py index cbc3bf37..e64b20d9 100644 --- a/bpython/line.py +++ b/bpython/line.py @@ -24,7 +24,7 @@ class LinePart: CHARACTER_PAIR_MAP = {"(": ")", "{": "}", "[": "]", "'": "'", '"': '"'} -def current_word(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_word(cursor_offset: int, line: str) -> LinePart | None: """the object.attribute.attribute just before or under the cursor""" start = cursor_offset end = cursor_offset @@ -76,7 +76,7 @@ def current_word(cursor_offset: int, line: str) -> Optional[LinePart]: ) -def current_dict_key(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_dict_key(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the current key""" for m in _current_dict_key_re.finditer(line): if m.start(1) <= cursor_offset <= m.end(1): @@ -96,7 +96,7 @@ def current_dict_key(cursor_offset: int, line: str) -> Optional[LinePart]: ) -def current_dict(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_dict(cursor_offset: int, line: str) -> LinePart | None: """If in dictionary completion, return the dict that should be used""" for m in _current_dict_re.finditer(line): if m.start(2) <= cursor_offset <= m.end(2): @@ -110,7 +110,7 @@ def current_dict(cursor_offset: int, line: str) -> Optional[LinePart]: ) -def current_string(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_string(cursor_offset: int, line: str) -> LinePart | None: """If inside a string of nonzero length, return the string (excluding quotes) @@ -126,7 +126,7 @@ def current_string(cursor_offset: int, line: str) -> Optional[LinePart]: _current_object_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]") -def current_object(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_object(cursor_offset: int, line: str) -> LinePart | None: """If in attribute completion, the object on which attribute should be looked up.""" match = current_word(cursor_offset, line) @@ -145,9 +145,7 @@ def current_object(cursor_offset: int, line: str) -> Optional[LinePart]: _current_object_attribute_re = LazyReCompile(r"([\w_][\w0-9_]*)[.]?") -def current_object_attribute( - cursor_offset: int, line: str -) -> Optional[LinePart]: +def current_object_attribute(cursor_offset: int, line: str) -> LinePart | None: """If in attribute completion, the attribute being completed""" # TODO replace with more general current_expression_attribute match = current_word(cursor_offset, line) @@ -168,9 +166,7 @@ def current_object_attribute( ) -def current_from_import_from( - cursor_offset: int, line: str -) -> Optional[LinePart]: +def current_from_import_from(cursor_offset: int, line: str) -> LinePart | None: """If in from import completion, the word after from returns None if cursor not in or just after one of the two interesting @@ -194,7 +190,7 @@ def current_from_import_from( def current_from_import_import( cursor_offset: int, line: str -) -> Optional[LinePart]: +) -> LinePart | None: """If in from import completion, the word after import being completed returns None if cursor not in or just after one of these words @@ -221,7 +217,7 @@ def current_from_import_import( _current_import_re_3 = LazyReCompile(r"[,][ ]*([\w0-9_.]*)") -def current_import(cursor_offset: int, line: str) -> Optional[LinePart]: +def current_import(cursor_offset: int, line: str) -> LinePart | None: # TODO allow for multiple as's baseline = _current_import_re_1.search(line) if baseline is None: @@ -244,7 +240,7 @@ def current_import(cursor_offset: int, line: str) -> Optional[LinePart]: def current_method_definition_name( cursor_offset: int, line: str -) -> Optional[LinePart]: +) -> LinePart | None: """The name of a method being defined""" for m in _current_method_definition_name_re.finditer(line): if m.start(1) <= cursor_offset <= m.end(1): @@ -255,7 +251,7 @@ def current_method_definition_name( _current_single_word_re = LazyReCompile(r"(? Optional[LinePart]: +def current_single_word(cursor_offset: int, line: str) -> LinePart | None: """the un-dotted word just before or under the cursor""" for m in _current_single_word_re.finditer(line): if m.start(1) <= cursor_offset <= m.end(1): @@ -263,9 +259,7 @@ def current_single_word(cursor_offset: int, line: str) -> Optional[LinePart]: return None -def current_dotted_attribute( - cursor_offset: int, line: str -) -> Optional[LinePart]: +def current_dotted_attribute(cursor_offset: int, line: str) -> LinePart | None: """The dotted attribute-object pair before the cursor""" match = current_word(cursor_offset, line) if match is not None and "." in match.word[1:]: @@ -280,7 +274,7 @@ def current_dotted_attribute( def current_expression_attribute( cursor_offset: int, line: str -) -> Optional[LinePart]: +) -> LinePart | None: """If after a dot, the attribute being completed""" # TODO replace with more general current_expression_attribute for m in _current_expression_attribute_re.finditer(line): @@ -290,8 +284,8 @@ def current_expression_attribute( def cursor_on_closing_char_pair( - cursor_offset: int, line: str, ch: Optional[str] = None -) -> Tuple[bool, bool]: + cursor_offset: int, line: str, ch: str | None = None +) -> tuple[bool, bool]: """Checks if cursor sits on closing character of a pair and whether its pair character is directly behind it """ diff --git a/bpython/pager.py b/bpython/pager.py index e145e0ed..2fa4846e 100644 --- a/bpython/pager.py +++ b/bpython/pager.py @@ -33,7 +33,7 @@ from typing import List -def get_pager_command(default: str = "less -rf") -> List[str]: +def get_pager_command(default: str = "less -rf") -> list[str]: command = shlex.split(os.environ.get("PAGER", default)) return command @@ -55,7 +55,8 @@ def page(data: str, use_internal: bool = False) -> None: try: popen = subprocess.Popen(command, stdin=subprocess.PIPE) assert popen.stdin is not None - data_bytes = data.encode(sys.__stdout__.encoding, "replace") + # `encoding` is new in py39 + data_bytes = data.encode(sys.__stdout__.encoding, "replace") # type: ignore popen.stdin.write(data_bytes) popen.stdin.close() except OSError as e: diff --git a/bpython/paste.py b/bpython/paste.py index fd140a0e..8ca6f2df 100644 --- a/bpython/paste.py +++ b/bpython/paste.py @@ -37,8 +37,7 @@ class PasteFailed(Exception): class Paster(Protocol): - def paste(self, s: str) -> Tuple[str, Optional[str]]: - ... + def paste(self, s: str) -> tuple[str, str | None]: ... class PastePinnwand: @@ -46,7 +45,7 @@ def __init__(self, url: str, expiry: str) -> None: self.url = url self.expiry = expiry - def paste(self, s: str) -> Tuple[str, str]: + def paste(self, s: str) -> tuple[str, str]: """Upload to pastebin via json interface.""" url = urljoin(self.url, "/api/v1/paste") @@ -73,7 +72,7 @@ class PasteHelper: def __init__(self, executable: str) -> None: self.executable = executable - def paste(self, s: str) -> Tuple[str, None]: + def paste(self, s: str) -> tuple[str, None]: """Call out to helper program for pastebin upload.""" try: diff --git a/bpython/patch_linecache.py b/bpython/patch_linecache.py index d91392d2..68787e70 100644 --- a/bpython/patch_linecache.py +++ b/bpython/patch_linecache.py @@ -8,9 +8,7 @@ class BPythonLinecache(dict): def __init__( self, - bpython_history: Optional[ - List[Tuple[int, None, List[str], str]] - ] = None, + bpython_history: None | (list[tuple[int, None, list[str], str]]) = None, *args, **kwargs, ) -> None: @@ -20,7 +18,7 @@ def __init__( def is_bpython_filename(self, fname: Any) -> bool: return isinstance(fname, str) and fname.startswith(" Tuple[int, None, List[str], str]: + def get_bpython_history(self, key: str) -> tuple[int, None, list[str], str]: """Given a filename provided by remember_bpython_input, returns the associated source string.""" try: diff --git a/bpython/repl.py b/bpython/repl.py index f30cfa31..50da2d46 100644 --- a/bpython/repl.py +++ b/bpython/repl.py @@ -41,9 +41,7 @@ from types import ModuleType, TracebackType from typing import ( Any, - Callable, Dict, - Iterable, List, Literal, Optional, @@ -53,6 +51,7 @@ Union, cast, ) +from collections.abc import Callable, Iterable from pygments.lexers import Python3Lexer from pygments.token import Token, _TokenType @@ -85,9 +84,9 @@ def __enter__(self) -> None: def __exit__( self, - exc_type: Optional[Type[BaseException]], - exc_val: Optional[BaseException], - exc_tb: Optional[TracebackType], + exc_type: type[BaseException] | None, + exc_val: BaseException | None, + exc_tb: TracebackType | None, ) -> Literal[False]: self.last_command = time.monotonic() - self.start self.running_time += self.last_command @@ -108,7 +107,7 @@ class Interpreter(code.InteractiveInterpreter): def __init__( self, - locals: Optional[Dict[str, Any]] = None, + locals: dict[str, Any] | None = None, ) -> None: """Constructor. @@ -125,7 +124,7 @@ def __init__( traceback. """ - self.syntaxerror_callback: Optional[Callable] = None + self.syntaxerror_callback: Callable | None = None if locals is None: # instead of messing with sys.modules, we should modify sys.modules @@ -139,7 +138,7 @@ def __init__( def runsource( self, source: str, - filename: Optional[str] = None, + filename: str | None = None, symbol: str = "single", ) -> bool: """Execute Python code. @@ -152,7 +151,7 @@ def runsource( with self.timer: return super().runsource(source, filename, symbol) - def showsyntaxerror(self, filename: Optional[str] = None) -> None: + def showsyntaxerror(self, filename: str | None = None, **kwargs) -> None: """Override the regular handler, the code's copied and pasted from code.py, as per showtraceback, but with the syntaxerror callback called and the text in a pretty colour.""" @@ -219,7 +218,7 @@ def __init__(self) -> None: # word being replaced in the original line of text self.current_word = "" # possible replacements for current_word - self.matches: List[str] = [] + self.matches: list[str] = [] # which word is currently replacing the current word self.index = -1 # cursor position in the original line @@ -227,9 +226,9 @@ def __init__(self) -> None: # original line (before match replacements) self.orig_line = "" # class describing the current type of completion - self.completer: Optional[autocomplete.BaseCompletionType] = None - self.start: Optional[int] = None - self.end: Optional[int] = None + self.completer: autocomplete.BaseCompletionType | None = None + self.start: int | None = None + self.end: int | None = None def __nonzero__(self) -> bool: """MatchesIterator is False when word hasn't been replaced yet""" @@ -263,12 +262,12 @@ def previous(self) -> str: return self.matches[self.index] - def cur_line(self) -> Tuple[int, str]: + def cur_line(self) -> tuple[int, str]: """Returns a cursor offset and line with the current substitution made""" return self.substitute(self.current()) - def substitute(self, match: str) -> Tuple[int, str]: + def substitute(self, match: str) -> tuple[int, str]: """Returns a cursor offset and line with match substituted in""" assert self.completer is not None @@ -284,7 +283,7 @@ def is_cseq(self) -> bool: os.path.commonprefix(self.matches)[len(self.current_word) :] ) - def substitute_cseq(self) -> Tuple[int, str]: + def substitute_cseq(self) -> tuple[int, str]: """Returns a new line by substituting a common sequence in, and update matches""" assert self.completer is not None @@ -305,7 +304,7 @@ def update( self, cursor_offset: int, current_line: str, - matches: List[str], + matches: list[str], completer: autocomplete.BaseCompletionType, ) -> None: """Called to reset the match index and update the word being replaced @@ -352,7 +351,7 @@ def notify( pass @abc.abstractmethod - def file_prompt(self, s: str) -> Optional[str]: + def file_prompt(self, s: str) -> str | None: pass @@ -368,7 +367,7 @@ def notify( ) -> None: pass - def file_prompt(self, s: str) -> Optional[str]: + def file_prompt(self, s: str) -> str | None: return None @@ -384,7 +383,7 @@ class _FuncExpr: function_expr: str arg_number: int opening: str - keyword: Optional[str] = None + keyword: str | None = None class Repl(metaclass=abc.ABCMeta): @@ -426,7 +425,7 @@ def reevaluate(self): @abc.abstractmethod def reprint_line( - self, lineno: int, tokens: List[Tuple[_TokenType, str]] + self, lineno: int, tokens: list[tuple[_TokenType, str]] ) -> None: pass @@ -477,7 +476,7 @@ def __init__(self, interp: Interpreter, config: Config): """ self.config = config self.cut_buffer = "" - self.buffer: List[str] = [] + self.buffer: list[str] = [] self.interp = interp self.interp.syntaxerror_callback = self.clear_current_line self.match = False @@ -486,19 +485,19 @@ def __init__(self, interp: Interpreter, config: Config): ) # all input and output, stored as old style format strings # (\x01, \x02, ...) for cli.py - self.screen_hist: List[str] = [] + self.screen_hist: list[str] = [] # commands executed since beginning of session - self.history: List[str] = [] - self.redo_stack: List[str] = [] + self.history: list[str] = [] + self.redo_stack: list[str] = [] self.evaluating = False self.matches_iter = MatchesIterator() self.funcprops = None - self.arg_pos: Union[str, int, None] = None + self.arg_pos: str | int | None = None self.current_func = None - self.highlighted_paren: Optional[ - Tuple[Any, List[Tuple[_TokenType, str]]] - ] = None - self._C: Dict[str, int] = {} + self.highlighted_paren: None | ( + tuple[Any, list[tuple[_TokenType, str]]] + ) = None + self._C: dict[str, int] = {} self.prev_block_finished: int = 0 self.interact: Interaction = NoInteraction(self.config) # previous pastebin content to prevent duplicate pastes, filled on call @@ -509,7 +508,7 @@ def __init__(self, interp: Interpreter, config: Config): # Necessary to fix mercurial.ui.ui expecting sys.stderr to have this # attribute self.closed = False - self.paster: Union[PasteHelper, PastePinnwand] + self.paster: PasteHelper | PastePinnwand if self.config.hist_file.exists(): try: @@ -587,7 +586,7 @@ def current_string(self, concatenate=False): def get_object(self, name: str) -> Any: attributes = name.split(".") - obj = eval(attributes.pop(0), cast(Dict[str, Any], self.interp.locals)) + obj = eval(attributes.pop(0), cast(dict[str, Any], self.interp.locals)) while attributes: obj = inspection.getattr_safe(obj, attributes.pop(0)) return obj @@ -595,7 +594,7 @@ def get_object(self, name: str) -> Any: @classmethod def _funcname_and_argnum( cls, line: str - ) -> Tuple[Optional[str], Optional[Union[str, int]]]: + ) -> tuple[str | None, str | int | None]: """Parse out the current function name and arg from a line of code.""" # each element in stack is a _FuncExpr instance # if keyword is not None, we've encountered a keyword and so we're done counting @@ -715,7 +714,7 @@ def get_source_of_current_name(self) -> str: current name in the current input line. Throw `SourceNotFound` if the source cannot be found.""" - obj: Optional[Callable] = self.current_func + obj: Callable | None = self.current_func try: if obj is None: line = self.current_line @@ -761,7 +760,7 @@ def set_docstring(self) -> None: # If exactly one match that is equal to current line, clear matches # If example one match and tab=True, then choose that and clear matches - def complete(self, tab: bool = False) -> Optional[bool]: + def complete(self, tab: bool = False) -> bool | None: """Construct a full list of possible completions and display them in a window. Also check if there's an available argspec (via the inspect module) and bang that on top of the completions too. @@ -780,7 +779,7 @@ def complete(self, tab: bool = False) -> Optional[bool]: self.completers, cursor_offset=self.cursor_offset, line=self.current_line, - locals_=cast(Dict[str, Any], self.interp.locals), + locals_=cast(dict[str, Any], self.interp.locals), argspec=self.funcprops, current_block="\n".join(self.buffer + [self.current_line]), complete_magic_methods=self.config.complete_magic_methods, @@ -817,7 +816,7 @@ def complete(self, tab: bool = False) -> Optional[bool]: def format_docstring( self, docstring: str, width: int, height: int - ) -> List[str]: + ) -> list[str]: """Take a string and try to format it into a sane list of strings to be put into the suggestion box.""" @@ -937,7 +936,7 @@ def copy2clipboard(self) -> None: else: self.interact.notify(_("Copied content to clipboard.")) - def pastebin(self, s=None) -> Optional[str]: + def pastebin(self, s=None) -> str | None: """Upload to a pastebin and display the URL in the status bar.""" if s is None: @@ -951,7 +950,7 @@ def pastebin(self, s=None) -> Optional[str]: else: return self.do_pastebin(s) - def do_pastebin(self, s) -> Optional[str]: + def do_pastebin(self, s) -> str | None: """Actually perform the upload.""" paste_url: str if s == self.prev_pastebin_content: @@ -1086,7 +1085,7 @@ def flush(self) -> None: def close(self): """See the flush() method docstring.""" - def tokenize(self, s, newline=False) -> List[Tuple[_TokenType, str]]: + def tokenize(self, s, newline=False) -> list[tuple[_TokenType, str]]: """Tokenizes a line of code, returning pygments tokens with side effects/impurities: - reads self.cpos to see what parens should be highlighted @@ -1103,7 +1102,7 @@ def tokenize(self, s, newline=False) -> List[Tuple[_TokenType, str]]: cursor = len(source) - self.cpos if self.cpos: cursor += 1 - stack: List[Any] = list() + stack: list[Any] = list() all_tokens = list(Python3Lexer().get_tokens(source)) # Unfortunately, Pygments adds a trailing newline and strings with # no size, so strip them @@ -1112,8 +1111,8 @@ def tokenize(self, s, newline=False) -> List[Tuple[_TokenType, str]]: all_tokens[-1] = (all_tokens[-1][0], all_tokens[-1][1].rstrip("\n")) line = pos = 0 parens = dict(zip("{([", "})]")) - line_tokens: List[Tuple[_TokenType, str]] = list() - saved_tokens: List[Tuple[_TokenType, str]] = list() + line_tokens: list[tuple[_TokenType, str]] = list() + saved_tokens: list[tuple[_TokenType, str]] = list() search_for_paren = True for token, value in split_lines(all_tokens): pos += len(value) @@ -1296,7 +1295,7 @@ def token_is_any_of(token): return token_is_any_of -def extract_exit_value(args: Tuple[Any, ...]) -> Any: +def extract_exit_value(args: tuple[Any, ...]) -> Any: """Given the arguments passed to `SystemExit`, return the value that should be passed to `sys.exit`. """ diff --git a/bpython/simpleeval.py b/bpython/simpleeval.py index c5bba43d..1e26ded4 100644 --- a/bpython/simpleeval.py +++ b/bpython/simpleeval.py @@ -33,19 +33,16 @@ from . import line as line_properties from .inspection import getattr_safe -_is_py38 = sys.version_info[:2] >= (3, 8) -_is_py39 = sys.version_info[:2] >= (3, 9) - _string_type_nodes = (ast.Str, ast.Bytes) _numeric_types = (int, float, complex) -_name_type_nodes = (ast.Name,) if _is_py38 else (ast.Name, ast.NameConstant) +_name_type_nodes = (ast.Name,) class EvaluationError(Exception): """Raised if an exception occurred in safe_eval.""" -def safe_eval(expr: str, namespace: Dict[str, Any]) -> Any: +def safe_eval(expr: str, namespace: dict[str, Any]) -> Any: """Not all that safe, just catches some errors""" try: return eval(expr, namespace) @@ -91,10 +88,6 @@ def simple_eval(node_or_string, namespace=None): def _convert(node): if isinstance(node, ast.Constant): return node.value - elif not _is_py38 and isinstance(node, _string_type_nodes): - return node.s - elif not _is_py38 and isinstance(node, ast.Num): - return node.n elif isinstance(node, ast.Tuple): return tuple(map(_convert, node.elts)) elif isinstance(node, ast.List): @@ -168,18 +161,8 @@ def _convert(node): return left - right # this is a deviation from literal_eval: we allow indexing - elif ( - not _is_py39 - and isinstance(node, ast.Subscript) - and isinstance(node.slice, ast.Index) - ): - obj = _convert(node.value) - index = _convert(node.slice.value) - return safe_getitem(obj, index) - elif ( - _is_py39 - and isinstance(node, ast.Subscript) - and isinstance(node.slice, (ast.Constant, ast.Name)) + elif isinstance(node, ast.Subscript) and isinstance( + node.slice, (ast.Constant, ast.Name) ): obj = _convert(node.value) index = _convert(node.slice) @@ -216,7 +199,7 @@ def find_attribute_with_name(node, name): def evaluate_current_expression( - cursor_offset: int, line: str, namespace: Optional[Dict[str, Any]] = None + cursor_offset: int, line: str, namespace: dict[str, Any] | None = None ) -> Any: """ Return evaluated expression to the right of the dot of current attribute. diff --git a/bpython/test/test_curtsies_repl.py b/bpython/test/test_curtsies_repl.py index 5a19c6ab..59102f9e 100644 --- a/bpython/test/test_curtsies_repl.py +++ b/bpython/test/test_curtsies_repl.py @@ -435,7 +435,7 @@ def setUp(self): self.repl = create_repl() def write_startup_file(self, fname, encoding): - with open(fname, mode="wt", encoding=encoding) as f: + with open(fname, mode="w", encoding=encoding) as f: f.write("# coding: ") f.write(encoding) f.write("\n") diff --git a/bpython/test/test_inspection.py b/bpython/test/test_inspection.py index 3f04222d..c83ca012 100644 --- a/bpython/test/test_inspection.py +++ b/bpython/test/test_inspection.py @@ -103,9 +103,7 @@ def test_get_source_latin1(self): self.assertEqual(inspect.getsource(encoding_latin1.foo), foo_non_ascii) def test_get_source_file(self): - path = os.path.join( - os.path.dirname(os.path.abspath(__file__)), "fodder" - ) + path = os.path.join(os.path.dirname(__file__), "fodder") encoding = inspection.get_encoding_file( os.path.join(path, "encoding_ascii.py") @@ -162,7 +160,7 @@ def fun(number, lst=[]): """ return lst + [number] - def fun_annotations(number: int, lst: List[int] = []) -> List[int]: + def fun_annotations(number: int, lst: list[int] = []) -> list[int]: """ Return a list of numbers @@ -185,7 +183,7 @@ def fun_annotations(number: int, lst: List[int] = []) -> List[int]: def test_issue_966_class_method(self): class Issue966(Sequence): @classmethod - def cmethod(cls, number: int, lst: List[int] = []): + def cmethod(cls, number: int, lst: list[int] = []): """ Return a list of numbers @@ -222,7 +220,7 @@ def bmethod(cls, number, lst): def test_issue_966_static_method(self): class Issue966(Sequence): @staticmethod - def cmethod(number: int, lst: List[int] = []): + def cmethod(number: int, lst: list[int] = []): """ Return a list of numbers diff --git a/bpython/test/test_interpreter.py b/bpython/test/test_interpreter.py index a4a32dd0..b9f0a31e 100644 --- a/bpython/test/test_interpreter.py +++ b/bpython/test/test_interpreter.py @@ -99,7 +99,22 @@ def gfunc(): global_not_found = "name 'gfunc' is not defined" - if (3, 11) <= sys.version_info[:2]: + if (3, 13) <= sys.version_info[:2]: + expected = ( + "Traceback (most recent call last):\n File " + + green('""') + + ", line " + + bold(magenta("1")) + + ", in " + + cyan("") + + "\n gfunc()" + + "\n ^^^^^\n" + + bold(red("NameError")) + + ": " + + cyan(global_not_found) + + "\n" + ) + elif (3, 11) <= sys.version_info[:2]: expected = ( "Traceback (most recent call last):\n File " + green('""') @@ -130,8 +145,8 @@ def gfunc(): ) a = i.a - self.assertMultiLineEqual(str(plain("").join(a)), str(expected)) - self.assertEqual(plain("").join(a), expected) + self.assertMultiLineEqual(str(expected), str(plain("").join(a))) + self.assertEqual(expected, plain("").join(a)) def test_getsource_works_on_interactively_defined_functions(self): source = "def foo(x):\n return x + 1\n" diff --git a/bpython/test/test_line_properties.py b/bpython/test/test_line_properties.py index 967ecbe0..01797827 100644 --- a/bpython/test/test_line_properties.py +++ b/bpython/test/test_line_properties.py @@ -27,7 +27,7 @@ def cursor(s): return cursor_offset, line -def decode(s: str) -> Tuple[Tuple[int, str], Optional[LinePart]]: +def decode(s: str) -> tuple[tuple[int, str], LinePart | None]: """'ad' -> ((3, 'abcd'), (1, 3, 'bdc'))""" if not s.count("|") == 1: @@ -52,7 +52,7 @@ def line_with_cursor(cursor_offset: int, line: str) -> str: return line[:cursor_offset] + "|" + line[cursor_offset:] -def encode(cursor_offset: int, line: str, result: Optional[LinePart]) -> str: +def encode(cursor_offset: int, line: str, result: LinePart | None) -> str: """encode(3, 'abdcd', (1, 3, 'bdc')) -> ad' Written for prettier assert error messages diff --git a/bpython/test/test_preprocess.py b/bpython/test/test_preprocess.py index ee3f2085..8e8a3630 100644 --- a/bpython/test/test_preprocess.py +++ b/bpython/test/test_preprocess.py @@ -4,6 +4,7 @@ import unittest from code import compile_command as compiler +from codeop import CommandCompiler from functools import partial from bpython.curtsiesfrontend.interpreter import code_finished_will_parse @@ -11,15 +12,15 @@ from bpython.test.fodder import original, processed -preproc = partial(preprocess, compiler=compiler) +preproc = partial(preprocess, compiler=CommandCompiler()) def get_fodder_source(test_name): pattern = rf"#StartTest-{test_name}\n(.*?)#EndTest" - orig, xformed = [ + orig, xformed = ( re.search(pattern, inspect.getsource(module), re.DOTALL) for module in [original, processed] - ] + ) if not orig: raise ValueError( diff --git a/bpython/test/test_repl.py b/bpython/test/test_repl.py index 8c3b85cc..a32ef90e 100644 --- a/bpython/test/test_repl.py +++ b/bpython/test/test_repl.py @@ -60,7 +60,7 @@ def getstdout(self) -> str: raise NotImplementedError def reprint_line( - self, lineno: int, tokens: List[Tuple[repl._TokenType, str]] + self, lineno: int, tokens: list[tuple[repl._TokenType, str]] ) -> None: raise NotImplementedError @@ -307,7 +307,7 @@ def assert_get_source_error_for_current_function(self, func, msg): try: self.repl.get_source_of_current_name() except repl.SourceNotFound as e: - self.assertEqual(e.args[0], msg) + self.assertEqual(msg, e.args[0]) else: self.fail("Should have raised SourceNotFound") @@ -332,9 +332,14 @@ def test_current_function_cpython(self): self.assert_get_source_error_for_current_function( collections.defaultdict.copy, "No source code found for INPUTLINE" ) - self.assert_get_source_error_for_current_function( - collections.defaultdict, "could not find class definition" - ) + if sys.version_info[:2] >= (3, 13): + self.assert_get_source_error_for_current_function( + collections.defaultdict, "source code not available" + ) + else: + self.assert_get_source_error_for_current_function( + collections.defaultdict, "could not find class definition" + ) def test_current_line(self): self.repl.interp.locals["a"] = socket.socket diff --git a/bpython/translations/__init__.py b/bpython/translations/__init__.py index 0cb4c01f..069f3465 100644 --- a/bpython/translations/__init__.py +++ b/bpython/translations/__init__.py @@ -18,7 +18,7 @@ def ngettext(singular, plural, n): def init( - locale_dir: Optional[str] = None, languages: Optional[List[str]] = None + locale_dir: str | None = None, languages: list[str] | None = None ) -> None: try: locale.setlocale(locale.LC_ALL, "") diff --git a/bpython/urwid.py b/bpython/urwid.py index 4b41c12a..d94fc2e7 100644 --- a/bpython/urwid.py +++ b/bpython/urwid.py @@ -76,10 +76,10 @@ class EvalProtocol(basic.LineOnlyReceiver): delimiter = "\n" - def __init__(self, myrepl): + def __init__(self, myrepl) -> None: self.repl = myrepl - def lineReceived(self, line): + def lineReceived(self, line) -> None: # HACK! # TODO: deal with encoding issues here... self.repl.main_loop.process_input(line) @@ -99,7 +99,6 @@ def buildProtocol(self, addr): if urwid.VERSION < (1, 0, 0) and hasattr(urwid, "TwistedEventLoop"): class TwistedEventLoop(urwid.TwistedEventLoop): - """TwistedEventLoop modified to properly stop the reactor. urwid 0.9.9 and 0.9.9.1 crash the reactor on ExitMainLoop instead @@ -564,7 +563,7 @@ def _prompt_result(self, text): self.callback = None callback(text) - def file_prompt(self, s: str) -> Optional[str]: + def file_prompt(self, s: str) -> str | None: raise NotImplementedError diff --git a/doc/sphinx/source/contributing.rst b/doc/sphinx/source/contributing.rst index 32b1ea86..3b93089d 100644 --- a/doc/sphinx/source/contributing.rst +++ b/doc/sphinx/source/contributing.rst @@ -17,7 +17,7 @@ the time of day. Getting your development environment set up ------------------------------------------- -bpython supports Python 3.8 and newer. The code is compatible with all +bpython supports Python 3.9 and newer. The code is compatible with all supported versions. Using a virtual environment is probably a good idea. Create a virtual diff --git a/doc/sphinx/source/releases.rst b/doc/sphinx/source/releases.rst index fcce5c1c..7d789f16 100644 --- a/doc/sphinx/source/releases.rst +++ b/doc/sphinx/source/releases.rst @@ -45,7 +45,7 @@ A checklist to perform some manual tests before a release: Check that all of the following work before a release: -* Runs under Python 3.8 - 3.11 +* Runs under Python 3.9 - 3.13 * Save * Rewind * Pastebin diff --git a/doc/sphinx/source/simplerepl.py b/doc/sphinx/source/simplerepl.py index 8a8dda74..8496f0dd 100644 --- a/doc/sphinx/source/simplerepl.py +++ b/doc/sphinx/source/simplerepl.py @@ -42,7 +42,7 @@ class SimpleRepl(BaseRepl): def __init__(self, config): self.requested_events = [] - BaseRepl.__init__(self, config) + BaseRepl.__init__(self, config, window=None) def _request_refresh(self): self.requested_events.append(bpythonevents.RefreshRequestEvent()) diff --git a/pyproject.toml b/pyproject.toml index b7bd3196..0a891d27 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,10 @@ [build-system] -requires = [ - "setuptools >= 43", -] +requires = ["setuptools >= 62.4.0"] build-backend = "setuptools.build_meta" [tool.black] line-length = 80 -target_version = ["py38"] +target_version = ["py310"] include = '\.pyi?$' exclude = ''' /( diff --git a/requirements.txt b/requirements.txt index 4c750a69..cc8fbff8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,4 @@ cwcwidth greenlet pyxdg requests -setuptools \ No newline at end of file +setuptools>=62.4.0 diff --git a/setup.cfg b/setup.cfg index 1fe4a6f9..7d61ee1c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,6 @@ [metadata] name = bpython +description = A fancy curses interface to the Python interactive interpreter long_description = file: README.rst long_description_content_type = text/x-rst license = MIT @@ -14,7 +15,7 @@ classifiers = Programming Language :: Python :: 3 [options] -python_requires = >=3.8 +python_requires = >=3.9 packages = bpython bpython.curtsiesfrontend @@ -29,11 +30,12 @@ install_requires = pygments pyxdg requests + typing_extensions ; python_version < "3.11" [options.extras_require] clipboard = pyperclip jedi = jedi >= 0.16 -urwid = urwid +urwid = urwid < 3.0 watch = watchdog [options.entry_points] diff --git a/setup.py b/setup.py index 7ca279d3..de10eaf4 100755 --- a/setup.py +++ b/setup.py @@ -5,8 +5,8 @@ import re import subprocess -from setuptools import setup -from distutils.command.build import build +from setuptools import setup, Command +from setuptools.command.build import build try: from babel.messages import frontend as babel @@ -17,14 +17,212 @@ try: import sphinx - from sphinx.setup_command import BuildDoc # Sphinx 1.5 and newer support Python 3.6 - using_sphinx = sphinx.__version__ >= "1.5" and sphinx.__version__ < "7.0" + using_sphinx = sphinx.__version__ >= "1.5" except ImportError: using_sphinx = False +if using_sphinx: + import sys + from io import StringIO + + from setuptools.errors import ExecError + from sphinx.application import Sphinx + from sphinx.cmd.build import handle_exception + from sphinx.util.console import color_terminal, nocolor + from sphinx.util.docutils import docutils_namespace, patch_docutils + from sphinx.util.osutil import abspath + + class BuildDoc(Command): + """ + Distutils command to build Sphinx documentation. + The Sphinx build can then be triggered from distutils, and some Sphinx + options can be set in ``setup.py`` or ``setup.cfg`` instead of Sphinx's + own configuration file. + For instance, from `setup.py`:: + # this is only necessary when not using setuptools/distribute + from sphinx.setup_command import BuildDoc + cmdclass = {'build_sphinx': BuildDoc} + name = 'My project' + version = '1.2' + release = '1.2.0' + setup( + name=name, + author='Bernard Montgomery', + version=release, + cmdclass=cmdclass, + # these are optional and override conf.py settings + command_options={ + 'build_sphinx': { + 'project': ('setup.py', name), + 'version': ('setup.py', version), + 'release': ('setup.py', release)}}, + ) + Or add this section in ``setup.cfg``:: + [build_sphinx] + project = 'My project' + version = 1.2 + release = 1.2.0 + """ + + description = "Build Sphinx documentation" + user_options = [ + ("fresh-env", "E", "discard saved environment"), + ("all-files", "a", "build all files"), + ("source-dir=", "s", "Source directory"), + ("build-dir=", None, "Build directory"), + ("config-dir=", "c", "Location of the configuration directory"), + ( + "builder=", + "b", + "The builder (or builders) to use. Can be a comma- " + 'or space-separated list. Defaults to "html"', + ), + ("warning-is-error", "W", "Turn warning into errors"), + ("project=", None, "The documented project's name"), + ("version=", None, "The short X.Y version"), + ( + "release=", + None, + "The full version, including alpha/beta/rc tags", + ), + ( + "today=", + None, + "How to format the current date, used as the " + "replacement for |today|", + ), + ("link-index", "i", "Link index.html to the master doc"), + ("copyright", None, "The copyright string"), + ("pdb", None, "Start pdb on exception"), + ("verbosity", "v", "increase verbosity (can be repeated)"), + ( + "nitpicky", + "n", + "nit-picky mode, warn about all missing references", + ), + ("keep-going", None, "With -W, keep going when getting warnings"), + ] + boolean_options = [ + "fresh-env", + "all-files", + "warning-is-error", + "link-index", + "nitpicky", + ] + + def initialize_options(self) -> None: + self.fresh_env = self.all_files = False + self.pdb = False + self.source_dir: str = None + self.build_dir: str = None + self.builder = "html" + self.warning_is_error = False + self.project = "" + self.version = "" + self.release = "" + self.today = "" + self.config_dir: str = None + self.link_index = False + self.copyright = "" + # Link verbosity to distutils' (which uses 1 by default). + self.verbosity = self.distribution.verbose - 1 # type: ignore + self.traceback = False + self.nitpicky = False + self.keep_going = False + + def _guess_source_dir(self) -> str: + for guess in ("doc", "docs"): + if not os.path.isdir(guess): + continue + for root, dirnames, filenames in os.walk(guess): + if "conf.py" in filenames: + return root + return os.curdir + + def finalize_options(self) -> None: + self.ensure_string_list("builder") + + if self.source_dir is None: + self.source_dir = self._guess_source_dir() + self.announce("Using source directory %s" % self.source_dir) + + self.ensure_dirname("source_dir") + + if self.config_dir is None: + self.config_dir = self.source_dir + + if self.build_dir is None: + build = self.get_finalized_command("build") + self.build_dir = os.path.join(abspath(build.build_base), "sphinx") # type: ignore + + self.doctree_dir = os.path.join(self.build_dir, "doctrees") + + self.builder_target_dirs = [ + (builder, os.path.join(self.build_dir, builder)) + for builder in self.builder + ] + + def run(self) -> None: + if not color_terminal(): + nocolor() + if not self.verbose: # type: ignore + status_stream = StringIO() + else: + status_stream = sys.stdout # type: ignore + confoverrides = {} + if self.project: + confoverrides["project"] = self.project + if self.version: + confoverrides["version"] = self.version + if self.release: + confoverrides["release"] = self.release + if self.today: + confoverrides["today"] = self.today + if self.copyright: + confoverrides["copyright"] = self.copyright + if self.nitpicky: + confoverrides["nitpicky"] = self.nitpicky + + for builder, builder_target_dir in self.builder_target_dirs: + app = None + + try: + confdir = self.config_dir or self.source_dir + with patch_docutils(confdir), docutils_namespace(): + app = Sphinx( + self.source_dir, + self.config_dir, + builder_target_dir, + self.doctree_dir, + builder, + confoverrides, + status_stream, + freshenv=self.fresh_env, + warningiserror=self.warning_is_error, + verbosity=self.verbosity, + keep_going=self.keep_going, + ) + app.build(force_all=self.all_files) + if app.statuscode: + raise ExecError( + "caused by %s builder." % app.builder.name + ) + except Exception as exc: + handle_exception(app, self, exc, sys.stderr) + if not self.pdb: + raise SystemExit(1) from exc + + if not self.link_index: + continue + + src = app.config.root_doc + app.builder.out_suffix # type: ignore + dst = app.builder.get_outfilename("index") # type: ignore + os.symlink(src, dst) + + # version handling @@ -122,21 +320,28 @@ def git_describe_to_python_version(version): vf.write(f'__version__ = "{version}"\n') -cmdclass = {"build": build} +class custom_build(build): + def run(self): + if using_translations: + self.run_command("compile_catalog") + if using_sphinx: + self.run_command("build_sphinx_man") + + +cmdclass = {"build": custom_build} + translations_dir = os.path.join("bpython", "translations") # localization options if using_translations: - build.sub_commands.insert(0, ("compile_catalog", None)) - cmdclass["compile_catalog"] = babel.compile_catalog cmdclass["extract_messages"] = babel.extract_messages cmdclass["update_catalog"] = babel.update_catalog cmdclass["init_catalog"] = babel.init_catalog if using_sphinx: - build.sub_commands.insert(0, ("build_sphinx_man", None)) + cmdclass["build_sphinx"] = BuildDoc cmdclass["build_sphinx_man"] = BuildDoc if platform.system() in ("FreeBSD", "OpenBSD"): @@ -175,6 +380,7 @@ def git_describe_to_python_version(version): if os.path.exists(os.path.join(translations_dir, mo_subpath)): mo_files.append(mo_subpath) + setup( version=version, data_files=data_files, @@ -185,6 +391,7 @@ def git_describe_to_python_version(version): }, cmdclass=cmdclass, test_suite="bpython.test", + zip_safe=False, ) # vim: fileencoding=utf-8 sw=4 ts=4 sts=4 ai et sta