diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c200a7..e131174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,58 +3,84 @@ name: CI on: push: branches: - - master + - main tags: - '**' pull_request: {} +env: + COLUMNS: 150 + jobs: lint: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 + + - uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - run: pip install -r requirements/linting.txt -r requirements/pyproject.txt + + - run: mypy devtools + + - uses: pre-commit/action@v3.0.0 + with: + extra_args: --all-files --verbose + + docs-build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - - run: pip install -U pip wheel - - run: pip install -r tests/requirements-linting.txt + - run: pip install -r requirements/docs.txt -r requirements/pyproject.txt - run: pip install . - - run: make lint + - run: make docs + + - name: Store docs site + uses: actions/upload-artifact@v3 + with: + name: docs + path: site test: name: test py${{ matrix.python-version }} on ${{ matrix.os }} strategy: fail-fast: false matrix: - os: [ubuntu, macos, windows] - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10.0-rc.1'] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] exclude: - # numpy currently get's upset with macos and python 3.10 - - os: macos - python-version: '3.10.0-rc.1' + - os: macos-latest + python-version: '3.7' + include: + - os: macos-13 + python-version: '3.7' env: PYTHON: ${{ matrix.python-version }} OS: ${{ matrix.os }} EXTRAS: yes - runs-on: ${{ matrix.os }}-latest + runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - run: pip install -U pip wheel - - run: pip install -r tests/requirements.txt - - run: pip install . + - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt + - run: pip freeze - name: test with extras @@ -62,7 +88,7 @@ jobs: - run: coverage xml - - uses: codecov/codecov-action@v2.0.3 + - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS @@ -75,36 +101,55 @@ jobs: - run: coverage xml - - uses: codecov/codecov-action@v2.0.3 + - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS env: EXTRAS: no + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks + check: + if: always() + needs: [test, lint, docs-build] + runs-on: ubuntu-latest + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + deploy: needs: - - test - - lint + - check if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: - python-version: '3.8' + python-version: '3.10' - - name: install - run: make install + - name: get docs + uses: actions/download-artifact@v3 + with: + name: docs + path: site + + - name: check GITHUB_REF matches package version + id: check-tag + uses: samuelcolvin/check-python-version@v4.1 + with: + version_file_path: devtools/version.py - - name: set version - run: VERSION_PATH='devtools/version.py' python <(curl -Ls https://git.io/JT3rm) + - name: install + run: pip install build twine - name: build - run: python setup.py sdist bdist_wheel + run: python -m build - run: twine check dist/* @@ -115,6 +160,10 @@ jobs: TWINE_PASSWORD: ${{ secrets.pypi_token }} - name: publish docs - run: make publish-docs + if: '!fromJSON(steps.check-tag.outputs.IS_PRERELEASE)' + uses: cloudflare/wrangler-action@2.0.0 + with: + apiToken: ${{ secrets.cloudflare_api_token }} + command: pages publish --project-name=python-devtools --branch=main site env: - NETLIFY: ${{ secrets.netlify_token }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.cloudflare_account_id }} diff --git a/.gitignore b/.gitignore index 1b7451b..c6e6fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .idea/ env/ -env35/ -env36/ -env37/ +env*/ *.py[cod] *.egg-info/ dist/ @@ -19,3 +17,4 @@ old-version/ *.swp /site/ /site.zip +/build/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..89b5ad4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.3.0 + hooks: + - id: check-yaml + - id: check-toml + - id: end-of-file-fixer + - id: trailing-whitespace + +- repo: local + hooks: + - id: ruff + name: Ruff + entry: ruff + args: [--fix, --exit-non-zero-on-fix] + types: [python] + language: system + files: ^devtools/|^tests/ + - id: black + name: Black + entry: black + types: [python] + language: system + files: ^devtools/|^tests/ + exclude: test_expr_render.py diff --git a/HISTORY.md b/HISTORY.md index 61dcf05..ddf0bda 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,47 @@ +## v0.12.1 (2023-08-17) + +fix docs release + +## v0.12.0 (2023-08-17) + +* build docs on CI by @samuelcolvin in #127 +* Update usage to reflect the recent addition of the pytest plugin by @tomhamiltonstubber in #128 +* support dataclasses with slots by @samuelcolvin in #136 +* Make `Pygments` required #137 + +## v0.11.0 (2023-04-05) + +* added support for sqlalchemy2 by @the-vty in #120 +* switch to ruff by @samuelcolvin in #124 +* support displaying ast types by @samuelcolvin in #125 +* Insert assert by @samuelcolvin in #126 + +## v0.10.0 (2022-11-28) + +* Use secure builtins standard module, instead of the `__builtins__` by @0xsirsaif in #109 +* upgrade executing to fix 3.10 by @samuelcolvin in #110 +* Fix windows build by @samuelcolvin in #111 +* Allow executing dependency to be >1.0.0 by @staticf0x in #115 +* more precise timer summary by @banteg in #113 +* Python 3.11 by @samuelcolvin in #118 + +## v0.9.0 (2022-07-26) + +* fix format of nested dataclasses, #99 thanks @aliereno +* Moving to `pyproject.toml`, complete type hints and test with mypy, #107 +* add `install` command to add `debug` to `__builtins__`, #108 + +## v0.8.0 (2021-09-29) + +* test with python 3.10 #91 +* display `SQLAlchemy` objects nicely #94 +* fix tests on windows #93 +* show function `qualname` #95 +* cache pygments loading (significant speedup) #96 + ## v0.7.0 (2021-09-03) -* switch to [`executing`](https://pypi.org/project/executing/) and [`asttokens`](https://pypi.org/project/asttokens/) +* switch to [`executing`](https://pypi.org/project/executing/) and [`asttokens`](https://pypi.org/project/asttokens/) for finding and printing debug arguments, #82, thanks @alexmojaki * correct changelog links, #76, thanks @Cielquan * return `debug()` arguments, #87 @@ -9,6 +50,10 @@ * display `dataclasses` properly, #88 * uprev test dependencies, #81, #83, #90 +## v0.6.1 (2020-10-22) + +compatibility with python 3.8.6 + ## v0.6.0 (2020-07-29) * improve `__pretty__` to work better with pydantic classes, #52 diff --git a/LICENSE b/LICENSE index 3338ce9..bdd9b15 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ The MIT License (MIT) -Copyright (c) 2017 Samuel Colvin +Copyright (c) 2017 to present Samuel Colvin Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 5f8d242..0000000 --- a/MANIFEST.in +++ /dev/null @@ -1,3 +0,0 @@ -include LICENSE -include README.md -include HISTORY.md diff --git a/Makefile b/Makefile index fe19741..51b877c 100644 --- a/Makefile +++ b/Makefile @@ -1,31 +1,45 @@ .DEFAULT_GOAL := all -isort = isort devtools tests -black = black -S -l 120 --target-version py37 devtools +sources = devtools tests docs/plugins.py .PHONY: install install: - python -m pip install -U setuptools pip wheel twine - pip install -U -r requirements.txt + python -m pip install -U pip pre-commit + pip install -U -r requirements/all.txt pip install -e . + pre-commit install + +.PHONY: refresh-lockfiles +refresh-lockfiles: + find requirements/ -name '*.txt' ! -name 'all.txt' -type f -delete + make update-lockfiles + +.PHONY: update-lockfiles +update-lockfiles: + @echo "Updating requirements/*.txt files using pip-compile" + pip-compile -q --resolver backtracking -o requirements/linting.txt requirements/linting.in + pip-compile -q --resolver backtracking -o requirements/testing.txt requirements/testing.in + pip-compile -q --resolver backtracking -o requirements/docs.txt requirements/docs.in + pip-compile -q --resolver backtracking -o requirements/pyproject.txt pyproject.toml + pip install --dry-run -r requirements/all.txt .PHONY: format format: - $(isort) - $(black) + black $(sources) + ruff $(sources) --fix-only .PHONY: lint lint: - flake8 devtools/ tests/ - $(isort) --check-only --df - $(black) --check --diff + black $(sources) --check --diff + ruff $(sources) + mypy devtools .PHONY: test test: - pytest --cov=devtools --cov-fail-under 0 + coverage run -m pytest .PHONY: testcov testcov: - pytest --cov=devtools --cov-fail-under 0 + coverage run -m pytest @echo "building coverage html" @coverage html @@ -49,13 +63,11 @@ clean: .PHONY: docs docs: - flake8 --max-line-length=80 docs/examples/ - python docs/build/main.py + ruff --line-length=80 docs/examples/ mkdocs build .PHONY: docs-serve docs-serve: - python docs/build/main.py mkdocs serve .PHONY: publish-docs diff --git a/README.md b/README.md index ab64a57..c660d28 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # python devtools -[![CI](https://github.com/samuelcolvin/python-devtools/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/python-devtools/actions?query=event%3Apush+branch%3Amaster+workflow%3ACI) -[![Coverage](https://codecov.io/gh/samuelcolvin/python-devtools/branch/master/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/python-devtools) +[![CI](https://github.com/samuelcolvin/python-devtools/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/python-devtools/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) +[![Coverage](https://codecov.io/gh/samuelcolvin/python-devtools/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/python-devtools) [![pypi](https://img.shields.io/pypi/v/devtools.svg)](https://pypi.python.org/pypi/devtools) [![versions](https://img.shields.io/pypi/pyversions/devtools.svg)](https://github.com/samuelcolvin/python-devtools) -[![license](https://img.shields.io/github/license/samuelcolvin/python-devtools.svg)](https://github.com/samuelcolvin/python-devtools/blob/master/LICENSE) +[![license](https://img.shields.io/github/license/samuelcolvin/python-devtools.svg)](https://github.com/samuelcolvin/python-devtools/blob/main/LICENSE) **Python's missing debug print command and other development tools.** @@ -15,13 +15,10 @@ For more information, see [documentation](https://python-devtools.helpmanual.io/ Just ```bash -pip install devtools[pygments] +pip install devtools ``` -`pygments` is not required but if it's installed, output will be highlighted and easier to read. - -`devtools` has no other required dependencies except python 3.6, 3.7, 3.8 or 3.9. -If you've got python 3.6+ and `pip` installed, you're good to go. +If you've got python 3.7+ and `pip` installed, you're good to go. ## Usage @@ -57,18 +54,12 @@ debug(data) outputs: -![python-devtools demo](https://raw.githubusercontent.com/samuelcolvin/python-devtools/master/demo.py.png) +![python-devtools demo](https://raw.githubusercontent.com/samuelcolvin/python-devtools/main/demo.py.png) ## Usage without Import -modify `/usr/lib/python3.8/sitecustomize.py` making `debug` available in any python 3.8 code +devtools can be used without `from devtools import debug` if you add `debug` into `__builtins__` +in `sitecustomize.py`. -```py -# add devtools debug to builtins -try: - from devtools import debug -except ImportError: - pass -else: - __builtins__['debug'] = debug -``` +For instructions on adding `debug` to `__builtins__`, +see the [installation docs](https://python-devtools.helpmanual.io/usage/#usage-without-import). diff --git a/devtools/__init__.py b/devtools/__init__.py index 6808bf5..b607d19 100644 --- a/devtools/__init__.py +++ b/devtools/__init__.py @@ -1,6 +1,9 @@ -# flake8: noqa -from .ansi import * -from .debug import * -from .prettier import * -from .timer import * +from .ansi import sformat, sprint +from .debug import Debug, debug +from .prettier import PrettyFormat, pformat, pprint +from .timer import Timer from .version import VERSION + +__version__ = VERSION + +__all__ = 'sformat', 'sprint', 'Debug', 'debug', 'PrettyFormat', 'pformat', 'pprint', 'Timer', 'VERSION' diff --git a/devtools/__main__.py b/devtools/__main__.py new file mode 100644 index 0000000..a5fa2f5 --- /dev/null +++ b/devtools/__main__.py @@ -0,0 +1,84 @@ +import builtins +import sys +from pathlib import Path + +from .version import VERSION + +# language=python +install_code = """ +# add devtools `debug` function to builtins +# we don't want to import devtools until it's required since it breaks pytest, hence this proxy +class DebugProxy: + def __init__(self): + self._debug = None + + def _import_debug(self): + if self._debug is None: + from devtools import debug + self._debug = debug + + def __call__(self, *args, **kwargs): + self._import_debug() + kwargs['frame_depth_'] = 3 + return self._debug(*args, **kwargs) + + def format(self, *args, **kwargs): + self._import_debug() + kwargs['frame_depth_'] = 3 + return self._debug.format(*args, **kwargs) + + def __getattr__(self, item): + self._import_debug() + return getattr(self._debug, item) + +import builtins +setattr(builtins, 'debug', DebugProxy()) +""" + + +def print_code() -> int: + print(install_code) + return 0 + + +def install() -> int: + try: + import sitecustomize # type: ignore + except ImportError: + paths = [Path(p) for p in sys.path] + try: + path = next(p for p in paths if p.is_dir() and p.name == 'site-packages') + except StopIteration: + # what else makes sense to try? + print(f'unable to file a suitable path to save `sitecustomize.py` to from sys.path: {paths}') + return 1 + else: + install_path = path / 'sitecustomize.py' + else: + install_path = Path(sitecustomize.__file__) + + if hasattr(builtins, 'debug'): + print(f'Looks like devtools is already installed, probably in `{install_path}`.') + return 0 + + print(f'Found path `{install_path}` to install devtools into `builtins`') + print('To install devtools, run the following command:\n') + print(f' python -m devtools print-code >> {install_path}\n') + try: + install_path.relative_to(Path.home()) + except ValueError: + print('or maybe\n') + print(f' python -m devtools print-code | sudo tee -a {install_path} > /dev/null\n') + print('Note: "sudo" might be required because the path is in your home directory.') + + return 0 + + +if __name__ == '__main__': + if 'install' in sys.argv: + sys.exit(install()) + elif 'print-code' in sys.argv: + sys.exit(print_code()) + else: + print(f'python-devtools v{VERSION}, CLI usage: `python -m devtools install|print-code`') + sys.exit(1) diff --git a/devtools/ansi.py b/devtools/ansi.py index e0e7b3d..e31a2d1 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -6,10 +6,10 @@ MYPY = False if MYPY: - from typing import Any, Union + from typing import Any, Mapping, Union -def strip_ansi(value): +def strip_ansi(value: str) -> str: import re return re.sub('\033\\[((?:\\d|;)*)([a-zA-Z])', '', value) @@ -62,7 +62,7 @@ class Style(IntEnum): # this is a meta value used for the "Style" instance which is the "style" function function = -1 - def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bool = True) -> str: + def __call__(self, input: 'Any', *styles: 'Union[Style, int, str]', reset: bool = True, apply: bool = True) -> str: """ Styles text with ANSI styles and returns the new string. @@ -91,7 +91,7 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo s = self.styles[s] except KeyError: raise ValueError(f'invalid style "{s}"') - codes.append(_style_as_int(s.value)) + codes.append(_style_as_int(s.value)) # type: ignore if codes: r = _as_ansi(';'.join(codes)) + text @@ -103,20 +103,23 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo return r @property - def styles(self): + def styles(self) -> 'Mapping[str, Style]': return self.__class__.__members__ - def __repr__(self): + def __repr__(self) -> str: if self == self.function: return '' else: return super().__repr__() - def __str__(self): + def __str__(self) -> str: if self == self.function: return repr(self) else: - return super().__str__() + # this matches `super().__str__()` in python 3.7 - 3.10 + # required since IntEnum.__str__ was changed in 3.11, + # see https://docs.python.org/3/library/enum.html#enum.IntEnum + return f'{self.__class__.__name__}.{self._name_}' def _style_as_int(v: 'Union[Style, int]') -> str: @@ -139,14 +142,22 @@ class StylePrint: for that mistake. """ - def __call__(self, input, *styles, reset=True, flush=True, file=None, **print_kwargs): + def __call__( + self, + input: str, + *styles: 'Union[Style, int, str]', + reset: bool = True, + flush: bool = True, + file: 'Any' = None, + **print_kwargs: 'Any', + ) -> None: text = sformat(input, *styles, reset=reset, apply=isatty(file)) print(text, flush=flush, file=file, **print_kwargs) - def __getattr__(self, item): + def __getattr__(self, item: str) -> str: return getattr(sformat, item) - def __repr__(self): + def __repr__(self) -> str: return '' diff --git a/devtools/debug.py b/devtools/debug.py index c56588c..89cda24 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -10,7 +10,7 @@ MYPY = False if MYPY: from types import FrameType - from typing import Any, Generator, List, Optional + from typing import Any, Generator, List, Optional, Union pformat = PrettyFormat( indent_step=int(os.getenv('PY_DEVTOOLS_INDENT', 4)), @@ -18,12 +18,14 @@ width=int(os.getenv('PY_DEVTOOLS_WIDTH', 120)), yield_from_generators=env_true('PY_DEVTOOLS_YIELD_FROM_GEN', True), ) +# required for type hinting because I (stupidly) added methods called `str` +StrType = str class DebugArgument: __slots__ = 'value', 'name', 'extra' - def __init__(self, value, *, name=None, **extra): + def __init__(self, value: 'Any', *, name: 'Optional[str]' = None, **extra: 'Any') -> None: self.value = value self.name = name self.extra = [] @@ -35,7 +37,7 @@ def __init__(self, value, *, name=None, **extra): self.extra.append(('len', length)) self.extra += [(k, v) for k, v in extra.items() if v is not None] - def str(self, highlight=False) -> str: + def str(self, highlight: bool = False) -> StrType: s = '' if self.name and not is_literal(self.name): s = f'{sformat(self.name, sformat.blue, apply=highlight)}: ' @@ -54,7 +56,7 @@ def str(self, highlight=False) -> str: s += suffix return s - def __str__(self) -> str: + def __str__(self) -> StrType: return self.str() @@ -66,14 +68,22 @@ class DebugOutput: arg_class = DebugArgument __slots__ = 'filename', 'lineno', 'frame', 'arguments', 'warning' - def __init__(self, *, filename: str, lineno: int, frame: str, arguments: 'List[DebugArgument]', warning=None): + def __init__( + self, + *, + filename: str, + lineno: int, + frame: str, + arguments: 'List[DebugArgument]', + warning: 'Union[None, str, bool]' = None, + ) -> None: self.filename = filename self.lineno = lineno self.frame = frame self.arguments = arguments self.warning = warning - def str(self, highlight=False) -> str: + def str(self, highlight: bool = False) -> StrType: if highlight: prefix = ( f'{sformat(self.filename, sformat.magenta)}:{sformat(self.lineno, sformat.green)} ' @@ -87,10 +97,10 @@ def str(self, highlight=False) -> str: prefix += f' ({self.warning})' return f'{prefix}\n ' + '\n '.join(a.str(highlight) for a in self.arguments) - def __str__(self) -> str: + def __str__(self) -> StrType: return self.str() - def __repr__(self) -> str: + def __repr__(self) -> StrType: arguments = ' '.join(str(a) for a in self.arguments) return f'' @@ -102,8 +112,15 @@ def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bo self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True) self._highlight = highlight - def __call__(self, *args, file_=None, flush_=True, **kwargs) -> 'Any': - d_out = self._process(args, kwargs) + def __call__( + self, + *args: 'Any', + file_: 'Any' = None, + flush_: bool = True, + frame_depth_: int = 2, + **kwargs: 'Any', + ) -> 'Any': + d_out = self._process(args, kwargs, frame_depth_) s = d_out.str(use_highlight(self._highlight, file_)) print(s, file=file_, flush=flush_) if kwargs: @@ -113,24 +130,24 @@ def __call__(self, *args, file_=None, flush_=True, **kwargs) -> 'Any': else: return args - def format(self, *args, **kwargs) -> DebugOutput: - return self._process(args, kwargs) + def format(self, *args: 'Any', frame_depth_: int = 2, **kwargs: 'Any') -> DebugOutput: + return self._process(args, kwargs, frame_depth_) - def breakpoint(self): + def breakpoint(self) -> None: import pdb pdb.Pdb(skip=['devtools.*']).set_trace() - def timer(self, name=None, *, verbose=True, file=None, dp=3) -> Timer: + def timer(self, name: 'Optional[str]' = None, *, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> Timer: return Timer(name=name, verbose=verbose, file=file, dp=dp) - def _process(self, args, kwargs) -> DebugOutput: + def _process(self, args: 'Any', kwargs: 'Any', frame_depth: int) -> DebugOutput: """ - BEWARE: this must be called from a function exactly 2 levels below the top of the stack. + BEWARE: this must be called from a function exactly `frame_depth` levels below the top of the stack. """ # HELP: any errors other than ValueError from _getframe? If so please submit an issue try: - call_frame: 'FrameType' = sys._getframe(2) + call_frame: 'FrameType' = sys._getframe(frame_depth) except ValueError: # "If [ValueError] is deeper than the call stack, ValueError is raised" return self.output_class( @@ -168,7 +185,7 @@ def _process(self, args, kwargs) -> DebugOutput: ex = source.executing(call_frame) function = ex.code_qualname() if not ex.node: - warning = "executing failed to find the calling node" + warning = 'executing failed to find the calling node' arguments = list(self._args_inspection_failed(args, kwargs)) else: arguments = list(self._process_args(ex, args, kwargs)) @@ -181,13 +198,13 @@ def _process(self, args, kwargs) -> DebugOutput: warning=self._show_warnings and warning, ) - def _args_inspection_failed(self, args, kwargs): + def _args_inspection_failed(self, args: 'Any', kwargs: 'Any') -> 'Generator[DebugArgument, None, None]': for arg in args: yield self.output_class.arg_class(arg) for name, value in kwargs.items(): yield self.output_class.arg_class(value, name=name) - def _process_args(self, ex, args, kwargs) -> 'Generator[DebugArgument, None, None]': + def _process_args(self, ex: 'Any', args: 'Any', kwargs: 'Any') -> 'Generator[DebugArgument, None, None]': import ast func_ast = ex.node diff --git a/devtools/prettier.py b/devtools/prettier.py index 36f2d57..c45bc6a 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -1,3 +1,4 @@ +import ast import io import os from collections import OrderedDict @@ -12,10 +13,15 @@ cache = lru_cache() +try: + from sqlalchemy import inspect as sa_inspect +except ImportError: + sa_inspect = None # type: ignore[assignment] + __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: - from typing import Any, Iterable, Tuple, Union + from typing import Any, Callable, Iterable, List, Set, Tuple, Union PARENTHESES_LOOKUP = [ (list, '[', ']'), @@ -27,7 +33,7 @@ PRETTY_KEY = '__prettier_formatted_value__' -def fmt(v): +def fmt(v: 'Any') -> 'Any': return {PRETTY_KEY: v} @@ -36,7 +42,7 @@ class SkipPretty(Exception): @cache -def get_pygments(): +def get_pygments() -> 'Tuple[Any, Any, Any]': try: import pygments from pygments.formatters import Terminal256Formatter @@ -54,12 +60,12 @@ def get_pygments(): class PrettyFormat: def __init__( self, - indent_step=4, - indent_char=' ', - repr_strings=False, - simple_cutoff=10, - width=120, - yield_from_generators=True, + indent_step: int = 4, + indent_char: str = ' ', + repr_strings: bool = False, + simple_cutoff: int = 10, + width: int = 120, + yield_from_generators: bool = True, ): self._indent_step = indent_step self._c = indent_char @@ -67,20 +73,21 @@ def __init__( self._repr_generators = not yield_from_generators self._simple_cutoff = simple_cutoff self._width = width - self._type_lookup = [ + self._type_lookup: 'List[Tuple[Any, Callable[[Any, str, int, int], None]]]' = [ (dict, self._format_dict), ((str, bytes), self._format_str_bytes), (tuple, self._format_tuples), ((list, set, frozenset), self._format_list_like), (bytearray, self._format_bytearray), (generator_types, self._format_generator), - # put this last as the check can be slow + # put these last as the check can be slow + (ast.AST, self._format_ast_expression), (LaxMapping, self._format_dict), (DataClassType, self._format_dataclass), (SQLAlchemyClassType, self._format_sqlalchemy_class), ] - def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, highlight: bool = False): + def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, highlight: bool = False) -> str: self._stream = io.StringIO() self._format(value, indent_current=indent, indent_first=indent_first) s = self._stream.getvalue() @@ -90,7 +97,7 @@ def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, s = pygments.highlight(s, lexer=pyg_lexer, formatter=pyg_formatter).rstrip('\n') return s - def _format(self, value: 'Any', indent_current: int, indent_first: bool): + def _format(self, value: 'Any', indent_current: int, indent_first: bool) -> None: if indent_first: self._stream.write(indent_current * self._c) @@ -110,7 +117,7 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): except SkipPretty: pass else: - return + return None value_repr = repr(value) if len(value_repr) <= self._simple_cutoff and not isinstance(value, generator_types): @@ -120,11 +127,11 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): for t, func in self._type_lookup: if isinstance(value, t): func(value, value_repr, indent_current, indent_new) - return + return None self._format_raw(value, value_repr, indent_current, indent_new) - def _render_pretty(self, gen, indent: int): + def _render_pretty(self, gen: 'Iterable[Any]', indent: int) -> None: prefix = False for v in gen: if isinstance(v, int) and v in {-1, 0, 1}: @@ -144,7 +151,7 @@ def _render_pretty(self, gen, indent: int): # shouldn't happen but will self._stream.write(repr(v)) - def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: open_, before_, split_, after_, close_ = '{\n', indent_new * self._c, ': ', ',\n', '}' if isinstance(value, OrderedDict): open_, split_, after_, close_ = 'OrderedDict([\n', ', ', '),\n', '])' @@ -161,7 +168,9 @@ def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: in self._stream.write(after_) self._stream.write(indent_current * self._c + close_) - def _format_list_like(self, value: 'Union[list, tuple, set]', _: str, indent_current: int, indent_new: int): + def _format_list_like( + self, value: 'Union[List[Any], Tuple[Any, ...], Set[Any]]', _: str, indent_current: int, indent_new: int + ) -> None: open_, close_ = '(', ')' for t, *oc in PARENTHESES_LOOKUP: if isinstance(value, t): @@ -174,16 +183,18 @@ def _format_list_like(self, value: 'Union[list, tuple, set]', _: str, indent_cur self._stream.write(',\n') self._stream.write(indent_current * self._c + close_) - def _format_tuples(self, value: tuple, value_repr: str, indent_current: int, indent_new: int): + def _format_tuples(self, value: 'Tuple[Any, ...]', value_repr: str, indent_current: int, indent_new: int) -> None: fields = getattr(value, '_fields', None) if fields: # named tuple self._format_fields(value, zip(fields, value), indent_current, indent_new) else: # normal tuples are just like other similar iterables - return self._format_list_like(value, value_repr, indent_current, indent_new) + self._format_list_like(value, value_repr, indent_current, indent_new) - def _format_str_bytes(self, value: 'Union[str, bytes]', value_repr: str, indent_current: int, indent_new: int): + def _format_str_bytes( + self, value: 'Union[str, bytes]', value_repr: str, indent_current: int, indent_new: int + ) -> None: if self._repr_strings: self._stream.write(value_repr) else: @@ -193,14 +204,14 @@ def _format_str_bytes(self, value: 'Union[str, bytes]', value_repr: str, indent_ else: self._stream.write(value_repr) - def _str_lines(self, lines: 'Iterable[str]', indent_current: int, indent_new: int) -> None: + def _str_lines(self, lines: 'Iterable[Union[str, bytes]]', indent_current: int, indent_new: int) -> None: self._stream.write('(\n') prefix = indent_new * self._c for line in lines: self._stream.write(prefix + repr(line) + '\n') self._stream.write(indent_current * self._c + ')') - def _wrap_lines(self, s, indent_new) -> 'Generator[str, None, None]': + def _wrap_lines(self, s: 'Union[str, bytes]', indent_new: int) -> 'Generator[Union[str, bytes], None, None]': width = self._width - indent_new - 3 for line in s.splitlines(True): start = 0 @@ -209,7 +220,9 @@ def _wrap_lines(self, s, indent_new) -> 'Generator[str, None, None]': start = pos yield line[start:] - def _format_generator(self, value: Generator, value_repr: str, indent_current: int, indent_new: int): + def _format_generator( + self, value: 'Generator[Any, None, None]', value_repr: str, indent_current: int, indent_new: int + ) -> None: if self._repr_generators: self._stream.write(value_repr) else: @@ -224,25 +237,45 @@ def _format_generator(self, value: Generator, value_repr: str, indent_current: i self._stream.write(',\n') self._stream.write(indent_current * self._c + ')') - def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: self._stream.write('bytearray') lines = self._wrap_lines(bytes(value), indent_new) self._str_lines(lines, indent_current, indent_new) - def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int): - from dataclasses import asdict - - self._format_fields(value, asdict(value).items(), indent_current, indent_new) + def _format_ast_expression(self, value: ast.AST, _: str, indent_current: int, indent_new: int) -> None: + try: + s = ast.dump(value, indent=self._indent_step) + except TypeError: + # no indent before 3.9 + s = ast.dump(value) + lines = s.splitlines(True) + self._stream.write(lines[0]) + for line in lines[1:]: + self._stream.write(indent_current * self._c + line) + + def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: + try: + field_items = value.__dict__.items() + except AttributeError: + # slots + field_items = ((f, getattr(value, f)) for f in value.__slots__) + self._format_fields(value, field_items, indent_current, indent_new) + + def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: + if sa_inspect is not None: + state = sa_inspect(value) + deferred = state.unloaded + else: + deferred = set() - def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int): fields = [ - (field, getattr(value, field)) + (field, getattr(value, field) if field not in deferred else '') for field in dir(value) if not (field.startswith('_') or field in ['metadata', 'registry']) ] self._format_fields(value, fields, indent_current, indent_new) - def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new: int): + def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new: int) -> None: lines = value_repr.splitlines(True) if len(lines) > 1 or (len(value_repr) + indent_current) >= self._width: self._stream.write('(\n') @@ -276,6 +309,6 @@ def _format_fields( force_highlight = env_true('PY_DEVTOOLS_HIGHLIGHT', None) -def pprint(s, file=None): +def pprint(s: 'Any', file: 'Any' = None) -> None: highlight = isatty(file) if force_highlight is None else force_highlight print(pformat(s, highlight=highlight), file=file, flush=True) diff --git a/devtools/py.typed b/devtools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/devtools/pytest_plugin.py b/devtools/pytest_plugin.py new file mode 100644 index 0000000..f80efd3 --- /dev/null +++ b/devtools/pytest_plugin.py @@ -0,0 +1,301 @@ +from __future__ import annotations as _annotations + +import ast +import builtins +import sys +import textwrap +from contextvars import ContextVar +from dataclasses import dataclass +from enum import Enum +from functools import lru_cache +from itertools import groupby +from pathlib import Path +from types import FrameType +from typing import TYPE_CHECKING, Any, Callable, Generator, Sized + +import pytest +from executing import Source + +from . import debug + +if TYPE_CHECKING: + pass + +__all__ = ('insert_assert',) + + +@dataclass +class ToReplace: + file: Path + start_line: int + end_line: int | None + code: str + + +to_replace: list[ToReplace] = [] +insert_assert_calls: ContextVar[int] = ContextVar('insert_assert_calls', default=0) +insert_assert_summary: ContextVar[list[str]] = ContextVar('insert_assert_summary') + + +def insert_assert(value: Any) -> int: + call_frame: FrameType = sys._getframe(1) + if sys.version_info < (3, 8): # pragma: no cover + raise RuntimeError('insert_assert() requires Python 3.8+') + + format_code = load_black() + ex = Source.for_frame(call_frame).executing(call_frame) + if ex.node is None: # pragma: no cover + python_code = format_code(str(custom_repr(value))) + raise RuntimeError( + f'insert_assert() was unable to find the frame from which it was called, called with:\n{python_code}' + ) + ast_arg = ex.node.args[0] # type: ignore[attr-defined] + if isinstance(ast_arg, ast.Name): + arg = ast_arg.id + else: + arg = ' '.join(map(str.strip, ex.source.asttokens().get_text(ast_arg).splitlines())) + + python_code = format_code(f'# insert_assert({arg})\nassert {arg} == {custom_repr(value)}') + + python_code = textwrap.indent(python_code, ex.node.col_offset * ' ') + to_replace.append(ToReplace(Path(call_frame.f_code.co_filename), ex.node.lineno, ex.node.end_lineno, python_code)) + calls = insert_assert_calls.get() + 1 + insert_assert_calls.set(calls) + return calls + + +def pytest_addoption(parser: Any) -> None: + parser.addoption( + '--insert-assert-print', + action='store_true', + default=False, + help='Print statements that would be substituted for insert_assert(), instead of writing to files', + ) + parser.addoption( + '--insert-assert-fail', + action='store_true', + default=False, + help='Fail tests which include one or more insert_assert() calls', + ) + + +@pytest.fixture(scope='session', autouse=True) +def insert_assert_add_to_builtins() -> None: + try: + setattr(builtins, 'insert_assert', insert_assert) + # we also install debug here since the default script doesn't install it + setattr(builtins, 'debug', debug) + except TypeError: + # happens on pypy + pass + + +@pytest.fixture(autouse=True) +def insert_assert_maybe_fail(pytestconfig: pytest.Config) -> Generator[None, None, None]: + insert_assert_calls.set(0) + yield + print_instead = pytestconfig.getoption('insert_assert_print') + if not print_instead: + count = insert_assert_calls.get() + if count: + pytest.fail(f'devtools-insert-assert: {count} assert{plural(count)} will be inserted', pytrace=False) + + +@pytest.fixture(name='insert_assert') +def insert_assert_fixture() -> Callable[[Any], int]: + return insert_assert + + +def pytest_report_teststatus(report: pytest.TestReport, config: pytest.Config) -> Any: + if report.when == 'teardown' and report.failed and 'devtools-insert-assert:' in repr(report.longrepr): + return 'insert assert', 'i', ('INSERT ASSERT', {'cyan': True}) + + +@pytest.fixture(scope='session', autouse=True) +def insert_assert_session(pytestconfig: pytest.Config) -> Generator[None, None, None]: + """ + Actual logic for updating code examples. + """ + try: + __builtins__['insert_assert'] = insert_assert + except TypeError: + # happens on pypy + pass + + yield + + if not to_replace: + return None + + print_instead = pytestconfig.getoption('insert_assert_print') + + highlight = None + if print_instead: + highlight = get_pygments() + + files = 0 + dup_count = 0 + summary = [] + for file, group in groupby(to_replace, key=lambda tr: tr.file): + # we have to substitute lines in reverse order to avoid messing up line numbers + lines = file.read_text().splitlines() + duplicates: set[int] = set() + for tr in sorted(group, key=lambda x: x.start_line, reverse=True): + if print_instead: + hr = '-' * 80 + code = highlight(tr.code) if highlight else tr.code + line_no = f'{tr.start_line}' if tr.start_line == tr.end_line else f'{tr.start_line}-{tr.end_line}' + summary.append(f'{file} - {line_no}:\n{hr}\n{code}{hr}\n') + else: + if tr.start_line in duplicates: + dup_count += 1 + else: + duplicates.add(tr.start_line) + lines[tr.start_line - 1 : tr.end_line] = tr.code.splitlines() + if not print_instead: + file.write_text('\n'.join(lines)) + files += 1 + prefix = 'Printed' if print_instead else 'Replaced' + summary.append( + f'{prefix} {len(to_replace)} insert_assert() call{plural(to_replace)} in {files} file{plural(files)}' + ) + if dup_count: + summary.append( + f'\n{dup_count} insert skipped because an assert statement on that line had already be inserted!' + ) + + insert_assert_summary.set(summary) + to_replace.clear() + + +def pytest_terminal_summary() -> None: + summary = insert_assert_summary.get(None) + if summary: + print('\n'.join(summary)) + + +def custom_repr(value: Any) -> Any: + if isinstance(value, (list, tuple, set, frozenset)): + return value.__class__(map(custom_repr, value)) + elif isinstance(value, dict): + return value.__class__((custom_repr(k), custom_repr(v)) for k, v in value.items()) + if isinstance(value, Enum): + return PlainRepr(f'{value.__class__.__name__}.{value.name}') + else: + return PlainRepr(repr(value)) + + +class PlainRepr(str): + """ + String class where repr doesn't include quotes. + """ + + def __repr__(self) -> str: + return str(self) + + +def plural(v: int | Sized) -> str: + if isinstance(v, (int, float)): + n = v + else: + n = len(v) + return '' if n == 1 else 's' + + +@lru_cache(maxsize=None) +def load_black() -> Callable[[str], str]: + """ + Build black configuration from "pyproject.toml". + + Black doesn't have a nice self-contained API for reading pyproject.toml, hence all this. + """ + try: + from black import format_file_contents + from black.files import find_pyproject_toml, parse_pyproject_toml + from black.mode import Mode, TargetVersion + from black.parsing import InvalidInput + except ImportError: + return lambda x: x + + def convert_target_version(target_version_config: Any) -> set[Any] | None: + if target_version_config is not None: + return None + elif not isinstance(target_version_config, list): + raise ValueError('Config key "target_version" must be a list') + else: + return {TargetVersion[tv.upper()] for tv in target_version_config} + + @dataclass + class ConfigArg: + config_name: str + keyword_name: str + converter: Callable[[Any], Any] + + config_mapping: list[ConfigArg] = [ + ConfigArg('target_version', 'target_versions', convert_target_version), + ConfigArg('line_length', 'line_length', int), + ConfigArg('skip_string_normalization', 'string_normalization', lambda x: not x), + ConfigArg('skip_magic_trailing_commas', 'magic_trailing_comma', lambda x: not x), + ] + + config_str = find_pyproject_toml((str(Path.cwd()),)) + mode_ = None + fast = False + if config_str: + try: + config = parse_pyproject_toml(config_str) + except (OSError, ValueError) as e: + raise ValueError(f'Error reading configuration file: {e}') + + if config: + kwargs = dict() + for config_arg in config_mapping: + try: + value = config[config_arg.config_name] + except KeyError: + pass + else: + value = config_arg.converter(value) + if value is not None: + kwargs[config_arg.keyword_name] = value + + mode_ = Mode(**kwargs) + fast = bool(config.get('fast')) + + mode = mode_ or Mode() + + def format_code(code: str) -> str: + try: + return format_file_contents(code, fast=fast, mode=mode) + except InvalidInput as e: + print('black error, you will need to format the code manually,', e) + return code + + return format_code + + +# isatty() is false inside pytest, hence calling this now +try: + std_out_istty = sys.stdout.isatty() +except Exception: + std_out_istty = False + + +@lru_cache(maxsize=None) +def get_pygments() -> Callable[[str], str] | None: # pragma: no cover + if not std_out_istty: + return None + try: + import pygments + from pygments.formatters import Terminal256Formatter + from pygments.lexers import PythonLexer + except ImportError as e: # pragma: no cover + print(e) + return None + else: + pyg_lexer, terminal_formatter = PythonLexer(), Terminal256Formatter() + + def highlight(code: str) -> str: + return pygments.highlight(code, lexer=pyg_lexer, formatter=terminal_formatter) + + return highlight diff --git a/devtools/timer.py b/devtools/timer.py index 3182d1f..822ea44 100644 --- a/devtools/timer.py +++ b/devtools/timer.py @@ -1,31 +1,38 @@ -from time import time +from time import perf_counter __all__ = ('Timer',) +MYPY = False +if MYPY: + from typing import Any, List, Optional + +# required for type hinting because I (stupidly) added methods called `str` +StrType = str + class TimerResult: - def __init__(self, name=None, verbose=True): + def __init__(self, name: 'Optional[str]' = None, verbose: bool = True) -> None: self._name = name self.verbose = verbose - self.finish = None - self.start = time() + self.finish: 'Optional[float]' = None + self.start = perf_counter() - def capture(self): - self.finish = time() + def capture(self) -> None: + self.finish = perf_counter() - def elapsed(self): + def elapsed(self) -> float: if self.finish: return self.finish - self.start else: return -1 - def str(self, dp=3): + def str(self, dp: int = 3) -> StrType: if self._name: return f'{self._name}: {self.elapsed():0.{dp}f}s elapsed' else: return f'{self.elapsed():0.{dp}f}s elapsed' - def __str__(self): + def __str__(self) -> StrType: return self.str() @@ -33,25 +40,25 @@ def __str__(self): class Timer: - def __init__(self, name=None, verbose=True, file=None, dp=3): + def __init__(self, name: 'Optional[str]' = None, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> None: self.file = file self.dp = dp self._name = name self._verbose = verbose - self.results = [] + self.results: 'List[TimerResult]' = [] - def __call__(self, name=None, verbose=None): + def __call__(self, name: 'Optional[str]' = None, verbose: 'Optional[bool]' = None) -> 'Timer': if name: self._name = name if verbose is not None: self._verbose = verbose return self - def start(self, name=None, verbose=None): + def start(self, name: 'Optional[str]' = None, verbose: 'Optional[bool]' = None) -> 'Timer': self.results.append(TimerResult(name or self._name, self._verbose if verbose is None else verbose)) return self - def capture(self, verbose=None): + def capture(self, verbose: 'Optional[bool]' = None) -> 'TimerResult': r = self.results[-1] r.capture() print_ = r.verbose if verbose is None else verbose @@ -59,14 +66,14 @@ def capture(self, verbose=None): print(r.str(self.dp), file=self.file, flush=True) return r - def summary(self, verbose=False): - times = set() + def summary(self, verbose: bool = False) -> 'List[float]': + times = [] for r in self.results: if not r.finish: r.capture() if verbose: print(f' {r.str(self.dp)}', file=self.file) - times.add(r.elapsed()) + times.append(r.elapsed()) if times: from statistics import mean, stdev @@ -84,9 +91,9 @@ def summary(self, verbose=False): raise RuntimeError('timer not started') return times - def __enter__(self): + def __enter__(self) -> 'Timer': self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, *args: 'Any') -> None: self.capture() diff --git a/devtools/utils.py b/devtools/utils.py index 034ece8..2a96765 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -14,10 +14,14 @@ MYPY = False if MYPY: - from typing import Any, Optional + from typing import Any, Optional, no_type_check +else: + def no_type_check(x: 'Any') -> 'Any': + return x -def isatty(stream=None): + +def isatty(stream: 'Any' = None) -> bool: stream = stream or sys.stdout try: return stream.isatty() @@ -25,7 +29,7 @@ def isatty(stream=None): return False -def env_true(var_name: str, alt: 'Optional[bool]' = None) -> 'Optional[bool]': +def env_true(var_name: str, alt: 'Optional[bool]' = None) -> 'Any': env = os.getenv(var_name, None) if env: return env.upper() in {'1', 'TRUE'} @@ -40,6 +44,7 @@ def env_bool(value: 'Optional[bool]', env_name: str, env_default: 'Optional[bool return value +@no_type_check def activate_win_color() -> bool: # pragma: no cover """ Activate ANSI support on windows consoles. @@ -88,14 +93,14 @@ def _set_conout_mode(new_mode, mask=0xFFFFFFFF): mode = mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING try: _set_conout_mode(mode, mask) - except WindowsError as e: + except OSError as e: if e.winerror == ERROR_INVALID_PARAMETER: return False raise return True -def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool: +def use_highlight(highlight: 'Optional[bool]' = None, file_: 'Any' = None) -> bool: highlight = env_bool(highlight, 'PY_DEVTOOLS_HIGHLIGHT', None) if highlight is not None: @@ -106,7 +111,7 @@ def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool: return isatty(file_) -def is_literal(s): +def is_literal(s: 'Any') -> bool: import ast try: @@ -133,13 +138,9 @@ class LaxMapping(metaclass=MetaLaxMapping): class MetaDataClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: - try: - from dataclasses import _is_dataclass_instance - except ImportError: - # python 3.6 - return False - else: - return _is_dataclass_instance(instance) + from dataclasses import is_dataclass + + return is_dataclass(instance) class DataClassType(metaclass=MetaDataClassType): @@ -148,13 +149,23 @@ class DataClassType(metaclass=MetaDataClassType): class MetaSQLAlchemyClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: + try: + from sqlalchemy.orm import DeclarativeBase + except ImportError: + pass + else: + if isinstance(instance, DeclarativeBase): + return True + try: from sqlalchemy.ext.declarative import DeclarativeMeta except ImportError: - return False + pass else: return isinstance(instance.__class__, DeclarativeMeta) + return False + class SQLAlchemyClassType(metaclass=MetaSQLAlchemyClassType): pass diff --git a/devtools/version.py b/devtools/version.py index 34672f0..a4ba93b 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1 @@ -__all__ = ('VERSION',) - -VERSION = '0.7.0' +VERSION = '0.12.2' diff --git a/docs/build/gen_html.py b/docs/build/gen_html.py deleted file mode 100644 index cabba3b..0000000 --- a/docs/build/gen_html.py +++ /dev/null @@ -1,32 +0,0 @@ -import os -import subprocess -import sys -from pathlib import Path - -from ansi2html import Ansi2HTMLConverter - -EX_DIR = Path(__file__).parent / '..' / 'examples' - - -def gen_examples_html(): - os.environ.update(PY_DEVTOOLS_HIGHLIGHT='true', PY_DEVTOOLS_WIDTH='80') - conv = Ansi2HTMLConverter() - fast = 'FAST' in os.environ - - for f in EX_DIR.iterdir(): - if f.suffix != '.py' or f.name == 'sitecustomize.py': - continue - output_file = EX_DIR / f'{f.stem}.html' - if fast and output_file.exists(): - print(f'HTML file already exists for {f}, skipping') - continue - - print(f'generating output for: {f}') - p = subprocess.run((sys.executable, str(f)), stdout=subprocess.PIPE, check=True) - html = conv.convert(p.stdout.decode(), full=False).strip('\r\n') - html = html.replace('docs/build/../examples/', '') - output_file.write_text(f'
\n{html}\n
\n') - - -if __name__ == '__main__': - gen_examples_html() diff --git a/docs/build/main.py b/docs/build/main.py deleted file mode 100755 index 9c36ae4..0000000 --- a/docs/build/main.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python3 -import re -import sys -from importlib.machinery import SourceFileLoader -from pathlib import Path - -THIS_DIR = Path(__file__).parent -PROJECT_ROOT = THIS_DIR / '..' / '..' - - -def main(): - history = (PROJECT_ROOT / 'HISTORY.md').read_text() - history = re.sub(r'#(\d+)', r'[#\1](https://github.com/samuelcolvin/python-devtools/issues/\1)', history) - history = re.sub(r'( +)@([\w\-]+)', r'\1[@\2](https://github.com/\2)', history, flags=re.I) - history = re.sub('@@', '@', history) - - (PROJECT_ROOT / 'docs/.history.md').write_text(history) - - version = SourceFileLoader('version', str(PROJECT_ROOT / 'devtools/version.py')).load_module() - (PROJECT_ROOT / 'docs/.version.md').write_text(f'Documentation for version: **v{version.VERSION}**\n') - - sys.path.append(str(THIS_DIR.resolve())) - from gen_html import gen_examples_html - return gen_examples_html() - - -if __name__ == '__main__': - sys.exit(main()) diff --git a/docs/examples/ansi_colours.py b/docs/examples/ansi_colours.py index 1e7c594..a89e9ca 100644 --- a/docs/examples/ansi_colours.py +++ b/docs/examples/ansi_colours.py @@ -1,4 +1,4 @@ -from devtools import sprint, sformat +from devtools import sformat, sprint sprint('this is red', sprint.red) diff --git a/docs/examples/complex.py b/docs/examples/complex.py index ea84b3b..55f1d55 100644 --- a/docs/examples/complex.py +++ b/docs/examples/complex.py @@ -1,6 +1,7 @@ -from devtools import debug import numpy as np +from devtools import debug + foo = { 'foo': np.array(range(20)), 'bar': [{'a': i, 'b': {j for j in range(1 + i * 2)}} for i in range(3)], diff --git a/docs/examples/other.py b/docs/examples/other.py index 0e569f8..7763972 100644 --- a/docs/examples/other.py +++ b/docs/examples/other.py @@ -20,7 +20,7 @@ # if used repeatedly a summary is available t3 = debug.timer() for i in [1e4, 1e6, 1e7]: - with t3('sum {}'.format(i), verbose=False): + with t3(f'sum {i}', verbose=False): sum(range(int(i))) t3.summary(verbose=True) diff --git a/docs/examples/prettier.py b/docs/examples/prettier.py index af1e4b4..0be9736 100644 --- a/docs/examples/prettier.py +++ b/docs/examples/prettier.py @@ -1,6 +1,7 @@ -from devtools import PrettyFormat, pprint, pformat import numpy as np +from devtools import PrettyFormat, pformat, pprint + v = { 'foo': {'whatever': [3, 2, 1]}, 'sentence': 'hello\nworld', diff --git a/docs/examples/sitecustomize.py b/docs/examples/sitecustomize.py deleted file mode 100644 index a1c2e28..0000000 --- a/docs/examples/sitecustomize.py +++ /dev/null @@ -1,8 +0,0 @@ -... - -try: - from devtools import debug -except ImportError: - pass -else: - __builtins__['debug'] = debug diff --git a/docs/index.md b/docs/index.md index 5098d6a..b6bc7b1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,12 +1,12 @@ # Python devtools -[![CI](https://github.com/samuelcolvin/python-devtools/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/python-devtools/actions?query=event%3Apush+branch%3Amaster+workflow%3ACI) -[![Coverage](https://codecov.io/gh/samuelcolvin/python-devtools/branch/master/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/python-devtools) +[![CI](https://github.com/samuelcolvin/python-devtools/workflows/CI/badge.svg?event=push)](https://github.com/samuelcolvin/python-devtools/actions?query=event%3Apush+branch%3Amain+workflow%3ACI) +[![Coverage](https://codecov.io/gh/samuelcolvin/python-devtools/branch/main/graph/badge.svg)](https://codecov.io/gh/samuelcolvin/python-devtools) [![pypi](https://img.shields.io/pypi/v/devtools.svg)](https://pypi.python.org/pypi/devtools) [![versions](https://img.shields.io/pypi/pyversions/devtools.svg)](https://github.com/samuelcolvin/python-devtools) -[![license](https://img.shields.io/github/license/samuelcolvin/python-devtools.svg)](https://github.com/samuelcolvin/python-devtools/blob/master/LICENSE) +[![license](https://img.shields.io/github/license/samuelcolvin/python-devtools.svg)](https://github.com/samuelcolvin/python-devtools/blob/main/LICENSE) -{!.version.md!} +{{ version }} **Python's missing debug print command and other development tools.** @@ -14,6 +14,6 @@ {!examples/example.py!} ``` -{!examples/example.html!} +{{ example_html(examples/example.py) }} Python devtools can do much more, see [Usage](./usage.md) for examples. diff --git a/docs/install.md b/docs/install.md index d81e5f6..a1ceba7 100644 --- a/docs/install.md +++ b/docs/install.md @@ -1,10 +1,8 @@ Installation is as simple as: ```bash -pip install devtools[pygments] +pip install devtools ``` -`pygments` is not required but if it's installed, output will be highlighted and easier to read. - -`devtools` has no other required dependencies except python 3.6, 3.7, 3.8, or 3.9. -If you've got python 3.6+ and `pip` installed, you're good to go. +`devtools` has [very few dependencies](https://github.com/samuelcolvin/python-devtools/blob/main/pyproject.toml#L37). +If you've got python 3.7+ and `pip` installed, you're good to go. diff --git a/docs/plugins.py b/docs/plugins.py new file mode 100755 index 0000000..5f40bf1 --- /dev/null +++ b/docs/plugins.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +import logging +import os +import re +import subprocess +import sys +from importlib.machinery import SourceFileLoader +from pathlib import Path + +from ansi2html import Ansi2HTMLConverter +from mkdocs.config import Config +from mkdocs.structure.files import Files +from mkdocs.structure.pages import Page + +THIS_DIR = Path(__file__).parent +PROJECT_ROOT = THIS_DIR / '..' + +logger = logging.getLogger('mkdocs.test_examples') + +# see mkdocs.yml for how these methods ar used +__all__ = 'on_pre_build', 'on_page_markdown', 'on_files' + + +def on_pre_build(config: Config): + build_history() + + +def on_page_markdown(markdown: str, page: Page, config: Config, files: Files) -> str: + markdown = set_version(markdown, page) + return gen_example_html(markdown) + + +def on_files(files: Files, config: Config) -> Files: + return remove_files(files) + + +def build_history(): + history = (PROJECT_ROOT / 'HISTORY.md').read_text() + history = re.sub(r'#(\d+)', r'[#\1](https://github.com/samuelcolvin/python-devtools/issues/\1)', history) + history = re.sub(r'( +)@([\w\-]+)', r'\1[@\2](https://github.com/\2)', history, flags=re.I) + history = re.sub('@@', '@', history) + output_file = THIS_DIR / '.history.md' + if not output_file.exists() or history != output_file.read_text(): + (THIS_DIR / '.history.md').write_text(history) + + +def gen_example_html(markdown: str): + return re.sub(r'{{ *example_html\((.*?)\) *}}', gen_examples_html, markdown) + + +def gen_examples_html(m: re.Match) -> str: + sys.path.append(str(THIS_DIR.resolve())) + + os.environ.update(PY_DEVTOOLS_HIGHLIGHT='true', PY_DEVTOOLS_WIDTH='80') + conv = Ansi2HTMLConverter() + name = THIS_DIR / Path(m.group(1)) + + logger.info('running %s to generate HTML...', name) + p = subprocess.run((sys.executable, str(name)), stdout=subprocess.PIPE, check=True) + html = conv.convert(p.stdout.decode(), full=False).strip('\r\n') + html = html.replace('docs/build/../examples/', '') + return f'
\n{html}\n
\n' + + +def set_version(markdown: str, page: Page) -> str: + if page.abs_url == '/': + version = SourceFileLoader('version', str(PROJECT_ROOT / 'devtools/version.py')).load_module() + version_str = f'Documentation for version: **v{version.VERSION}**' + markdown = re.sub(r'{{ *version *}}', version_str, markdown) + return markdown + + +def remove_files(files: Files) -> Files: + to_remove = [] + for file in files: + if file.src_path == 'requirements.txt': + to_remove.append(file) + elif file.src_path.startswith('__pycache__/'): + to_remove.append(file) + elif file.src_path.endswith('.py'): + to_remove.append(file) + + logger.debug('removing files: %s', [f.src_path for f in to_remove]) + for f in to_remove: + files.remove(f) + + return files diff --git a/docs/requirements.txt b/docs/requirements.txt index 20804a2..d091120 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,7 @@ -ansi2html==1.5.2 -mkdocs==1.1.2 -markdown==3.2.2 +ansi2html==1.8.0 +mkdocs==1.3.1 mkdocs-exclude==1.0.2 -mkdocs-material==5.5.0 -markdown-include==0.5.1 -pygments==2.7.4 +mkdocs-material==8.3.9 +mkdocs-simple-hooks==0.1.5 +markdown-include==0.7.0 +pygments==2.15.0 diff --git a/docs/usage.md b/docs/usage.md index ad92c16..3efce3e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ and readable way to print stuff during development. (If you know why this is, I' {!examples/example.py!} ``` -{!examples/example.html!} +{{ example_html(examples/example.py) }} `debug` is like `print` after a good night's sleep and lots of coffee: @@ -24,7 +24,7 @@ A more complex example of `debug` shows more of what it can do. {!examples/complex.py!} ``` -{!examples/complex.html!} +{{ example_html(examples/complex.py) }} ### Returning the arguments @@ -40,7 +40,7 @@ The returned arguments work as follows: {!examples/return_args.py!} ``` -{!examples/return_args.html!} +{{ example_html(examples/return_args.py) }} ## Other debug tools @@ -54,7 +54,7 @@ The debug namespace includes a number of other useful functions: {!examples/other.py!} ``` -{!examples/other.html!} +{{ example_html(examples/other.py) }} ### Prettier print @@ -69,10 +69,10 @@ in `debug()`, but it can also be used directly: {!examples/prettier.py!} ``` -{!examples/prettier.html!} +{{ example_html(examples/prettier.py) }} For more details on prettier printing, see -[`prettier.py`](https://github.com/samuelcolvin/python-devtools/blob/master/devtools/prettier.py). +[`prettier.py`](https://github.com/samuelcolvin/python-devtools/blob/main/devtools/prettier.py). ## ANSI terminal colours @@ -81,21 +81,49 @@ For more details on prettier printing, see ``` For more details on ansi colours, see -[ansi.py](https://github.com/samuelcolvin/python-devtools/blob/master/devtools/ansi.py). +[ansi.py](https://github.com/samuelcolvin/python-devtools/blob/main/devtools/ansi.py). -## Usage without import +## Usage without Import We all know the annoyance of running code only to discover a missing import, this can be particularly frustrating when the function you're using isn't used except during development. -You can setup your environment to make `debug` available at all times by editing `sitecustomize.py`, -with ubuntu and python3.6 this file can be found at `/usr/lib/python3.6/sitecustomize.py` but you might -need to look elsewhere depending on your OS/python version. +devtool's `debug` function can be used without import if you add `debug` to `__builtins__` +in `sitecustomize.py`. -Add the following to `sitecustomize.py` +Two ways to do this: + +### Automatic install + +!!! warning + This is experimental, please [create an issue](https://github.com/samuelcolvin/python-devtools/issues) + if you encounter any problems. + +To install `debug` into `__builtins__` automatically, run: + +```bash +python -m devtools install +``` + +This command won't write to any files, but it should print a command for you to run to add/edit `sitecustomize.py`. + +### Manual install + +To manually add `debug` to `__builtins__`, add the following to `sitecustomize.py` or any code +which is always imported. ```py -{!examples/sitecustomize.py!} +import sys +# we don't install here for pytest as it breaks pytest, it is +# installed later by a pytest fixture +if not sys.argv[0].endswith('pytest'): + import builtins + try: + from devtools import debug + except ImportError: + pass + else: + setattr(builtins, 'debug', debug) ``` The `ImportError` exception is important since you'll want python to run fine even if *devtools* isn't installed. diff --git a/mkdocs.yml b/mkdocs.yml index d6de97d..b088763 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,9 +12,16 @@ theme: repo_name: samuelcolvin/python-devtools repo_url: https://github.com/samuelcolvin/python-devtools -google_analytics: -- 'UA-62733018-4' -- 'auto' + +extra: + analytics: + provider: google + property: UA-62733018-4 + social: + - icon: fontawesome/brands/github-alt + link: https://github.com/samuelcolvin/python-devtools + - icon: fontawesome/brands/twitter + link: https://twitter.com/samuel_colvin extra_css: - 'theme/customization.css' @@ -36,6 +43,11 @@ markdown_extensions: - codehilite - extra - attr_list +- pymdownx.highlight: + anchor_linenums: true +- pymdownx.inlinehilite +- pymdownx.snippets +- pymdownx.superfences plugins: - search @@ -44,3 +56,8 @@ plugins: - build/* - examples/* - requirements.txt +- mkdocs-simple-hooks: + hooks: + on_pre_build: 'docs.plugins:on_pre_build' + on_files: 'docs.plugins:on_files' + on_page_markdown: 'docs.plugins:on_page_markdown' diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..28a9ecf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,99 @@ +[build-system] +requires = ['hatchling'] +build-backend = 'hatchling.build' + +[tool.hatch.version] +path = 'devtools/version.py' + +[project] +name = 'devtools' +description = "Python's missing debug print command, and more." +authors = [{name = 'Samuel Colvin', email = 's@muelcolvin.com'}] +license = {file = 'LICENSE'} +readme = 'README.md' +classifiers = [ + 'Development Status :: 5 - Production/Stable', + 'Intended Audience :: Developers', + 'Intended Audience :: Education', + 'Intended Audience :: Information Technology', + 'Intended Audience :: Science/Research', + 'Intended Audience :: System Administrators', + 'Operating System :: Unix', + 'Operating System :: POSIX :: Linux', + 'Environment :: Console', + 'Environment :: MacOS X', + 'License :: OSI Approved :: MIT License', + 'Programming Language :: Python :: 3 :: Only', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet', + 'Typing :: Typed', +] +requires-python = '>=3.7' +dependencies = [ + 'executing>=1.1.1', + 'asttokens>=2.0.0,<3.0.0', + 'Pygments>=2.15.0', +] +# keep this meaningless group around to avoid breaking installs using `pip install devtools[pygments]` +optional-dependencies = {pygments = [] } +dynamic = ['version'] + +[project.urls] +Homepage = 'https://github.com/samuelcolvin/python-devtools' +Documentation = 'https://python-devtools.helpmanual.io' +Funding = 'https://github.com/sponsors/samuelcolvin' +Source = 'https://github.com/samuelcolvin/python-devtools' +Changelog = 'https://github.com/samuelcolvin/python-devtools/releases' + +[project.entry-points.pytest11] +devtools = 'devtools.pytest_plugin' + +[tool.pytest.ini_options] +testpaths = 'tests' +filterwarnings = 'error' + +[tool.coverage.run] +source = ['devtools'] +branch = true +omit = ['devtools/__main__.py'] + +[tool.coverage.report] +precision = 2 +exclude_lines = [ + 'pragma: no cover', + 'raise NotImplementedError', + 'raise NotImplemented', + 'if TYPE_CHECKING:', + 'if MYPY:', + '@overload', +] + +[tool.black] +color = true +line-length = 120 +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] +skip-string-normalization = true +extend-exclude = ['tests/test_expr_render.py'] + +[tool.ruff] +line-length = 120 +exclude = ['cases_update'] +extend-select = ['Q', 'RUF100', 'C90', 'UP', 'I'] +flake8-quotes = {inline-quotes = 'single', multiline-quotes = 'double'} +mccabe = { max-complexity = 14 } +isort = { known-first-party = ['devtools'] } +target-version = 'py37' + +[tool.mypy] +show_error_codes = true +strict = true +warn_return_any = false + +[[tool.mypy.overrides]] +module = ['executing.*', 'pygments.*'] +ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 357dec2..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ --r docs/requirements.txt --r tests/requirements-linting.txt --r tests/requirements.txt diff --git a/requirements/all.txt b/requirements/all.txt new file mode 100644 index 0000000..3e6af75 --- /dev/null +++ b/requirements/all.txt @@ -0,0 +1,4 @@ +-r ./docs.txt +-r ./linting.txt +-r ./testing.txt +-r ./pyproject.txt diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 0000000..caf159b --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,8 @@ +ansi2html +mkdocs +mkdocs-exclude +mkdocs-material +mkdocs-simple-hooks +markdown-include +ruff +numpy diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..b2d5b9f --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,79 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/docs.txt --resolver=backtracking requirements/docs.in +# +ansi2html==1.8.0 + # via -r requirements/docs.in +certifi==2023.7.22 + # via requests +charset-normalizer==3.1.0 + # via requests +click==8.1.3 + # via mkdocs +colorama==0.4.6 + # via mkdocs-material +ghp-import==2.1.0 + # via mkdocs +idna==3.4 + # via requests +jinja2==3.1.2 + # via + # mkdocs + # mkdocs-material +markdown==3.3.7 + # via + # markdown-include + # mkdocs + # mkdocs-material + # pymdown-extensions +markdown-include==0.8.1 + # via -r requirements/docs.in +markupsafe==2.1.2 + # via jinja2 +mergedeep==1.3.4 + # via mkdocs +mkdocs==1.4.2 + # via + # -r requirements/docs.in + # mkdocs-exclude + # mkdocs-material + # mkdocs-simple-hooks +mkdocs-exclude==1.0.2 + # via -r requirements/docs.in +mkdocs-material==9.1.5 + # via -r requirements/docs.in +mkdocs-material-extensions==1.1.1 + # via mkdocs-material +mkdocs-simple-hooks==0.1.5 + # via -r requirements/docs.in +numpy==1.24.2 + # via -r requirements/docs.in +packaging==23.0 + # via mkdocs +pygments==2.15.0 + # via mkdocs-material +pymdown-extensions==10.0 + # via mkdocs-material +python-dateutil==2.8.2 + # via ghp-import +pyyaml==6.0 + # via + # mkdocs + # pymdown-extensions + # pyyaml-env-tag +pyyaml-env-tag==0.1 + # via mkdocs +regex==2023.3.23 + # via mkdocs-material +requests==2.31.0 + # via mkdocs-material +ruff==0.0.261 + # via -r requirements/docs.in +six==1.16.0 + # via python-dateutil +urllib3==1.26.15 + # via requests +watchdog==3.0.0 + # via mkdocs diff --git a/requirements/linting.in b/requirements/linting.in new file mode 100644 index 0000000..41aa444 --- /dev/null +++ b/requirements/linting.in @@ -0,0 +1,6 @@ +black +mypy +ruff +# required so mypy can find stubs +sqlalchemy +pytest diff --git a/requirements/linting.txt b/requirements/linting.txt new file mode 100644 index 0000000..bd6c1fc --- /dev/null +++ b/requirements/linting.txt @@ -0,0 +1,47 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/linting.txt --resolver=backtracking requirements/linting.in +# +attrs==22.2.0 + # via pytest +black==23.3.0 + # via -r requirements/linting.in +click==8.1.3 + # via black +exceptiongroup==1.1.1 + # via pytest +iniconfig==2.0.0 + # via pytest +mypy==1.1.1 + # via -r requirements/linting.in +mypy-extensions==1.0.0 + # via + # black + # mypy +packaging==23.0 + # via + # black + # pytest +pathspec==0.11.1 + # via black +platformdirs==3.2.0 + # via black +pluggy==1.0.0 + # via pytest +pytest==7.2.2 + # via -r requirements/linting.in +ruff==0.0.261 + # via -r requirements/linting.in +sqlalchemy==2.0.8 + # via -r requirements/linting.in +tomli==2.0.1 + # via + # black + # mypy + # pytest +typing-extensions==4.5.0 + # via + # mypy + # sqlalchemy diff --git a/requirements/pyproject.txt b/requirements/pyproject.txt new file mode 100644 index 0000000..d385c38 --- /dev/null +++ b/requirements/pyproject.txt @@ -0,0 +1,12 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/pyproject.txt --resolver=backtracking pyproject.toml +# +asttokens==2.2.1 + # via devtools (pyproject.toml) +executing==1.2.0 + # via devtools (pyproject.toml) +six==1.16.0 + # via asttokens diff --git a/requirements/testing.in b/requirements/testing.in new file mode 100644 index 0000000..6a3232a --- /dev/null +++ b/requirements/testing.in @@ -0,0 +1,13 @@ +coverage[toml] +pytest +pytest-mock +pytest-pretty +# these packages are used in tests so install the latest version +# no binaries for 3.7 +asyncpg; python_version>='3.8' +black +multidict; python_version>='3.8' +# no version is compatible with 3.7 and 3.11 +numpy; python_version>='3.8' +pydantic +sqlalchemy diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..a3b4099 --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,66 @@ +# +# This file is autogenerated by pip-compile with Python 3.10 +# by the following command: +# +# pip-compile --output-file=requirements/testing.txt --resolver=backtracking requirements/testing.in +# +asyncpg==0.27.0 ; python_version >= "3.8" + # via -r requirements/testing.in +attrs==22.2.0 + # via pytest +black==23.3.0 + # via -r requirements/testing.in +click==8.1.3 + # via black +coverage[toml]==7.2.2 + # via -r requirements/testing.in +exceptiongroup==1.1.3 + # via pytest +iniconfig==2.0.0 + # via pytest +markdown-it-py==2.2.0 + # via rich +mdurl==0.1.2 + # via markdown-it-py +multidict==6.0.4 ; python_version >= "3.8" + # via -r requirements/testing.in +mypy-extensions==1.0.0 + # via black +numpy==1.24.2 ; python_version >= "3.8" + # via -r requirements/testing.in +packaging==23.0 + # via + # black + # pytest +pathspec==0.11.1 + # via black +platformdirs==3.2.0 + # via black +pluggy==1.0.0 + # via pytest +pydantic==1.10.7 + # via -r requirements/testing.in +pygments==2.15.0 + # via rich +pytest==7.2.2 + # via + # -r requirements/testing.in + # pytest-mock + # pytest-pretty +pytest-mock==3.10.0 + # via -r requirements/testing.in +pytest-pretty==1.2.0 + # via -r requirements/testing.in +rich==13.3.3 + # via pytest-pretty +sqlalchemy==2.0.8 + # via -r requirements/testing.in +tomli==2.0.1 + # via + # black + # coverage + # pytest +typing-extensions==4.5.0 + # via + # pydantic + # sqlalchemy diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index 475ba51..0000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -3.7 diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f58b3c4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[tool:pytest] -testpaths = tests -filterwarnings = error - -[flake8] -max-line-length = 120 -max-complexity = 12 - -[coverage:run] -source = devtools -branch = True - -[coverage:report] -precision = 2 -exclude_lines = - pragma: no cover - raise NotImplementedError - raise NotImplemented - if MYPY: - @overload - -[isort] -line_length=120 -known_first_party=devtools -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -combine_as_imports=True diff --git a/setup.py b/setup.py deleted file mode 100644 index d699598..0000000 --- a/setup.py +++ /dev/null @@ -1,61 +0,0 @@ -import re -from importlib.machinery import SourceFileLoader -from pathlib import Path -from setuptools import setup - -description = "Python's missing debug print command and other development tools." -THIS_DIR = Path(__file__).resolve().parent -try: - history = (THIS_DIR / 'HISTORY.md').read_text() - history = re.sub(r'#(\d+)', r'[#\1](https://github.com/samuelcolvin/python-devtools/issues/\1)', history) - history = re.sub(r'( +)@([\w\-]+)', r'\1[@\2](https://github.com/\2)', history, flags=re.I) - history = re.sub('@@', '@', history) - - long_description = (THIS_DIR / 'README.md').read_text() + '\n\n' + history -except FileNotFoundError: - long_description = description + '.\n\nSee https://python-devtools.helpmanual.io/ for documentation.' - -# avoid loading the package before requirements are installed: -version = SourceFileLoader('version', 'devtools/version.py').load_module() - -setup( - name='devtools', - version=str(version.VERSION), - description=description, - long_description=long_description, - long_description_content_type='text/markdown', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Operating System :: Unix', - 'Operating System :: POSIX :: Linux', - 'Environment :: MacOS X', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - author='Samuel Colvin', - author_email='s@muelcolvin.com', - url='https://github.com/samuelcolvin/python-devtools', - license='MIT', - packages=['devtools'], - python_requires='>=3.6', - install_requires=[ - 'executing>=0.8.0,<1.0.0', - 'asttokens>=2.0.0,<3.0.0', - ], - extras_require={ - 'pygments': ['Pygments>=2.2.0'], - }, - zip_safe=True, -) diff --git a/tests/conftest.py b/tests/conftest.py index 2591e75..2a62df2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,7 @@ import os +pytest_plugins = ['pytester'] + def pytest_sessionstart(session): os.environ.pop('PY_DEVTOOLS_HIGHLIGHT', None) diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt deleted file mode 100644 index 593bd2e..0000000 --- a/tests/requirements-linting.txt +++ /dev/null @@ -1,6 +0,0 @@ -black==20.8b1 -flake8==3.9.2 -isort==5.9.3 -mypy==0.910 -pycodestyle==2.7.0 -pyflakes==2.3.1 diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 3570e26..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,12 +0,0 @@ -coverage==5.5 -Pygments==2.7.4 -pytest==6.2.5 -pytest-cov==2.12.1 -pytest-mock==3.6.1 -pytest-sugar==0.9.4 -pytest-toolbox==0.4 -pydantic -asyncpg -numpy -multidict -sqlalchemy \ No newline at end of file diff --git a/tests/test_custom_pretty.py b/tests/test_custom_pretty.py index d63392b..552e5d3 100644 --- a/tests/test_custom_pretty.py +++ b/tests/test_custom_pretty.py @@ -23,12 +23,15 @@ def __pretty__(self, fmt, **kwargs): my_cls = CustomCls() v = pformat(my_cls) - assert v == """\ + assert ( + v + == """\ Thing( [], [0], [0, 1], )""" + ) def test_skip(): diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index 6b30bad..eed9469 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -48,14 +48,14 @@ def test_exotic_types(): (a for a in aa), ) s = normalise_output(str(v)) - print('\n---\n{}\n---'.format(v)) + print(f'\n---\n{v}\n---') # Generator expression source changed in 3.8 to include parentheses, see: # https://github.com/gristlabs/asttokens/pull/50 # https://bugs.python.org/issue31241 - genexpr_source = "a for a in aa" + genexpr_source = 'a for a in aa' if sys.version_info[:2] > (3, 7): - genexpr_source = f"({genexpr_source})" + genexpr_source = f'({genexpr_source})' assert ( "tests/test_expr_render.py: test_exotic_types\n" diff --git a/tests/test_insert_assert.py b/tests/test_insert_assert.py new file mode 100644 index 0000000..299cee8 --- /dev/null +++ b/tests/test_insert_assert.py @@ -0,0 +1,169 @@ +import os +import sys + +import pytest + +from devtools.pytest_plugin import load_black + +pytestmark = pytest.mark.skipif(sys.version_info < (3, 8), reason='requires Python 3.8+') + + +config = "pytest_plugins = ['devtools.pytest_plugin']" +# language=Python +default_test = """\ +def test_ok(): + assert 1 + 2 == 3 + +def test_string_assert(insert_assert): + thing = 'foobar' + insert_assert(thing)\ +""" + + +def test_insert_assert(pytester_pretty): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + test_file = pytester_pretty.makepyfile(default_test) + result = pytester_pretty.runpytest() + result.assert_outcomes(passed=2) + # print(result.outlines) + assert test_file.read_text() == ( + 'def test_ok():\n' + ' assert 1 + 2 == 3\n' + '\n' + 'def test_string_assert(insert_assert):\n' + " thing = 'foobar'\n" + ' # insert_assert(thing)\n' + ' assert thing == "foobar"' + ) + + +def test_insert_assert_no_pretty(pytester): + os.environ.pop('CI', None) + pytester.makeconftest(config) + test_file = pytester.makepyfile(default_test) + result = pytester.runpytest('-p', 'no:pretty') + result.assert_outcomes(passed=2) + assert test_file.read_text() == ( + 'def test_ok():\n' + ' assert 1 + 2 == 3\n' + '\n' + 'def test_string_assert(insert_assert):\n' + " thing = 'foobar'\n" + ' # insert_assert(thing)\n' + ' assert thing == "foobar"' + ) + + +def test_insert_assert_print(pytester_pretty, capsys): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + test_file = pytester_pretty.makepyfile(default_test) + # assert r == 0 + result = pytester_pretty.runpytest('--insert-assert-print') + result.assert_outcomes(passed=2) + assert test_file.read_text() == default_test + captured = capsys.readouterr() + assert 'test_insert_assert_print.py - 6:' in captured.out + assert 'Printed 1 insert_assert() call in 1 file\n' in captured.out + + +def test_insert_assert_fail(pytester_pretty): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + test_file = pytester_pretty.makepyfile(default_test) + # assert r == 0 + result = pytester_pretty.runpytest() + assert result.parseoutcomes() == {'passed': 2, 'warning': 1, 'insert': 1} + assert test_file.read_text() != default_test + + +def test_deep(pytester_pretty): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + # language=Python + test_file = pytester_pretty.makepyfile( + """ + def test_deep(insert_assert): + insert_assert([{'a': i, 'b': 2 * 2} for i in range(3)]) + """ + ) + result = pytester_pretty.runpytest() + result.assert_outcomes(passed=1) + assert test_file.read_text() == ( + 'def test_deep(insert_assert):\n' + " # insert_assert([{'a': i, 'b': 2 * 2} for i in range(3)])\n" + ' assert [{"a": i, "b": 2 * 2} for i in range(3)] == [\n' + ' {"a": 0, "b": 4},\n' + ' {"a": 1, "b": 4},\n' + ' {"a": 2, "b": 4},\n' + ' ]' + ) + + +def test_enum(pytester_pretty, capsys): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + # language=Python + pytester_pretty.makepyfile( + """ +from enum import Enum + +class Foo(Enum): + A = 1 + B = 2 + +def test_deep(insert_assert): + x = Foo.A + insert_assert(x) + """ + ) + result = pytester_pretty.runpytest('--insert-assert-print') + result.assert_outcomes(passed=1) + captured = capsys.readouterr() + assert ' assert x == Foo.A\n' in captured.out + + +def test_insert_assert_black(tmp_path): + old_wd = os.getcwd() + try: + os.chdir(tmp_path) + (tmp_path / 'pyproject.toml').write_text( + """\ +[tool.black] +target-version = ["py39"] +skip-string-normalization = true""" + ) + load_black.cache_clear() + finally: + os.chdir(old_wd) + + f = load_black() + # no string normalization + assert f("'foobar'") == "'foobar'\n" + + +def test_insert_assert_repeat(pytester_pretty, capsys): + os.environ.pop('CI', None) + pytester_pretty.makeconftest(config) + test_file = pytester_pretty.makepyfile( + """\ +import pytest + +@pytest.mark.parametrize('x', [1, 2, 3]) +def test_string_assert(x, insert_assert): + insert_assert(x)\ +""" + ) + result = pytester_pretty.runpytest() + result.assert_outcomes(passed=3) + assert test_file.read_text() == ( + 'import pytest\n' + '\n' + "@pytest.mark.parametrize('x', [1, 2, 3])\n" + 'def test_string_assert(x, insert_assert):\n' + ' # insert_assert(x)\n' + ' assert x == 1' + ) + captured = capsys.readouterr() + assert '2 insert skipped because an assert statement on that line had already be inserted!\n' in captured.out diff --git a/tests/test_main.py b/tests/test_main.py index 667a79a..1057313 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,7 @@ import sys from collections.abc import Generator from pathlib import Path -from subprocess import PIPE, run +from subprocess import run import pytest @@ -19,9 +19,7 @@ def test_print(capsys): stdout, stderr = capsys.readouterr() print(stdout) assert normalise_output(stdout) == ( - 'tests/test_main.py: test_print\n' - ' a: 1 (int)\n' - ' b: 2 (int)\n' + 'tests/test_main.py: test_print\n' ' a: 1 (int)\n' ' b: 2 (int)\n' ) assert stderr == '' assert result == (1, 2) @@ -64,7 +62,7 @@ def test_print_generator(capsys): def test_format(): a = b'i might bite' - b = "hello this is a test" + b = 'hello this is a test' v = debug.format(a, b) s = normalise_output(str(v)) print(s) @@ -81,7 +79,8 @@ def test_format(): ) def test_print_subprocess(tmpdir): f = tmpdir.join('test.py') - f.write("""\ + f.write( + """\ from devtools import debug def test_func(v): @@ -92,9 +91,10 @@ def test_func(v): debug(foobar) test_func(42) print('debug run.') - """) + """ + ) env = {'PYTHONPATH': str(Path(__file__).parent.parent.resolve())} - p = run([sys.executable, str(f)], stdout=PIPE, stderr=PIPE, universal_newlines=True, env=env) + p = run([sys.executable, str(f)], capture_output=True, text=True, env=env) assert p.stderr == '' assert p.returncode == 0, (p.stderr, p.stdout) assert p.stdout.replace(str(f), '/path/to/test.py') == ( @@ -113,10 +113,10 @@ def test_odd_path(mocker): mocked_relative_to = mocker.patch('pathlib.Path.relative_to') mocked_relative_to.side_effect = ValueError() v = debug.format('test') - if sys.platform == "win32": - pattern = r"\w:\\.*?\\" + if sys.platform == 'win32': + pattern = r'\w:\\.*?\\' else: - pattern = r"/.*?/" + pattern = r'/.*?/' pattern += r"test_main.py:\d{2,} test_odd_path\n 'test' \(str\) len=4" assert re.search(pattern, str(v)), v @@ -129,10 +129,7 @@ def test_small_call_frame(): 3, ) assert normalise_output(str(v)) == ( - 'tests/test_main.py: test_small_call_frame\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + 'tests/test_main.py: test_small_call_frame\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' ) @@ -143,12 +140,9 @@ def test_small_call_frame_warning(): 2, 3, ) - print('\n---\n{}\n---'.format(v)) + print(f'\n---\n{v}\n---') assert normalise_output(str(v)) == ( - 'tests/test_main.py: test_small_call_frame_warning\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + 'tests/test_main.py: test_small_call_frame_warning\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' ) @@ -171,7 +165,7 @@ def test_kwargs_orderless(): v = debug.format(first=a, second='literal') s = normalise_output(str(v)) assert set(s.split('\n')) == { - "tests/test_main.py: test_kwargs_orderless", + 'tests/test_main.py: test_kwargs_orderless', " first: 'variable' (str) len=8 variable=a", " second: 'literal' (str) len=7", } @@ -181,10 +175,7 @@ def test_simple_vars(): v = debug.format('test', 1, 2) s = normalise_output(str(v)) assert s == ( - "tests/test_main.py: test_simple_vars\n" - " 'test' (str) len=4\n" - " 1 (int)\n" - " 2 (int)" + "tests/test_main.py: test_simple_vars\n" " 'test' (str) len=4\n" " 1 (int)\n" " 2 (int)" ) r = normalise_output(repr(v)) assert r == ( @@ -212,30 +203,24 @@ def test_eval(): def test_warnings_disabled(): debug_ = Debug(warnings=False) - with pytest.warns(None) as warnings: - v1 = eval('debug_.format(1)') - assert str(v1) == ':1 \n 1 (int)' - v2 = debug_.format(1) - assert 'test_warnings_disabled\n 1 (int)' in str(v2) - assert len(warnings) == 0 + v1 = eval('debug_.format(1)') + assert str(v1) == ':1 \n 1 (int)' + v2 = debug_.format(1) + assert 'test_warnings_disabled\n 1 (int)' in str(v2) def test_eval_kwargs(): v = eval('debug.format(1, apple="pear")') assert set(str(v).split('\n')) == { - ":1 (no code context for debug call, code inspection impossible)", - " 1 (int)", + ':1 (no code context for debug call, code inspection impossible)', + ' 1 (int)', " apple: 'pear' (str) len=4", } def test_exec(capsys): - exec( - 'a = 1\n' - 'b = 2\n' - 'debug(b, a + b)' - ) + exec('a = 1\n' 'b = 2\n' 'debug(b, a + b)') stdout, stderr = capsys.readouterr() assert stdout == ( @@ -316,8 +301,7 @@ def test_multiple_debugs(): v = debug.format([i * 2 for i in range(2)]) s = normalise_output(str(v)) assert s == ( - 'tests/test_main.py: test_multiple_debugs\n' - ' [i * 2 for i in range(2)]: [0, 2] (list) len=2' + 'tests/test_main.py: test_multiple_debugs\n' ' [i * 2 for i in range(2)]: [0, 2] (list) len=2' ) diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 34c507d..298dc58 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -1,3 +1,4 @@ +import ast import os import string import sys @@ -29,7 +30,12 @@ try: from sqlalchemy import Column, Integer, String - from sqlalchemy.ext.declarative import declarative_base + + try: + from sqlalchemy.orm import declarative_base + except ImportError: + from sqlalchemy.ext.declarative import declarative_base + SQLAlchemyBase = declarative_base() except ImportError: SQLAlchemyBase = None @@ -38,21 +44,13 @@ def test_dict(): v = pformat({1: 2, 3: 4}) print(v) - assert v == ( - '{\n' - ' 1: 2,\n' - ' 3: 4,\n' - '}') + assert v == ('{\n' ' 1: 2,\n' ' 3: 4,\n' '}') def test_print(capsys): pprint({1: 2, 3: 4}) stdout, stderr = capsys.readouterr() - assert strip_ansi(stdout) == ( - '{\n' - ' 1: 2,\n' - ' 3: 4,\n' - '}\n') + assert strip_ansi(stdout) == ('{\n' ' 1: 2,\n' ' 3: 4,\n' '}\n') assert stderr == '' @@ -65,64 +63,33 @@ def test_colours(): def test_list(): v = pformat(list(range(6))) - assert v == ( - '[\n' - ' 0,\n' - ' 1,\n' - ' 2,\n' - ' 3,\n' - ' 4,\n' - ' 5,\n' - ']') + assert v == ('[\n' ' 0,\n' ' 1,\n' ' 2,\n' ' 3,\n' ' 4,\n' ' 5,\n' ']') def test_set(): v = pformat(set(range(5))) - assert v == ( - '{\n' - ' 0,\n' - ' 1,\n' - ' 2,\n' - ' 3,\n' - ' 4,\n' - '}') + assert v == ('{\n' ' 0,\n' ' 1,\n' ' 2,\n' ' 3,\n' ' 4,\n' '}') def test_tuple(): v = pformat(tuple(range(5))) - assert v == ( - '(\n' - ' 0,\n' - ' 1,\n' - ' 2,\n' - ' 3,\n' - ' 4,\n' - ')') + assert v == ('(\n' ' 0,\n' ' 1,\n' ' 2,\n' ' 3,\n' ' 4,\n' ')') def test_generator(): - v = pformat((i for i in range(3))) - assert v == ( - '(\n' - ' 0,\n' - ' 1,\n' - ' 2,\n' - ')') + v = pformat(i for i in range(3)) + assert v == ('(\n' ' 0,\n' ' 1,\n' ' 2,\n' ')') def test_named_tuple(): f = namedtuple('Foobar', ['foo', 'bar', 'spam']) v = pformat(f('x', 'y', 1)) - assert v == ("Foobar(\n" - " foo='x',\n" - " bar='y',\n" - " spam=1,\n" - ")") + assert v == ("Foobar(\n" " foo='x',\n" " bar='y',\n" " spam=1,\n" ")") def test_generator_no_yield(): pformat_ = PrettyFormat(yield_from_generators=False) - v = pformat_((i for i in range(3))) + v = pformat_(i for i in range(3)) assert v.startswith('. at ') @@ -154,7 +121,9 @@ def test_str_repr(): def test_bytes(): pformat_ = PrettyFormat(width=12) v = pformat_(string.ascii_lowercase.encode()) - assert v == """( + assert ( + v + == """( b'abcde' b'fghij' b'klmno' @@ -162,6 +131,7 @@ def test_bytes(): b'uvwxy' b'z' )""" + ) def test_short_bytes(): @@ -171,40 +141,52 @@ def test_short_bytes(): def test_bytearray(): pformat_ = PrettyFormat(width=18) v = pformat_(bytearray(string.ascii_lowercase.encode())) - assert v == """\ + assert ( + v + == """\ bytearray( b'abcdefghijk' b'lmnopqrstuv' b'wxyz' )""" + ) def test_bytearray_short(): v = pformat(bytearray(b'boo')) - assert v == """\ + assert ( + v + == """\ bytearray( b'boo' )""" + ) def test_map(): v = pformat(map(str.strip, ['x', 'y ', ' z'])) - assert v == """\ + assert ( + v + == """\ map( 'x', 'y', 'z', )""" + ) def test_filter(): v = pformat(filter(None, [1, 2, False, 3])) - assert v == """\ + assert ( + v + == """\ filter( 1, 2, 3, )""" + ) def test_counter(): @@ -213,14 +195,16 @@ def test_counter(): c['x'] += 1 c['y'] += 1 v = pformat(c) - assert v == """\ + assert ( + v + == """\ """ + ) -@pytest.mark.skipif(sys.version_info > (3, 7), reason='no datalcasses before 3.6') def test_dataclass(): @dataclass class FooDataclass: @@ -230,7 +214,9 @@ class FooDataclass: f = FooDataclass(123, [1, 2, 3, 4]) v = pformat(f) print(v) - assert v == """\ + assert ( + v + == """\ FooDataclass( x=123, y=[ @@ -240,61 +226,119 @@ class FooDataclass: 4, ], )""" + ) + + +def test_nested_dataclasses(): + @dataclass + class FooDataclass: + x: int + + @dataclass + class BarDataclass: + a: float + b: FooDataclass + + f = FooDataclass(123) + b = BarDataclass(10.0, f) + v = pformat(b) + print(v) + assert ( + v + == """\ +BarDataclass( + a=10.0, + b=FooDataclass( + x=123, + ), +)""" + ) + + +def test_dataclass_slots(): + try: + dec = dataclass(slots=True) + except TypeError: + pytest.skip('dataclasses.slots not available') + + @dec + class FooDataclass: + x: int + y: str + + f = FooDataclass(123, 'bar') + v = pformat(f) + print(v) + assert ( + v + == """\ +FooDataclass( + x=123, + y='bar', +)""" + ) @pytest.mark.skipif(numpy is None, reason='numpy not installed') def test_indent_numpy(): v = pformat({'numpy test': numpy.array(range(20))}) - assert v == """{ + assert ( + v + == """{ 'numpy test': ( array([ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]) ), }""" + ) @pytest.mark.skipif(numpy is None, reason='numpy not installed') def test_indent_numpy_short(): v = pformat({'numpy test': numpy.array(range(10))}) - assert v == """{ + assert ( + v + == """{ 'numpy test': array([0, 1, 2, 3, 4, 5, 6, 7, 8, 9]), }""" + ) def test_ordered_dict(): v = pformat(OrderedDict([(1, 2), (3, 4), (5, 6)])) print(v) - assert v == """\ + assert ( + v + == """\ OrderedDict([ (1, 2), (3, 4), (5, 6), ])""" + ) def test_frozenset(): v = pformat(frozenset(range(3))) print(v) - assert v == """\ + assert ( + v + == """\ frozenset({ 0, 1, 2, })""" + ) def test_deep_objects(): f = namedtuple('Foobar', ['foo', 'bar', 'spam']) - v = pformat(( - ( - f('x', 'y', OrderedDict([(1, 2), (3, 4), (5, 6)])), - frozenset(range(3)), - [1, 2, {1: 2}] - ), - {1, 2, 3} - )) + v = pformat(((f('x', 'y', OrderedDict([(1, 2), (3, 4), (5, 6)])), frozenset(range(3)), [1, 2, {1: 2}]), {1, 2, 3})) print(v) - assert v == """\ + assert ( + v + == """\ ( ( Foobar( @@ -319,6 +363,7 @@ def test_deep_objects(): ), {1, 2, 3}, )""" + ) def test_call_args(): @@ -326,11 +371,14 @@ def test_call_args(): m(1, 2, 3, a=4) v = pformat(m.call_args) - assert v == """\ + assert ( + v + == """\ _Call( _fields=(1, 2, 3), {'a': 4}, )""" + ) @pytest.mark.skipif(MultiDict is None, reason='MultiDict not installed') @@ -339,11 +387,11 @@ def test_multidict(): d.add('b', 3) v = pformat(d) assert set(v.split('\n')) == { - "", + '})>', } @@ -351,10 +399,10 @@ def test_multidict(): def test_cimultidict(): v = pformat(CIMultiDict({'a': 1, 'b': 2})) assert set(v.split('\n')) == { - "", + '})>', } @@ -374,21 +422,11 @@ def __init__(self): def test_dir(): - assert pformat(vars(Foo())) == ( - "{\n" - " 'b': 2,\n" - " 'c': 3,\n" - "}" - ) + assert pformat(vars(Foo())) == ("{\n" " 'b': 2,\n" " 'c': 3,\n" "}") def test_instance_dict(): - assert pformat(Foo().__dict__) == ( - "{\n" - " 'b': 2,\n" - " 'c': 3,\n" - "}" - ) + assert pformat(Foo().__dict__) == ("{\n" " 'b': 2,\n" " 'c': 3,\n" "}") def test_class_dict(): @@ -409,25 +447,14 @@ def items(self): def __getitem__(self, item): return self._d[item] - assert pformat(Dictlike()) == ( - "" - ) + assert pformat(Dictlike()) == ("") @pytest.mark.skipif(Record is None, reason='asyncpg not installed') def test_asyncpg_record(): r = Record({'a': 0, 'b': 1}, (41, 42)) assert dict(r) == {'a': 41, 'b': 42} - assert pformat(r) == ( - "" - ) + assert pformat(r) == ("") def test_dict_type(): @@ -442,11 +469,12 @@ class User(SQLAlchemyBase): name = Column(String) fullname = Column(String) nickname = Column(String) + user = User() user.id = 1 - user.name = "Test" - user.fullname = "Test For SQLAlchemy" - user.nickname = "test" + user.name = 'Test' + user.fullname = 'Test For SQLAlchemy' + user.nickname = 'test' assert pformat(user) == ( "User(\n" " fullname='Test For SQLAlchemy',\n" @@ -455,3 +483,26 @@ class User(SQLAlchemyBase): " nickname='test',\n" ")" ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='no indent on older versions') +def test_ast_expr(): + assert pformat(ast.parse('print(1, 2, round(3))', mode='eval')) == ( + "Expression(" + "\n body=Call(" + "\n func=Name(id='print', ctx=Load())," + "\n args=[" + "\n Constant(value=1)," + "\n Constant(value=2)," + "\n Call(" + "\n func=Name(id='round', ctx=Load())," + "\n args=[" + "\n Constant(value=3)]," + "\n keywords=[])]," + "\n keywords=[]))" + ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='no indent on older versions') +def test_ast_module(): + assert pformat(ast.parse('print(1, 2, round(3))')).startswith('Module(\n body=[')