diff --git a/.codecov.yml b/.codecov.yml index c952680..0ea43a0 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -1,6 +1,9 @@ coverage: precision: 2 range: [95, 100] + status: + patch: false + project: false comment: layout: 'header, diff, flags, files, footer' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfebeb..e131174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,97 +3,156 @@ name: CI on: push: branches: - - master + - main tags: - '**' pull_request: {} +env: + COLUMNS: 150 + jobs: + lint: + runs-on: ubuntu-latest + + steps: + - 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@v4 + with: + python-version: '3.10' + + - run: pip install -r requirements/docs.txt -r requirements/pyproject.txt + - run: pip install . + + - 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'] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] + exclude: + - 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@v1 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - name: install dependencies - run: | - make install - pip freeze + - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt - - name: lint - run: | - make lint - make check-dist + - run: pip freeze - name: test with extras - run: | - make test - coverage xml + run: make test - - uses: codecov/codecov-action@v1.0.7 + - run: coverage xml + + - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS - name: uninstall extras - run: pip uninstall -y multidict numpy pydantic asyncpg + run: pip uninstall -y multidict numpy pydantic asyncpg sqlalchemy - name: test without extras - run: | - make test - coverage xml + run: make test + + - run: coverage xml - - uses: codecov/codecov-action@v1.0.7 + - 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: - name: Deploy - needs: test + needs: + - 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@v1 + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: get docs + uses: actions/download-artifact@v3 with: - python-version: '3.8' + 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: install - run: | - make install - pip install -U wheel + run: pip install build twine - name: build - run: python setup.py sdist bdist_wheel + run: python -m build - run: twine check dist/* - - name: check tag - run: PACKAGE=devtools python <(curl -Ls https://git.io/JvQsH) - - name: upload to pypi run: twine upload dist/* env: @@ -101,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 7a0502f..ddf0bda 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,61 @@ +## 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/) + for finding and printing debug arguments, #82, thanks @alexmojaki +* correct changelog links, #76, thanks @Cielquan +* return `debug()` arguments, #87 +* display more generators like `map` and `filter`, #88 +* display `Counter` and similar dict-like objects properly, #88 +* 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 * improve the way statement ranges are calculated, #58 * drastically improve import time, #50 @@ -8,24 +65,29 @@ * fix `debug(type(dict(...)))`, #62 ## v0.5.1 (2019-10-09) + * fix python tag in `setup.cfg`, #46 ## v0.5.0 (2019-01-03) + * support `MultiDict`, #34 * support `__pretty__` method, #36 ## v0.4.0 (2018-12-29) + * remove use of `warnings`, include in output, #30 * fix rendering errors #31 * better str and bytes wrapping #32 * add `len` everywhere possible, part of #16 ## v0.3.0 (2017-10-11) + * allow `async/await` arguments * fix subscript * fix weird named tuples eg. `mock > call_args` * add `timer` ## v0.2.0 (2017-09-14) + * improve output * numerous bug fixes 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 ac344d7..51b877c 100644 --- a/Makefile +++ b/Makefile @@ -1,37 +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 - 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 - -.PHONY: check-dist -check-dist: - python setup.py check -ms - python setup.py sdist - twine check dist/* + 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 @@ -55,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 0d7702d..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, or 3.8. -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 323cafc..e31a2d1 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -2,16 +2,14 @@ from .utils import isatty -_ansi_template = '\033[{}m' - __all__ = 'sformat', 'sprint' MYPY = False if MYPY: - from typing import Any + 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) @@ -64,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. @@ -92,33 +90,47 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo try: s = self.styles[s] except KeyError: - raise ValueError('invalid style "{}"'.format(s)) - codes.append(str(s.value)) + raise ValueError(f'invalid style "{s}"') + codes.append(_style_as_int(s.value)) # type: ignore if codes: - r = _ansi_template.format(';'.join(codes)) + text + r = _as_ansi(';'.join(codes)) + text else: r = text if reset: - r += _ansi_template.format(self.reset) + r += _as_ansi(_style_as_int(self.reset)) 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: + if isinstance(v, Style): + return str(v.value) + else: + return str(v) + + +def _as_ansi(s: str) -> str: + return f'\033[{s}m' sformat = Style(-1) @@ -130,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 4155b3a..89cda24 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -4,15 +4,13 @@ from .ansi import sformat from .prettier import PrettyFormat from .timer import Timer -from .utils import env_bool, env_true, use_highlight +from .utils import env_bool, env_true, is_literal, use_highlight __all__ = 'Debug', 'debug' MYPY = False if MYPY: - import ast from types import FrameType - from typing import Generator, List, Optional, Tuple - + from typing import Any, Generator, List, Optional, Union pformat = PrettyFormat( indent_step=int(os.getenv('PY_DEVTOOLS_INDENT', 4)), @@ -20,16 +18,14 @@ width=int(os.getenv('PY_DEVTOOLS_WIDTH', 120)), yield_from_generators=env_true('PY_DEVTOOLS_YIELD_FROM_GEN', True), ) - - -class IntrospectionError(ValueError): - pass +# 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 = [] @@ -41,29 +37,26 @@ 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: - s = sformat(self.name, sformat.blue, apply=highlight) + ': ' + if self.name and not is_literal(self.name): + s = f'{sformat(self.name, sformat.blue, apply=highlight)}: ' suffix = sformat( - ' ({.value.__class__.__name__}){}'.format(self, ''.join(' {}={}'.format(k, v) for k, v in self.extra)), + f" ({self.value.__class__.__name__}){''.join(f' {k}={v}' for k, v in self.extra)}", sformat.dim, apply=highlight, ) try: s += pformat(self.value, indent=4, highlight=highlight) except Exception as exc: - s += '{!r}{}\n {}'.format( - self.value, - suffix, - sformat('!!! error pretty printing value: {!r}'.format(exc), sformat.yellow, apply=highlight), - ) + v = sformat(f'!!! error pretty printing value: {exc!r}', sformat.yellow, apply=highlight) + s += f'{self.value!r}{suffix}\n {v}' else: s += suffix return s - def __str__(self) -> str: + def __str__(self) -> StrType: return self.str() @@ -75,70 +68,86 @@ 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 = '{}:{} {}'.format( - sformat(self.filename, sformat.magenta), - sformat(self.lineno, sformat.green), - sformat(self.frame, sformat.green, sformat.italic), + prefix = ( + f'{sformat(self.filename, sformat.magenta)}:{sformat(self.lineno, sformat.green)} ' + f'{sformat(self.frame, sformat.green, sformat.italic)}' ) if self.warning: - prefix += sformat(' ({})'.format(self.warning), sformat.dim) + prefix += sformat(f' ({self.warning})', sformat.dim) else: - prefix = '{0.filename}:{0.lineno} {0.frame}'.format(self) + prefix = f'{self.filename}:{self.lineno} {self.frame}' if self.warning: - prefix += ' ({})'.format(self.warning) - return prefix + '\n ' + '\n '.join(a.str(highlight) for a in self.arguments) + 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 ''.format(s=self, a=arguments) + return f'' class Debug: output_class = DebugOutput - def __init__( - self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bool]' = None, frame_context_length: int = 50 - ): + def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bool]' = None): self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True) self._highlight = highlight - # 50 lines should be enough to make sure we always get the entire function definition - self._frame_context_length = frame_context_length - def __call__(self, *args, file_=None, flush_=True, **kwargs) -> None: - d_out = self._process(args, kwargs, 'debug') + 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: + return (*args, kwargs) + elif len(args) == 1: + return args[0] + else: + return args - def format(self, *args, **kwargs) -> DebugOutput: - return self._process(args, kwargs, 'format') + 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, func_name: str) -> 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( @@ -149,15 +158,16 @@ def _process(self, args, kwargs, func_name: str) -> DebugOutput: warning=self._show_warnings and 'error parsing code, call stack too shallow', ) - filename = call_frame.f_code.co_filename function = call_frame.f_code.co_name - if filename.startswith('/'): - # make the path relative - from pathlib import Path + from pathlib import Path + + path = Path(call_frame.f_code.co_filename) + if path.is_absolute(): + # make the path relative cwd = Path('.').resolve() try: - filename = str(Path(filename).relative_to(cwd)) + path = path.relative_to(cwd) except ValueError: # happens if filename path is not within CWD pass @@ -165,206 +175,54 @@ def _process(self, args, kwargs, func_name: str) -> DebugOutput: lineno = call_frame.f_lineno warning = None - import inspect + import executing - try: - file_lines, _ = inspect.findsource(call_frame) - except OSError: + source = executing.Source.for_frame(call_frame) + if not source.text: warning = 'no code context for debug call, code inspection impossible' arguments = list(self._args_inspection_failed(args, kwargs)) else: - try: - first_line, last_line = self._statement_range(call_frame, func_name) - func_ast, code_lines = self._parse_code(filename, file_lines, first_line, last_line) - except IntrospectionError as e: - # parsing failed - warning = e.args[0] + ex = source.executing(call_frame) + function = ex.code_qualname() + if not ex.node: + warning = 'executing failed to find the calling node' arguments = list(self._args_inspection_failed(args, kwargs)) else: - arguments = list(self._process_args(func_ast, code_lines, args, kwargs)) + arguments = list(self._process_args(ex, args, kwargs)) return self.output_class( - filename=filename, + filename=str(path), lineno=lineno, frame=function, arguments=arguments, 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, func_ast, code_lines, args, kwargs) -> 'Generator[DebugArgument, None, None]': # noqa: C901 + def _process_args(self, ex: 'Any', args: 'Any', kwargs: 'Any') -> 'Generator[DebugArgument, None, None]': import ast - complex_nodes = ( - ast.Call, - ast.Attribute, - ast.Subscript, - ast.IfExp, - ast.BoolOp, - ast.BinOp, - ast.Compare, - ast.DictComp, - ast.ListComp, - ast.SetComp, - ast.GeneratorExp, - ) - - arg_offsets = list(self._get_offsets(func_ast)) - for i, arg in enumerate(args): - try: - ast_node = func_ast.args[i] - except IndexError: # pragma: no cover - # happens when code has been commented out and there are fewer func_ast args than real args - yield self.output_class.arg_class(arg) - continue - - if isinstance(ast_node, ast.Name): - yield self.output_class.arg_class(arg, name=ast_node.id) - elif isinstance(ast_node, complex_nodes): - # TODO replace this hack with astor when it get's round to a new release - start_line, start_col = arg_offsets[i] - - if i + 1 < len(arg_offsets): - end_line, end_col = arg_offsets[i + 1] - else: - end_line, end_col = len(code_lines) - 1, None - - name_lines = [] - for l_ in range(start_line, end_line + 1): - start_ = start_col if l_ == start_line else 0 - end_ = end_col if l_ == end_line else None - name_lines.append(code_lines[l_][start_:end_].strip(' ')) - yield self.output_class.arg_class(arg, name=' '.join(name_lines).strip(' ,')) + func_ast = ex.node + atok = ex.source.asttokens() + for arg, ast_arg in zip(args, func_ast.args): + if isinstance(ast_arg, ast.Name): + yield self.output_class.arg_class(arg, name=ast_arg.id) else: - yield self.output_class.arg_class(arg) + name = ' '.join(map(str.strip, atok.get_text(ast_arg).splitlines())) + yield self.output_class.arg_class(arg, name=name) kw_arg_names = {} for kw in func_ast.keywords: if isinstance(kw.value, ast.Name): kw_arg_names[kw.arg] = kw.value.id + for name, value in kwargs.items(): yield self.output_class.arg_class(value, name=name, variable=kw_arg_names.get(name)) - def _parse_code( - self, filename: str, file_lines: 'List[str]', first_line: int, last_line: int - ) -> 'Tuple[ast.AST, List[str]]': - """ - All we're trying to do here is build an AST of the function call statement. However numerous ugly interfaces, - lack on introspection support and changes between python versions make this extremely hard. - """ - import ast - from textwrap import dedent - - def get_code(_last_line: int) -> str: - lines = file_lines[first_line - 1 : _last_line] - return dedent(''.join(ln for ln in lines if ln.strip('\n ') and not ln.lstrip(' ').startswith('#'))) - - code = get_code(last_line) - func_ast = None - try: - func_ast = self._wrap_parse(code, filename) - except (SyntaxError, AttributeError) as e1: - # if the trailing bracket(s) of the function is/are on a new line e.g.: - # debug( - # foo, bar, - # ) - # inspect ignores it when setting index and we have to add it back - for extra in range(1, 6): - code = get_code(last_line + extra) - try: - func_ast = self._wrap_parse(code, filename) - except (SyntaxError, AttributeError): - pass - else: - break - - if not func_ast: - raise IntrospectionError('error parsing code, {0.__class__.__name__}: {0}'.format(e1)) - - if not isinstance(func_ast, ast.Call): - raise IntrospectionError('error parsing code, found {0.__class__} not Call'.format(func_ast)) - - code_lines = [line for line in code.split('\n') if line] - # this removes the trailing bracket from the lines of code meaning it doesn't appear in the - # representation of the last argument - code_lines[-1] = code_lines[-1][:-1] - return func_ast, code_lines - - @staticmethod # noqa: C901 - def _statement_range(call_frame: 'FrameType', func_name: str) -> 'Tuple[int, int]': # noqa: C901 - """ - Try to find the start and end of a frame statement. - """ - import dis - - # dis.disassemble(call_frame.f_code, call_frame.f_lasti) - # pprint([i for i in dis.get_instructions(call_frame.f_code)]) - - instructions = iter(dis.get_instructions(call_frame.f_code)) - first_line = None - last_line = None - - for instr in instructions: # pragma: no branch - if ( - instr.starts_line - and instr.opname in {'LOAD_GLOBAL', 'LOAD_NAME'} - and (instr.argval == func_name or (instr.argval == 'debug' and next(instructions).argval == func_name)) - ): - first_line = instr.starts_line - if instr.offset == call_frame.f_lasti: - break - - if first_line is None: - raise IntrospectionError('error parsing code, unable to find "{}" function statement'.format(func_name)) - - for instr in instructions: - if instr.starts_line: - last_line = instr.starts_line - 1 - break - - if last_line is None: - if sys.version_info >= (3, 8): - # absolutely no reliable way of getting the last line of the statement, complete hack is to - # get the last line of the last statement of the whole code block and go from there - # this assumes (perhaps wrongly?) that the reason we couldn't find last_line is that the statement - # in question was the last of the block - last_line = max(i.starts_line for i in dis.get_instructions(call_frame.f_code) if i.starts_line) - else: - # in older version of python f_lineno is the end of the statement, not the beginning - # so this is a reasonable guess - last_line = call_frame.f_lineno - - return first_line, last_line - - @staticmethod - def _wrap_parse(code: str, filename: str) -> 'ast.Call': - """ - async wrapper is required to avoid await calls raising a SyntaxError - """ - import ast - from textwrap import indent - - code = 'async def wrapper():\n' + indent(code, ' ') - return ast.parse(code, filename=filename).body[0].body[0].value - - @staticmethod - def _get_offsets(func_ast): - import ast - - for arg in func_ast.args: - start_line, start_col = arg.lineno - 2, arg.col_offset - 1 - - # horrible hack for http://bugs.python.org/issue31241 - if isinstance(arg, (ast.ListComp, ast.GeneratorExp)): - start_col -= 1 - yield start_line, start_col - for kw in func_ast.keywords: - yield kw.value.lineno - 2, kw.value.col_offset - 2 - (len(kw.arg) if kw.arg else 0) - debug = Debug() diff --git a/devtools/prettier.py b/devtools/prettier.py index c6c41a4..c45bc6a 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -1,14 +1,27 @@ +import ast import io import os from collections import OrderedDict from collections.abc import Generator -from .utils import env_true, isatty +from .utils import DataClassType, LaxMapping, SQLAlchemyClassType, env_true, isatty + +try: + from functools import cache +except ImportError: + from functools import lru_cache + + 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, Union + from typing import Any, Callable, Iterable, List, Set, Tuple, Union PARENTHESES_LOOKUP = [ (list, '[', ']'), @@ -20,7 +33,7 @@ PRETTY_KEY = '__prettier_formatted_value__' -def fmt(v): +def fmt(v: 'Any') -> 'Any': return {PRETTY_KEY: v} @@ -28,7 +41,8 @@ class SkipPretty(Exception): pass -def get_pygments(): +@cache +def get_pygments() -> 'Tuple[Any, Any, Any]': try: import pygments from pygments.formatters import Terminal256Formatter @@ -39,15 +53,19 @@ def get_pygments(): return pygments, PythonLexer(), Terminal256Formatter(style='vim') +# common generator types (this is not exhaustive: things like chain are not include to avoid the import) +generator_types = Generator, map, filter, zip, enumerate + + 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 @@ -55,15 +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), - (Generator, self._format_generators), + (bytearray, self._format_bytearray), + (generator_types, self._format_generator), + # 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() @@ -73,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) @@ -93,33 +117,21 @@ 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): + if len(value_repr) <= self._simple_cutoff and not isinstance(value, generator_types): self._stream.write(value_repr) else: indent_new = indent_current + self._indent_step for t, func in self._type_lookup: if isinstance(value, t): func(value, value_repr, indent_current, indent_new) - return - - # very blunt check for things that look like dictionaries but do not necessarily inherit from Mapping - # e.g. asyncpg Records - # HELP: are there any other checks we should include here? - if ( - hasattr(value, '__getitem__') - and hasattr(value, 'items') - and callable(value.items) - and not type(value) == type - ): - self._format_dict(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}: @@ -139,13 +151,13 @@ def _render_pretty(self, gen, indent: int): # shouldn't happen but will self._stream.write(repr(v)) - def _format_dict(self, value: 'Any', value_repr: 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', '])' before_ += '(' - elif not isinstance(value, dict): - open_, close_ = '<{}({{\n'.format(value.__class__.__name__), '})>' + elif type(value) != dict: + open_, close_ = f'<{value.__class__.__name__}({{\n', '})>' self._stream.write(open_) for k, v in value.items(): @@ -157,8 +169,8 @@ def _format_dict(self, value: 'Any', value_repr: str, indent_current: int, inden self._stream.write(indent_current * self._c + close_) def _format_list_like( - self, value: 'Union[list, tuple, set]', value_repr: str, indent_current: int, indent_new: int - ): + 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): @@ -171,38 +183,35 @@ def _format_list_like( 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._stream.write(value.__class__.__name__ + '(\n') - for field, v in zip(fields, value): - self._stream.write(indent_new * self._c) - if field: # field is falsy sometimes for odd things like call_args - self._stream.write(str(field)) - self._stream.write('=') - self._format(v, indent_new, False) - self._stream.write(',\n') - self._stream.write(indent_current * self._c + ')') + 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: lines = list(self._wrap_lines(value, indent_new)) if len(lines) > 1: - 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 + ')') + self._str_lines(lines, indent_current, indent_new) else: self._stream.write(value_repr) - def _wrap_lines(self, s, indent_new): + 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: '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 @@ -211,17 +220,62 @@ def _wrap_lines(self, s, indent_new): start = pos yield line[start:] - def _format_generators(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: - self._stream.write('(\n') + name = value.__class__.__name__ + if name == 'generator': + # no name if the name is just "generator" + self._stream.write('(\n') + else: + self._stream.write(f'{name}(\n') for v in value: self._format(v, indent_new, True) self._stream.write(',\n') self._stream.write(indent_current * self._c + ')') - def _format_raw(self, value: 'Any', value_repr: 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_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() + + fields = [ + (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) -> None: lines = value_repr.splitlines(True) if len(lines) > 1 or (len(value_repr) + indent_current) >= self._width: self._stream.write('(\n') @@ -238,11 +292,23 @@ def _format_raw(self, value: 'Any', value_repr: str, indent_current: int, indent else: self._stream.write(value_repr) + def _format_fields( + self, value: 'Any', fields: 'Iterable[Tuple[str, Any]]', indent_current: int, indent_new: int + ) -> None: + self._stream.write(f'{value.__class__.__name__}(\n') + for field, v in fields: + self._stream.write(indent_new * self._c) + if field: # field is falsy sometimes for odd things like call_args + self._stream.write(f'{field}=') + self._format(v, indent_new, False) + self._stream.write(',\n') + self._stream.write(indent_current * self._c + ')') + pformat = PrettyFormat() 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 da4cc3b..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 '{}: {:0.{dp}f}s elapsed'.format(self._name, self.elapsed(), dp=dp) + return f'{self._name}: {self.elapsed():0.{dp}f}s elapsed' else: - return '{:0.{dp}f}s elapsed'.format(self.elapsed(), dp=dp) + 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,27 +66,24 @@ 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(' {}'.format(r.str(self.dp)), file=self.file) - times.add(r.elapsed()) + print(f' {r.str(self.dp)}', file=self.file) + times.append(r.elapsed()) if times: from statistics import mean, stdev print( - _SUMMARY_TEMPLATE.format( - count=len(times), - mean=mean(times), - stddev=stdev(times) if len(times) > 1 else 0, - min=min(times), - max=max(times), - dp=self.dp, - ), + f'{len(times)} times: ' + f'mean={mean(times):0.{self.dp}f}s ' + f'stdev={stdev(times) if len(times) > 1 else 0:0.{self.dp}f}s ' + f'min={min(times):0.{self.dp}f}s ' + f'max={max(times):0.{self.dp}f}s', file=self.file, flush=True, ) @@ -87,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 9e01ab0..2a96765 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -1,14 +1,27 @@ import os import sys -__all__ = ('isatty',) +__all__ = ( + 'isatty', + 'env_true', + 'env_bool', + 'use_highlight', + 'is_literal', + 'LaxMapping', + 'DataClassType', + 'SQLAlchemyClassType', +) MYPY = False if MYPY: - from typing import 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() @@ -16,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'} @@ -31,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. @@ -79,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: @@ -95,3 +109,63 @@ def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool: if sys.platform == 'win32': # pragma: no cover return isatty(file_) and activate_win_color() return isatty(file_) + + +def is_literal(s: 'Any') -> bool: + import ast + + try: + ast.literal_eval(s) + except (TypeError, MemoryError, SyntaxError, ValueError): + return False + else: + return True + + +class MetaLaxMapping(type): + def __instancecheck__(self, instance: 'Any') -> bool: + return ( + hasattr(instance, '__getitem__') + and hasattr(instance, 'items') + and callable(instance.items) + and type(instance) != type + ) + + +class LaxMapping(metaclass=MetaLaxMapping): + pass + + +class MetaDataClassType(type): + def __instancecheck__(self, instance: 'Any') -> bool: + from dataclasses import is_dataclass + + return is_dataclass(instance) + + +class DataClassType(metaclass=MetaDataClassType): + pass + + +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: + 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 ef6e42d..a4ba93b 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1 @@ -__all__ = ('VERSION',) - -VERSION = '0.6' +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/return_args.py b/docs/examples/return_args.py new file mode 100644 index 0000000..2098507 --- /dev/null +++ b/docs/examples/return_args.py @@ -0,0 +1,6 @@ +from devtools import debug + +assert debug('foo') == 'foo' +assert debug('foo', 'bar') == ('foo', 'bar') +assert debug('foo', 'bar', spam=123) == ('foo', 'bar', {'spam': 123}) +assert debug(spam=123) == ({'spam': 123},) 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 eb1778d..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,4 +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 ceaeaeb..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, or 3.8. -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 b438c2b..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.6.1 +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 90c1a74..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,23 @@ 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 + +`debug` will return the arguments passed to it meaning you can insert `debug(...)` into code. + +The returned arguments work as follows: + +* if one non-keyword argument is passed to `debug()`, it is returned as-is +* if multiple arguments are passed to `debug()`, they are returned as a tuple +* if keyword arguments are passed to `debug()`, the `kwargs` dictionary is added to the returned tuple + +```py +{!examples/return_args.py!} +``` + +{{ example_html(examples/return_args.py) }} ## Other debug tools @@ -38,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 @@ -53,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 @@ -65,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`. + +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 -Add the following to `sitecustomize.py` +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 e4ab17d..0000000 --- a/requirements.txt +++ /dev/null @@ -1,2 +0,0 @@ --r docs/requirements.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 8680fb2..0000000 --- a/setup.cfg +++ /dev/null @@ -1,29 +0,0 @@ -[tool:pytest] -testpaths = tests -filterwarnings = error - -[flake8] -max-line-length = 120 -max-complexity = 12 -ignore = E203, W503 - -[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 d310938..0000000 --- a/setup.py +++ /dev/null @@ -1,55 +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/pydantic/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', - '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', - 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.txt b/tests/requirements.txt deleted file mode 100644 index 049f5a8..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,17 +0,0 @@ -black==19.10b0 -coverage==5.2.1 -flake8==3.8.3 -isort==5.2.1 -pycodestyle==2.6.0 -pyflakes==2.2.0 -Pygments==2.6.1 -pydantic==1.6.1 -pytest==5.4.3 -pytest-cov==2.10.0 -pytest-mock==3.2.0 -pytest-sugar==0.9.4 -pytest-toolbox==0.4 -twine==3.2.0 -asyncpg # pyup: ignore -numpy # pyup: ignore -multidict # pyup: ignore 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 ed8f0a4..eed9469 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -1,22 +1,21 @@ -import ast import asyncio -import re import sys import pytest from devtools import Debug, debug +from .utils import normalise_output + def foobar(a, b, c): return a + b + c -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_simple(): a = [1, 2, 3] v = debug.format(len(a)) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) # print(s) assert ( 'tests/test_expr_render.py: test_simple\n' @@ -24,18 +23,16 @@ def test_simple(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_subscription(): a = {1: 2} v = debug.format(a[1]) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert ( 'tests/test_expr_render.py: test_subscription\n' ' a[1]: 2 (int)' ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_exotic_types(): aa = [1, 2, 3] v = debug.format( @@ -50,10 +47,16 @@ def test_exotic_types(): {a: a + 1 for a in aa}, (a for a in aa), ) - s = re.sub(r':\d{2,}', ':', str(v)) - s = re.sub(r'(at 0x)\w+', r'\1', s) - print('\n---\n{}\n---'.format(v)) - # list and generator comprehensions are wrong because ast is wrong, see https://bugs.python.org/issue31241 + s = normalise_output(str(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' + if sys.version_info[:2] > (3, 7): + genexpr_source = f'({genexpr_source})' + assert ( "tests/test_expr_render.py: test_exotic_types\n" " sum(aa): 6 (int)\n" @@ -69,7 +72,7 @@ def test_exotic_types(): " 2: 3,\n" " 3: 4,\n" " } (dict) len=3\n" - " (a for a in aa): (\n" + f" {genexpr_source}: (\n" " 1,\n" " 2,\n" " 3,\n" @@ -77,11 +80,10 @@ def test_exotic_types(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_newline(): v = debug.format( foobar(1, 2, 3)) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) # print(s) assert ( 'tests/test_expr_render.py: test_newline\n' @@ -89,12 +91,11 @@ def test_newline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_trailing_bracket(): v = debug.format( foobar(1, 2, 3) ) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) # print(s) assert ( 'tests/test_expr_render.py: test_trailing_bracket\n' @@ -102,14 +103,13 @@ def test_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_multiline(): v = debug.format( foobar(1, 2, 3) ) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) # print(s) assert ( 'tests/test_expr_render.py: test_multiline\n' @@ -117,12 +117,11 @@ def test_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_multiline_trailing_bracket(): v = debug.format( foobar(1, 2, 3 )) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) # print(s) assert ( 'tests/test_expr_render.py: test_multiline_trailing_bracket\n' @@ -130,7 +129,6 @@ def test_multiline_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') @pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5') def test_kwargs(): v = debug.format( @@ -138,7 +136,7 @@ def test_kwargs(): a=6, b=7 ) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert ( 'tests/test_expr_render.py: test_kwargs\n' ' foobar(1, 2, 3): 6 (int)\n' @@ -147,7 +145,6 @@ def test_kwargs(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') @pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5') def test_kwargs_multiline(): v = debug.format( @@ -156,7 +153,7 @@ def test_kwargs_multiline(): a=6, b=7 ) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert ( 'tests/test_expr_render.py: test_kwargs_multiline\n' ' foobar(1, 2, 3): 6 (int)\n' @@ -165,20 +162,18 @@ def test_kwargs_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_multiple_trailing_lines(): v = debug.format( foobar( 1, 2, 3 ), ) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert ( 'tests/test_expr_render.py: test_multiple_trailing_lines\n foobar( 1, 2, 3 ): 6 (int)' ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_very_nested_last_statement(): def func(): return debug.format( @@ -195,14 +190,13 @@ def func(): v = func() # check only the original code is included in the warning - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert s == ( - 'tests/test_expr_render.py: func\n' + 'tests/test_expr_render.py: test_very_nested_last_statement..func\n' ' abs( abs( abs( abs( -1 ) ) ) ): 1 (int)' ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_syntax_warning(): def func(): return debug.format( @@ -221,11 +215,10 @@ def func(): v = func() # check only the original code is included in the warning - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert s == ( - 'tests/test_expr_render.py: func ' - '(error parsing code, SyntaxError: unexpected EOF while parsing (test_expr_render.py, line 8))\n' - ' 1 (int)' + 'tests/test_expr_render.py: test_syntax_warning..func\n' + ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' ) @@ -249,11 +242,13 @@ def func(): ) v = func() - assert '(error parsing code' not in str(v) - assert 'func' in str(v) + s = normalise_output(str(v)) + assert s == ( + 'tests/test_expr_render.py: test_no_syntax_warning..func\n' + ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' + ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_await(): async def foo(): return 1 @@ -261,12 +256,13 @@ async def foo(): async def bar(): return debug.format(await foo()) - loop = asyncio.get_event_loop() + loop = asyncio.new_event_loop() v = loop.run_until_complete(bar()) - s = re.sub(r':\d{2,}', ':', str(v)) + loop.close() + s = normalise_output(str(v)) assert ( - 'tests/test_expr_render.py: bar\n' - ' 1 (int)' + 'tests/test_expr_render.py: test_await..bar\n' + ' await foo(): 1 (int)' ) == s @@ -275,17 +271,50 @@ def test_other_debug_arg(): v = debug.format([1, 2]) # check only the original code is included in the warning - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert s == ( 'tests/test_expr_render.py: test_other_debug_arg\n' ' [1, 2] (list) len=2' ) -def test_wrong_ast_type(mocker): - mocked_ast_parse = mocker.patch('ast.parse') +def test_other_debug_arg_not_literal(): + debug.timer() + x = 1 + y = 2 + v = debug.format([x, y]) + + s = normalise_output(str(v)) + assert s == ( + 'tests/test_expr_render.py: test_other_debug_arg_not_literal\n' + ' [x, y]: [1, 2] (list) len=2' + ) + - code = 'async def wrapper():\n x = "foobar"' - mocked_ast_parse.return_value = ast.parse(code, filename='testing.py').body[0].body[0].value - v = debug.format('x') - assert "(error parsing code, found not Call)" in v.str() +def test_executing_failure(): + debug.timer() + x = 1 + y = 2 + + # executing fails inside a pytest assert ast the AST is modified + assert normalise_output(str(debug.format([x, y]))) == ( + 'tests/test_expr_render.py: test_executing_failure ' + '(executing failed to find the calling node)\n' + ' [1, 2] (list) len=2' + ) + + +def test_format_inside_error(): + debug.timer() + x = 1 + y = 2 + try: + raise RuntimeError(debug.format([x, y])) + except RuntimeError as e: + v = str(e) + + s = normalise_output(str(v)) + assert s == ( + 'tests/test_expr_render.py: test_format_inside_error\n' + ' [x, y]: [1, 2] (list) len=2' + ) 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 d425abb..1057313 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,35 +1,70 @@ import re import sys +from collections.abc import Generator from pathlib import Path -from subprocess import PIPE, run +from subprocess import run import pytest from devtools import Debug, debug from devtools.ansi import strip_ansi +from .utils import normalise_output + -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_print(capsys): a = 1 b = 2 - debug(a, b) + result = debug(a, b) stdout, stderr = capsys.readouterr() print(stdout) - assert re.sub(r':\d{2,}', ':', stdout) == ( - 'tests/test_main.py: test_print\n' + assert normalise_output(stdout) == ( + 'tests/test_main.py: test_print\n' ' a: 1 (int)\n' ' b: 2 (int)\n' + ) + assert stderr == '' + assert result == (1, 2) + + +def test_print_kwargs(capsys): + a = 1 + b = 2 + result = debug(a, b, foo=[1, 2, 3]) + stdout, stderr = capsys.readouterr() + print(stdout) + assert normalise_output(stdout) == ( + 'tests/test_main.py: test_print_kwargs\n' ' a: 1 (int)\n' ' b: 2 (int)\n' + ' foo: [1, 2, 3] (list) len=3\n' + ) + assert stderr == '' + assert result == (1, 2, {'foo': [1, 2, 3]}) + + +def test_print_generator(capsys): + gen = (i for i in [1, 2]) + + result = debug(gen) + stdout, stderr = capsys.readouterr() + print(stdout) + assert normalise_output(stdout) == ( + 'tests/test_main.py: test_print_generator\n' + ' gen: (\n' + ' 1,\n' + ' 2,\n' + ' ) (generator)\n' ) assert stderr == '' + assert isinstance(result, Generator) + # the generator got evaluated and is now empty, that's correct currently + assert list(result) == [] -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') 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 = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) print(s) assert s == ( "tests/test_main.py: test_format\n" @@ -38,10 +73,14 @@ def test_format(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail( + sys.platform == 'win32', + reason='Fatal Python error: _Py_HashRandomization_Init: failed to get random numbers to initialize Python', +) def test_print_subprocess(tmpdir): f = tmpdir.join('test.py') - f.write("""\ + f.write( + """\ from devtools import debug def test_func(v): @@ -52,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') == ( @@ -68,55 +108,49 @@ def test_func(v): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_odd_path(mocker): # all valid calls mocked_relative_to = mocker.patch('pathlib.Path.relative_to') mocked_relative_to.side_effect = ValueError() v = debug.format('test') - assert re.search(r"/.*?/test_main.py:\d{2,} test_odd_path\n 'test' \(str\) len=4", str(v)), v + if sys.platform == 'win32': + pattern = r'\w:\\.*?\\' + else: + pattern = r'/.*?/' + pattern += r"test_main.py:\d{2,} test_odd_path\n 'test' \(str\) len=4" + assert re.search(pattern, str(v)), v -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_small_call_frame(): - debug_ = Debug(warnings=False, frame_context_length=2) + debug_ = Debug(warnings=False) v = debug_.format( 1, 2, 3, ) - assert re.sub(r':\d{2,}', ':', str(v)) == ( - 'tests/test_main.py: test_small_call_frame\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + assert normalise_output(str(v)) == ( + 'tests/test_main.py: test_small_call_frame\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_small_call_frame_warning(): - debug_ = Debug(frame_context_length=2) + debug_ = Debug() v = debug_.format( 1, 2, 3, ) - print('\n---\n{}\n---'.format(v)) - assert re.sub(r':\d{2,}', ':', str(v)) == ( - 'tests/test_main.py: test_small_call_frame_warning ' - '(error parsing code, unable to find "format" function statement)\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + 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)' ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') @pytest.mark.skipif(sys.version_info < (3, 6), reason='kwarg order is not guaranteed for 3.5') def test_kwargs(): a = 'variable' v = debug.format(first=a, second='literal') - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) print(s) assert s == ( "tests/test_main.py: test_kwargs\n" @@ -125,30 +159,25 @@ def test_kwargs(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_kwargs_orderless(): # for python3.5 a = 'variable' v = debug.format(first=a, second='literal') - s = re.sub(r':\d{2,}', ':', str(v)) + 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", } -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_simple_vars(): v = debug.format('test', 1, 2) - s = re.sub(r':\d{2,}', ':', str(v)) + 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 = re.sub(r':\d{2,}', ':', repr(v)) + r = normalise_output(repr(v)) assert r == ( " test_simple_vars arguments: 'test' (str) len=4 1 (int) 2 (int)>" ) @@ -174,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 == ( @@ -208,20 +231,19 @@ def test_exec(capsys): assert stderr == '' -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_colours(): v = debug.format(range(6)) - s = re.sub(r':\d{2,}', ':', v.str(True)) + s = v.str(True) assert s.startswith('\x1b[35mtests'), repr(s) - s2 = strip_ansi(s) - assert s2 == v.str(), repr(s2) + s2 = normalise_output(strip_ansi(s)) + assert s2 == normalise_output(v.str()), repr(s2) def test_colours_warnings(mocker): mocked_getframe = mocker.patch('sys._getframe') mocked_getframe.side_effect = ValueError() v = debug.format('x') - s = re.sub(r':\d{2,}', ':', v.str(True)) + s = normalise_output(v.str(True)) assert s.startswith('\x1b[35m'), repr(s) s2 = strip_ansi(s) assert s2 == v.str(), repr(s2) @@ -242,11 +264,14 @@ def test_breakpoint(mocker): assert mocked_set_trace.called -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail( + sys.platform == 'win32' and sys.version_info >= (3, 9), + reason='see https://github.com/alexmojaki/executing/issues/27', +) def test_starred_kwargs(): v = {'foo': 1, 'bar': 2} v = debug.format(**v) - s = re.sub(r':\d{2,}', ':', v.str()) + s = normalise_output(v.str()) assert set(s.split('\n')) == { 'tests/test_main.py: test_starred_kwargs', ' foo: 1 (int)', @@ -254,7 +279,6 @@ def test_starred_kwargs(): } -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') @pytest.mark.skipif(sys.version_info < (3, 7), reason='error repr different before 3.7') def test_pretty_error(): class BadPretty: @@ -263,35 +287,28 @@ def __getattr__(self, item): b = BadPretty() v = debug.format(b) - s = re.sub(r':\d{2,}', ':', str(v)) - s = re.sub(r'0x[0-9a-f]+', '0x000', s) + s = normalise_output(str(v)) assert s == ( "tests/test_main.py: test_pretty_error\n" - " b: .BadPretty object at 0x000> (BadPretty)\n" + " b: .BadPretty object at 0x> (BadPretty)\n" " !!! error pretty printing value: RuntimeError('this is an error')" ) -@pytest.mark.skipif(sys.version_info >= (3, 8), reason='different between 3.7 and 3.8') -def test_multiple_debugs_37(): +def test_multiple_debugs(): debug.format([i * 2 for i in range(2)]) debug.format([i * 2 for i in range(2)]) v = debug.format([i * 2 for i in range(2)]) - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert s == ( - 'tests/test_main.py: test_multiple_debugs_37\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' ) -@pytest.mark.skipif(sys.version_info < (3, 8), reason='different between 3.7 and 3.8') -def test_multiple_debugs_38(): - debug.format([i * 2 for i in range(2)]) - debug.format([i * 2 for i in range(2)]) - v = debug.format([i * 2 for i in range(2)]) - s = re.sub(r':\d{2,}', ':', str(v)) - # FIXME there's an extraneous bracket here, due to some error building code from the ast - assert s == ( - 'tests/test_main.py: test_multiple_debugs_38\n' - ' ([i * 2 for i in range(2)]: [0, 2] (list) len=2' - ) +def test_return_args(capsys): + assert debug('foo') == 'foo' + assert debug('foo', 'bar') == ('foo', 'bar') + assert debug('foo', 'bar', spam=123) == ('foo', 'bar', {'spam': 123}) + assert debug(spam=123) == ({'spam': 123},) + stdout, stderr = capsys.readouterr() + print(stdout) diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 0da0a20..298dc58 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -1,7 +1,10 @@ +import ast import os import string import sys -from collections import OrderedDict, namedtuple +from collections import Counter, OrderedDict, namedtuple +from dataclasses import dataclass +from typing import List from unittest.mock import MagicMock import pytest @@ -25,25 +28,29 @@ except ImportError: Record = None +try: + from sqlalchemy import Column, Integer, String + + try: + from sqlalchemy.orm import declarative_base + except ImportError: + from sqlalchemy.ext.declarative import declarative_base + + SQLAlchemyBase = declarative_base() +except ImportError: + SQLAlchemyBase = None + 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 stdout == ( - '{\n' - ' 1: 2,\n' - ' 3: 4,\n' - '}\n') + assert strip_ansi(stdout) == ('{\n' ' 1: 2,\n' ' 3: 4,\n' '}\n') assert stderr == '' @@ -56,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 ') @@ -145,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' @@ -153,65 +131,214 @@ def test_bytes(): b'uvwxy' b'z' )""" + ) def test_short_bytes(): assert "b'abcdefghijklmnopqrstuvwxyz'" == pformat(string.ascii_lowercase.encode()) +def test_bytearray(): + pformat_ = PrettyFormat(width=18) + v = pformat_(bytearray(string.ascii_lowercase.encode())) + assert ( + v + == """\ +bytearray( + b'abcdefghijk' + b'lmnopqrstuv' + b'wxyz' +)""" + ) + + +def test_bytearray_short(): + v = pformat(bytearray(b'boo')) + assert ( + v + == """\ +bytearray( + b'boo' +)""" + ) + + +def test_map(): + v = pformat(map(str.strip, ['x', 'y ', ' z'])) + assert ( + v + == """\ +map( + 'x', + 'y', + 'z', +)""" + ) + + +def test_filter(): + v = pformat(filter(None, [1, 2, False, 3])) + assert ( + v + == """\ +filter( + 1, + 2, + 3, +)""" + ) + + +def test_counter(): + c = Counter() + c['x'] += 1 + c['x'] += 1 + c['y'] += 1 + v = pformat(c) + assert ( + v + == """\ +""" + ) + + +def test_dataclass(): + @dataclass + class FooDataclass: + x: int + y: List[int] + + f = FooDataclass(123, [1, 2, 3, 4]) + v = pformat(f) + print(v) + assert ( + v + == """\ +FooDataclass( + x=123, + y=[ + 1, + 2, + 3, + 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( @@ -236,32 +363,22 @@ def test_deep_objects(): ), {1, 2, 3}, )""" + ) -@pytest.mark.skipif(sys.version_info > (3, 5, 3), reason='like this only for old 3.5') -def test_call_args_py353(): - m = MagicMock() - m(1, 2, 3, a=4) - v = pformat(m.call_args) - - assert v == """\ -_Call( - (1, 2, 3), - {'a': 4}, -)""" - - -@pytest.mark.skipif(sys.version_info <= (3, 5, 3), reason='different for old 3.5') -def test_call_args_py354(): +def test_call_args(): m = MagicMock() 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') @@ -270,11 +387,11 @@ def test_multidict(): d.add('b', 3) v = pformat(d) assert set(v.split('\n')) == { - "", + '})>', } @@ -282,17 +399,18 @@ def test_multidict(): def test_cimultidict(): v = pformat(CIMultiDict({'a': 1, 'b': 2})) assert set(v.split('\n')) == { - "", + '})>', } def test_os_environ(): v = pformat(os.environ) assert v.startswith('<_Environ({') - assert " 'HOME': '" in v + for key in os.environ: + assert f" '{key}': " in v class Foo: @@ -304,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(): @@ -339,26 +447,62 @@ 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(): assert pformat(type({1: 2})) == "" + + +@pytest.mark.skipif(SQLAlchemyBase is None, reason='sqlalchemy not installed') +def test_sqlalchemy_object(): + class User(SQLAlchemyBase): + __tablename__ = 'users' + id = Column(Integer, primary_key=True) + 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' + assert pformat(user) == ( + "User(\n" + " fullname='Test For SQLAlchemy',\n" + " id=1,\n" + " name='Test',\n" + " 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=[') diff --git a/tests/test_utils.py b/tests/test_utils.py index 9c455b0..d7c5681 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -43,8 +43,4 @@ def test_use_highlight_auto_win(monkeypatch): monkeypatch.delenv('TEST_DONT_USE_HIGHLIGHT', raising=False) monkeypatch.setattr(devtools.utils, 'isatty', lambda _=None: True) - monkeypatch.setattr(devtools.utils, 'color_active', False) - assert use_highlight() is False - - monkeypatch.setattr(devtools.utils, 'color_active', True) assert use_highlight() is True diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 0000000..d254c63 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,8 @@ +import re + + +def normalise_output(s): + s = re.sub(r':\d{2,}', ':', s) + s = re.sub(r'(at 0x)\w+', r'\1', s) + s = s.replace('\\', '/') + return s