From 4bef4c3d46f6d8ca7d6d3919b451bda0acde41e8 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 22 Oct 2020 14:54:39 +0100 Subject: [PATCH 01/53] compatibility with python 3.8.6 (#74) * compatibility with python 3.8.6, fix #73 * uprev --- devtools/ansi.py | 13 ++++++++++--- devtools/version.py | 2 +- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/devtools/ansi.py b/devtools/ansi.py index 323cafc..027466d 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -8,7 +8,7 @@ MYPY = False if MYPY: - from typing import Any + from typing import Any, Union def strip_ansi(value): @@ -93,7 +93,7 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo s = self.styles[s] except KeyError: raise ValueError('invalid style "{}"'.format(s)) - codes.append(str(s.value)) + codes.append(_style_as_int(s.value)) if codes: r = _ansi_template.format(';'.join(codes)) + text @@ -101,7 +101,7 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo r = text if reset: - r += _ansi_template.format(self.reset) + r += _ansi_template.format(_style_as_int(self.reset)) return r @property @@ -121,6 +121,13 @@ def __str__(self): return super().__str__() +def _style_as_int(v: 'Union[Style, int]') -> str: + if isinstance(v, Style): + return str(v.value) + else: + return str(v) + + sformat = Style(-1) diff --git a/devtools/version.py b/devtools/version.py index ef6e42d..67f0397 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1,3 @@ __all__ = ('VERSION',) -VERSION = '0.6' +VERSION = '0.6.1' From e0eb11c57b87c9227bbc065afcbb3c8d87733399 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 30 Mar 2021 13:05:16 +0100 Subject: [PATCH 02/53] Bump pygments from 2.6.1 to 2.7.4 in /docs (#81) * Bump pygments from 2.6.1 to 2.7.4 in /docs Bumps [pygments](https://github.com/pygments/pygments) from 2.6.1 to 2.7.4. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.6.1...2.7.4) Signed-off-by: dependabot[bot] * update pygments in other file Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Samuel Colvin --- docs/requirements.txt | 2 +- tests/requirements.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index b438c2b..20804a2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ markdown==3.2.2 mkdocs-exclude==1.0.2 mkdocs-material==5.5.0 markdown-include==0.5.1 -pygments==2.6.1 +pygments==2.7.4 diff --git a/tests/requirements.txt b/tests/requirements.txt index 049f5a8..9b1dd89 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -4,7 +4,7 @@ flake8==3.8.3 isort==5.2.1 pycodestyle==2.6.0 pyflakes==2.2.0 -Pygments==2.6.1 +Pygments==2.7.4 pydantic==1.6.1 pytest==5.4.3 pytest-cov==2.10.0 From b498361b824ed3bb2bbfeabe3478451b58e31c1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Sep 2021 16:58:07 +0100 Subject: [PATCH 03/53] Bump pydantic from 1.6.1 to 1.6.2 in /tests (#83) Bumps [pydantic](https://github.com/samuelcolvin/pydantic) from 1.6.1 to 1.6.2. - [Release notes](https://github.com/samuelcolvin/pydantic/releases) - [Changelog](https://github.com/samuelcolvin/pydantic/blob/master/HISTORY.md) - [Commits](https://github.com/samuelcolvin/pydantic/compare/v1.6.1...v1.6.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/requirements.txt b/tests/requirements.txt index 9b1dd89..2142af9 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -5,7 +5,7 @@ isort==5.2.1 pycodestyle==2.6.0 pyflakes==2.2.0 Pygments==2.7.4 -pydantic==1.6.1 +pydantic==1.6.2 pytest==5.4.3 pytest-cov==2.10.0 pytest-mock==3.2.0 From f2b7f8ecb1134769670ee17ac856a88182719982 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Fri, 3 Sep 2021 15:03:44 +0200 Subject: [PATCH 04/53] Use executing and asttokens (#82) * Use executing and asttokens * Test `raise RuntimeError(debug.format(...))` Shows that #47 is fixed * Clean up some now unused stuff * black * Differentiate source of generator expressions between python versions * restrict version, other tweaks * tweaking is_literal * remove from requirements Co-authored-by: Samuel Colvin --- devtools/debug.py | 209 +++++--------------------------------- devtools/utils.py | 11 ++ setup.py | 4 + tests/test_expr_render.py | 71 ++++++++++--- tests/test_main.py | 26 +---- tests/test_prettier.py | 2 +- 6 files changed, 101 insertions(+), 222 deletions(-) diff --git a/devtools/debug.py b/devtools/debug.py index 4155b3a..7be02a5 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -4,14 +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 Generator, List, Optional pformat = PrettyFormat( @@ -22,10 +21,6 @@ ) -class IntrospectionError(ValueError): - pass - - class DebugArgument: __slots__ = 'value', 'name', 'extra' @@ -43,7 +38,7 @@ def __init__(self, value, *, name=None, **extra): def str(self, highlight=False) -> str: s = '' - if self.name: + if self.name and not is_literal(self.name): s = sformat(self.name, sformat.blue, apply=highlight) + ': ' suffix = sformat( @@ -108,21 +103,17 @@ def __repr__(self) -> str: 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') + d_out = self._process(args, kwargs) s = d_out.str(use_highlight(self._highlight, file_)) print(s, file=file_, flush=flush_) def format(self, *args, **kwargs) -> DebugOutput: - return self._process(args, kwargs, 'format') + return self._process(args, kwargs) def breakpoint(self): import pdb @@ -132,7 +123,7 @@ def breakpoint(self): def timer(self, name=None, *, verbose=True, file=None, dp=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, kwargs) -> DebugOutput: """ BEWARE: this must be called from a function exactly 2 levels below the top of the stack. """ @@ -165,23 +156,20 @@ 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, @@ -197,174 +185,25 @@ def _args_inspection_failed(self, args, kwargs): 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, args, kwargs) -> '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/utils.py b/devtools/utils.py index 9e01ab0..1b32204 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -95,3 +95,14 @@ 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): + import ast + + try: + ast.literal_eval(s) + except (TypeError, MemoryError, SyntaxError, ValueError): + return False + else: + return True diff --git a/setup.py b/setup.py index d310938..4e05858 100644 --- a/setup.py +++ b/setup.py @@ -48,6 +48,10 @@ license='MIT', packages=['devtools'], python_requires='>=3.6', + install_requires=[ + 'executing>=0.8.0,<1.0.0', + 'asttokens>=2.0.0,<3.0.0', + ], extras_require={ 'pygments': ['Pygments>=2.2.0'], }, diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index ed8f0a4..2eca653 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -1,4 +1,3 @@ -import ast import asyncio import re import sys @@ -53,7 +52,14 @@ def test_exotic_types(): 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 + + # 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 +75,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" @@ -223,9 +229,8 @@ def func(): # check only the original code is included in the warning s = re.sub(r':\d{2,}', ':', 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: func\n' + ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' ) @@ -249,8 +254,11 @@ def func(): ) v = func() - assert '(error parsing code' not in str(v) - assert 'func' in str(v) + s = re.sub(r':\d{2,}', ':', str(v)) + assert s == ( + 'tests/test_expr_render.py: func\n' + ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' + ) @pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') @@ -266,7 +274,7 @@ async def bar(): s = re.sub(r':\d{2,}', ':', str(v)) assert ( 'tests/test_expr_render.py: bar\n' - ' 1 (int)' + ' await foo(): 1 (int)' ) == s @@ -282,10 +290,43 @@ def test_other_debug_arg(): ) -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 = re.sub(r':\d{2,}', ':', str(v)) + assert s == ( + 'tests/test_expr_render.py: test_other_debug_arg_not_literal\n' + ' [x, y]: [1, 2] (list) len=2' + ) + + +def test_executing_failure(): + debug.timer() + x = 1 + y = 2 + + # executing fails inside a pytest assert ast the AST is modified + assert re.sub(r':\d{2,}', ':', 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) - 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() + s = re.sub(r':\d{2,}', ':', 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_main.py b/tests/test_main.py index d425abb..cb11c10 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -77,9 +77,8 @@ def test_odd_path(mocker): assert re.search(r"/.*?/test_main.py:\d{2,} test_odd_path\n 'test' \(str\) len=4", 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, @@ -95,7 +94,7 @@ def test_small_call_frame(): @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, @@ -103,8 +102,7 @@ def test_small_call_frame_warning(): ) 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' + 'tests/test_main.py: test_small_call_frame_warning\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' @@ -272,26 +270,12 @@ def __getattr__(self, item): ) -@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)) assert s == ( - 'tests/test_main.py: test_multiple_debugs_37\n' + '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' - ) diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 0da0a20..48da18d 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -39,7 +39,7 @@ def test_dict(): def test_print(capsys): pprint({1: 2, 3: 4}) stdout, stderr = capsys.readouterr() - assert stdout == ( + assert strip_ansi(stdout) == ( '{\n' ' 1: 2,\n' ' 3: 4,\n' From fda133f57c355c07280c74a4b85bec8746945075 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 15:07:30 +0100 Subject: [PATCH 05/53] correct changelog link, fix #76 --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 4e05858..3beaae4 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ 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'#(\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) From 259b242871851baac86cf45bdd8b3b6c68acabbe Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 15:20:10 +0100 Subject: [PATCH 06/53] Return args (#87) * return the args of debug() * test for generators * adding docs for returning * linting --- devtools/debug.py | 11 +++++-- docs/examples/return_args.py | 6 ++++ docs/index.md | 2 ++ docs/usage.md | 16 ++++++++++ tests/test_main.py | 61 +++++++++++++++++++++++++++++------- 5 files changed, 81 insertions(+), 15 deletions(-) create mode 100644 docs/examples/return_args.py diff --git a/devtools/debug.py b/devtools/debug.py index 7be02a5..8de6c69 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -10,8 +10,7 @@ MYPY = False if MYPY: from types import FrameType - from typing import Generator, List, Optional - + from typing import Any, Generator, List, Optional pformat = PrettyFormat( indent_step=int(os.getenv('PY_DEVTOOLS_INDENT', 4)), @@ -107,10 +106,16 @@ def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bo self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True) self._highlight = highlight - def __call__(self, *args, file_=None, flush_=True, **kwargs) -> None: + def __call__(self, *args, file_=None, flush_=True, **kwargs) -> 'Any': d_out = self._process(args, kwargs) 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) 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/index.md b/docs/index.md index eb1778d..5098d6a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,3 +15,5 @@ ``` {!examples/example.html!} + +Python devtools can do much more, see [Usage](./usage.md) for examples. diff --git a/docs/usage.md b/docs/usage.md index 90c1a74..ad92c16 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -26,6 +26,22 @@ A more complex example of `debug` shows more of what it can do. {!examples/complex.html!} +### 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!} +``` + +{!examples/return_args.html!} + ## Other debug tools The debug namespace includes a number of other useful functions: diff --git a/tests/test_main.py b/tests/test_main.py index cb11c10..9b51b72 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -1,5 +1,6 @@ import re import sys +from collections.abc import Generator from pathlib import Path from subprocess import PIPE, run @@ -8,12 +9,13 @@ from devtools import Debug, debug from devtools.ansi import strip_ansi +pytestmark = pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') + -@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) == ( @@ -22,9 +24,44 @@ def test_print(capsys): ' 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 re.sub(r':\d{2,}', ':', 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 re.sub(r':\d{2,}', ':', 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" @@ -38,7 +75,6 @@ def test_format(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_print_subprocess(tmpdir): f = tmpdir.join('test.py') f.write("""\ @@ -68,7 +104,6 @@ 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') @@ -92,7 +127,6 @@ def test_small_call_frame(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_small_call_frame_warning(): debug_ = Debug() v = debug_.format( @@ -109,7 +143,6 @@ def test_small_call_frame_warning(): ) -@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' @@ -123,7 +156,6 @@ def test_kwargs(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_kwargs_orderless(): # for python3.5 a = 'variable' @@ -136,7 +168,6 @@ def test_kwargs_orderless(): } -@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)) @@ -206,7 +237,6 @@ 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)) @@ -240,7 +270,6 @@ def test_breakpoint(mocker): assert mocked_set_trace.called -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') def test_starred_kwargs(): v = {'foo': 1, 'bar': 2} v = debug.format(**v) @@ -252,7 +281,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: @@ -279,3 +307,12 @@ def test_multiple_debugs(): 'tests/test_main.py: test_multiple_debugs\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) From 3abf5803aeb0877d5f9c380b83c22a910b0c941b Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 17:00:12 +0100 Subject: [PATCH 07/53] Display more generators (#88) * display more generator types, fix #86 * actual test for filter * display of counters * support dataclasses, fix #70 --- devtools/prettier.py | 78 +++++++++++++++++++++-------------- devtools/utils.py | 33 ++++++++++++++- tests/test_prettier.py | 94 +++++++++++++++++++++++++++++++++++------- 3 files changed, 157 insertions(+), 48 deletions(-) diff --git a/devtools/prettier.py b/devtools/prettier.py index c6c41a4..58f03d5 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -3,12 +3,12 @@ from collections import OrderedDict from collections.abc import Generator -from .utils import env_true, isatty +from .utils import DataClassType, LaxMapping, env_true, isatty __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: - from typing import Any, Union + from typing import Any, Iterable, Union PARENTHESES_LOOKUP = [ (list, '[', ']'), @@ -39,6 +39,10 @@ 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, @@ -60,7 +64,11 @@ def __init__( ((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 this last as the check can be slow + (LaxMapping, self._format_dict), + (DataClassType, self._format_dataclass), ] def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, highlight: bool = False): @@ -96,7 +104,7 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): return 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 @@ -105,18 +113,6 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): 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 - self._format_raw(value, value_repr, indent_current, indent_new) def _render_pretty(self, gen, indent: int): @@ -139,12 +135,12 @@ 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): 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): + elif type(value) != dict: open_, close_ = '<{}({{\n'.format(value.__class__.__name__), '})>' self._stream.write(open_) @@ -156,9 +152,7 @@ def _format_dict(self, value: 'Any', value_repr: str, indent_current: int, inden self._stream.write(after_) 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 - ): + def _format_list_like(self, value: 'Union[list, tuple, set]', _: str, indent_current: int, indent_new: int): open_, close_ = '(', ')' for t, *oc in PARENTHESES_LOOKUP: if isinstance(value, t): @@ -194,15 +188,18 @@ def _format_str_bytes(self, value: 'Union[str, bytes]', value_repr: str, indent_ 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[str]', indent_current: int, indent_new: int) -> None: + self._stream.write('(\n') + prefix = indent_new * self._c + for line in lines: + self._stream.write(prefix + repr(line) + '\n') + self._stream.write(indent_current * self._c + ')') + + def _wrap_lines(self, s, indent_new) -> 'Generator[str, None, None]': width = self._width - indent_new - 3 for line in s.splitlines(True): start = 0 @@ -211,17 +208,38 @@ 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, value_repr: str, indent_current: int, indent_new: int): 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): + self._stream.write('bytearray') + lines = self._wrap_lines(bytes(value), indent_new) + self._str_lines(lines, indent_current, indent_new) + + def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int): + from dataclasses import asdict + + before_ = indent_new * self._c + self._stream.write(f'{value.__class__.__name__}(\n') + for k, v in asdict(value).items(): + self._stream.write(f'{before_}{k}=') + self._format(v, indent_new, False) + self._stream.write(',\n') + self._stream.write(indent_current * self._c + ')') + + def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new: int): lines = value_repr.splitlines(True) if len(lines) > 1 or (len(value_repr) + indent_current) >= self._width: self._stream.write('(\n') diff --git a/devtools/utils.py b/devtools/utils.py index 1b32204..78b4c20 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -1,11 +1,11 @@ import os import sys -__all__ = ('isatty',) +__all__ = 'isatty', 'env_true', 'env_bool', 'use_highlight', 'is_literal', 'LaxMapping', 'DataClassType' MYPY = False if MYPY: - from typing import Optional + from typing import Any, Optional def isatty(stream=None): @@ -106,3 +106,32 @@ def is_literal(s): 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: + try: + from dataclasses import _is_dataclass_instance + except ImportError: + # python 3.6 + return False + else: + return _is_dataclass_instance(instance) + + +class DataClassType(metaclass=MetaDataClassType): + pass diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 48da18d..88b5ec3 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -1,7 +1,9 @@ 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 @@ -159,6 +161,80 @@ 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 == """\ +""" + + +@pytest.mark.skipif(sys.version_info > (3, 7), reason='no datalcasses before 3.6') +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, + ], +)""" + + @pytest.mark.skipif(numpy is None, reason='numpy not installed') def test_indent_numpy(): v = pformat({'numpy test': numpy.array(range(20))}) @@ -238,21 +314,7 @@ def test_deep_objects(): )""" -@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) From 72c72a1b1b443de49a58a5fd4b4d3031bd1a66a0 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 17:08:42 +0100 Subject: [PATCH 08/53] use f-strings not .format() (#89) * use f-strings not .format() * few more formats --- devtools/ansi.py | 12 +++++++----- devtools/debug.py | 28 ++++++++++++---------------- devtools/prettier.py | 2 +- devtools/timer.py | 19 ++++++++----------- 4 files changed, 28 insertions(+), 33 deletions(-) diff --git a/devtools/ansi.py b/devtools/ansi.py index 027466d..e0e7b3d 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -2,8 +2,6 @@ from .utils import isatty -_ansi_template = '\033[{}m' - __all__ = 'sformat', 'sprint' MYPY = False @@ -92,16 +90,16 @@ 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)) + raise ValueError(f'invalid style "{s}"') codes.append(_style_as_int(s.value)) if codes: - r = _ansi_template.format(';'.join(codes)) + text + r = _as_ansi(';'.join(codes)) + text else: r = text if reset: - r += _ansi_template.format(_style_as_int(self.reset)) + r += _as_ansi(_style_as_int(self.reset)) return r @property @@ -128,6 +126,10 @@ def _style_as_int(v: 'Union[Style, int]') -> str: return str(v) +def _as_ansi(s: str) -> str: + return f'\033[{s}m' + + sformat = Style(-1) diff --git a/devtools/debug.py b/devtools/debug.py index 8de6c69..b9f7182 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -38,21 +38,18 @@ def __init__(self, value, *, name=None, **extra): def str(self, highlight=False) -> str: s = '' if self.name and not is_literal(self.name): - s = sformat(self.name, sformat.blue, apply=highlight) + ': ' + 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 @@ -78,25 +75,24 @@ def __init__(self, *, filename: str, lineno: int, frame: str, arguments: 'List[D def str(self, highlight=False) -> str: 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: return self.str() def __repr__(self) -> str: arguments = ' '.join(str(a) for a in self.arguments) - return ''.format(s=self, a=arguments) + return f'' class Debug: diff --git a/devtools/prettier.py b/devtools/prettier.py index 58f03d5..4d8f941 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -141,7 +141,7 @@ def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: in open_, split_, after_, close_ = 'OrderedDict([\n', ', ', '),\n', '])' before_ += '(' elif type(value) != dict: - open_, close_ = '<{}({{\n'.format(value.__class__.__name__), '})>' + open_, close_ = f'<{value.__class__.__name__}({{\n', '})>' self._stream.write(open_) for k, v in value.items(): diff --git a/devtools/timer.py b/devtools/timer.py index da4cc3b..3182d1f 100644 --- a/devtools/timer.py +++ b/devtools/timer.py @@ -21,9 +21,9 @@ def elapsed(self): def str(self, dp=3): 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): return self.str() @@ -65,21 +65,18 @@ def summary(self, verbose=False): if not r.finish: r.capture() if verbose: - print(' {}'.format(r.str(self.dp)), file=self.file) + print(f' {r.str(self.dp)}', file=self.file) times.add(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, ) From 6d1fcd858aa80f8ca2b4aa49365289a52fee65b2 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 17:43:04 +0100 Subject: [PATCH 09/53] uprev and split linting and tests (#90) * uprev and split linting and tests * add tests/requirements-linting.txt * fix syntax for older python * move check-dist to deploy * correct deploy step * fix docs build --- .github/workflows/ci.yml | 50 +++++++++++++++++++++------------- Makefile | 6 ---- devtools/version.py | 2 +- requirements.txt | 1 + setup.cfg | 1 - setup.py | 1 + tests/requirements-linting.txt | 6 ++++ tests/requirements.txt | 22 ++++++--------- 8 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 tests/requirements-linting.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbfebeb..a0fad6d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,13 +9,29 @@ on: pull_request: {} jobs: + lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + + - uses: actions/setup-python@v1 + with: + python-version: '3.8' + + - run: pip install -U pip wheel + - run: pip install -r tests/requirements-linting.txt + - run: pip install . + + - run: make lint + 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'] + python-version: ['3.6', '3.7', '3.8', '3.9'] env: PYTHON: ${{ matrix.python-version }} @@ -32,22 +48,17 @@ jobs: with: python-version: ${{ matrix.python-version }} - - name: install dependencies - run: | - make install - pip freeze - - - name: lint - run: | - make lint - make check-dist + - run: pip install -U pip wheel + - run: pip install -r tests/requirements.txt + - run: pip install . + - run: pip freeze - name: test with extras run: | make test coverage xml - - uses: codecov/codecov-action@v1.0.7 + - uses: codecov/codecov-action@v2.0.3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS @@ -60,7 +71,7 @@ jobs: make test coverage xml - - uses: codecov/codecov-action@v1.0.7 + - uses: codecov/codecov-action@v2.0.3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS @@ -68,8 +79,9 @@ jobs: EXTRAS: no deploy: - name: Deploy - needs: test + needs: + - test + - lint if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest @@ -83,17 +95,17 @@ jobs: - name: install run: | - make install - pip install -U wheel + pip install -U pip setuptools wheel twine + pip install . + + - name: set version + run: VERSION_PATH='devtools/version.py' python <(curl -Ls https://git.io/JT3rm) - name: build run: python setup.py sdist bdist_wheel - 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: diff --git a/Makefile b/Makefile index ac344d7..905706e 100644 --- a/Makefile +++ b/Makefile @@ -19,12 +19,6 @@ lint: $(isort) --check-only --df $(black) --check --diff -.PHONY: check-dist -check-dist: - python setup.py check -ms - python setup.py sdist - twine check dist/* - .PHONY: test test: pytest --cov=devtools --cov-fail-under 0 diff --git a/devtools/version.py b/devtools/version.py index 67f0397..c49dd5f 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1,3 @@ __all__ = ('VERSION',) -VERSION = '0.6.1' +VERSION = 'dev' diff --git a/requirements.txt b/requirements.txt index e4ab17d..357dec2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ -r docs/requirements.txt +-r tests/requirements-linting.txt -r tests/requirements.txt diff --git a/setup.cfg b/setup.cfg index 8680fb2..f58b3c4 100644 --- a/setup.cfg +++ b/setup.cfg @@ -5,7 +5,6 @@ filterwarnings = error [flake8] max-line-length = 120 max-complexity = 12 -ignore = E203, W503 [coverage:run] source = devtools diff --git a/setup.py b/setup.py index 3beaae4..f66b02a 100644 --- a/setup.py +++ b/setup.py @@ -33,6 +33,7 @@ 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt new file mode 100644 index 0000000..593bd2e --- /dev/null +++ b/tests/requirements-linting.txt @@ -0,0 +1,6 @@ +black==20.8b1 +flake8==3.9.2 +isort==5.9.3 +mypy==0.910 +pycodestyle==2.7.0 +pyflakes==2.3.1 diff --git a/tests/requirements.txt b/tests/requirements.txt index 2142af9..77c76b9 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,17 +1,11 @@ -black==19.10b0 -coverage==5.2.1 -flake8==3.8.3 -isort==5.2.1 -pycodestyle==2.6.0 -pyflakes==2.2.0 +coverage==5.5 Pygments==2.7.4 -pydantic==1.6.2 -pytest==5.4.3 -pytest-cov==2.10.0 -pytest-mock==3.2.0 +pytest==6.2.5 +pytest-cov==2.12.1 +pytest-mock==3.6.1 pytest-sugar==0.9.4 pytest-toolbox==0.4 -twine==3.2.0 -asyncpg # pyup: ignore -numpy # pyup: ignore -multidict # pyup: ignore +pydantic +asyncpg +numpy +multidict From a3fa16476532876403a95e23b7b4f4d37632f541 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 17:49:30 +0100 Subject: [PATCH 10/53] update history --- HISTORY.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/HISTORY.md b/HISTORY.md index 7a0502f..61dcf05 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,4 +1,16 @@ +## 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.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 +20,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 From 6cd36644569b1e087d12dcccaa4566bcf2b8cc8d Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 18:07:17 +0100 Subject: [PATCH 11/53] correct deploy CI step --- .github/workflows/ci.yml | 4 +--- Makefile | 2 +- README.md | 2 +- devtools/version.py | 2 +- docs/install.md | 2 +- 5 files changed, 5 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a0fad6d..be12fb8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,9 +94,7 @@ jobs: python-version: '3.8' - name: install - run: | - pip install -U pip setuptools wheel twine - pip install . + run: make install - name: set version run: VERSION_PATH='devtools/version.py' python <(curl -Ls https://git.io/JT3rm) diff --git a/Makefile b/Makefile index 905706e..fe19741 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ black = black -S -l 120 --target-version py37 devtools .PHONY: install install: - python -m pip install -U setuptools pip + python -m pip install -U setuptools pip wheel twine pip install -U -r requirements.txt pip install -e . diff --git a/README.md b/README.md index 0d7702d..ab64a57 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ pip install devtools[pygments] `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. +`devtools` has no other required dependencies except python 3.6, 3.7, 3.8 or 3.9. If you've got python 3.6+ and `pip` installed, you're good to go. ## Usage diff --git a/devtools/version.py b/devtools/version.py index c49dd5f..34672f0 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1,3 @@ __all__ = ('VERSION',) -VERSION = 'dev' +VERSION = '0.7.0' diff --git a/docs/install.md b/docs/install.md index ceaeaeb..d81e5f6 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,5 +6,5 @@ pip install devtools[pygments] `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. +`devtools` has no other required dependencies except python 3.6, 3.7, 3.8, or 3.9. If you've got python 3.6+ and `pip` installed, you're good to go. From 66eaa80425cf5a0b82a348dc81ab9a0179010813 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 3 Sep 2021 18:52:47 +0100 Subject: [PATCH 12/53] test with 3.10 (#91) * test with 3.10 * use 3.10.0-rc.1 * try just "3.10" * now try 3.10.0-beta.4 * try a new version of actions/setup-python * tweak async test * exclude 3.10+macros from CI --- .codecov.yml | 3 +++ .github/workflows/ci.yml | 12 ++++++++---- setup.py | 1 + tests/test_expr_render.py | 29 +++++++++++++++-------------- 4 files changed, 27 insertions(+), 18 deletions(-) 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 be12fb8..75b669f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: steps: - uses: actions/checkout@v2 - - uses: actions/setup-python@v1 + - uses: actions/setup-python@v2 with: python-version: '3.8' @@ -31,7 +31,11 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python-version: ['3.6', '3.7', '3.8', '3.9'] + python-version: ['3.6', '3.7', '3.8', '3.9', '3.10.0-rc.1'] + exclude: + # numpy currently get's upset with macos and python 3.10 + - os: macos + python-version: '3.10.0-rc.1' env: PYTHON: ${{ matrix.python-version }} @@ -44,7 +48,7 @@ jobs: - uses: actions/checkout@v2 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} @@ -89,7 +93,7 @@ jobs: - uses: actions/checkout@v2 - name: set up python - uses: actions/setup-python@v1 + uses: actions/setup-python@v2 with: python-version: '3.8' diff --git a/setup.py b/setup.py index f66b02a..d699598 100644 --- a/setup.py +++ b/setup.py @@ -34,6 +34,7 @@ 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', 'Intended Audience :: Developers', 'Intended Audience :: Information Technology', 'Intended Audience :: System Administrators', diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index 2eca653..7d66268 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -11,7 +11,7 @@ def foobar(a, b, c): return a + b + c -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_simple(): a = [1, 2, 3] v = debug.format(len(a)) @@ -23,7 +23,7 @@ def test_simple(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_subscription(): a = {1: 2} v = debug.format(a[1]) @@ -34,7 +34,7 @@ def test_subscription(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_exotic_types(): aa = [1, 2, 3] v = debug.format( @@ -83,7 +83,7 @@ def test_exotic_types(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_newline(): v = debug.format( foobar(1, 2, 3)) @@ -95,7 +95,7 @@ def test_newline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_trailing_bracket(): v = debug.format( foobar(1, 2, 3) @@ -108,7 +108,7 @@ def test_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_multiline(): v = debug.format( foobar(1, @@ -123,7 +123,7 @@ def test_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_multiline_trailing_bracket(): v = debug.format( foobar(1, 2, 3 @@ -136,7 +136,7 @@ def test_multiline_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as 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( @@ -153,7 +153,7 @@ def test_kwargs(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as 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( @@ -171,7 +171,7 @@ def test_kwargs_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_multiple_trailing_lines(): v = debug.format( foobar( @@ -184,7 +184,7 @@ def test_multiple_trailing_lines(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_very_nested_last_statement(): def func(): return debug.format( @@ -208,7 +208,7 @@ def func(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_syntax_warning(): def func(): return debug.format( @@ -261,7 +261,7 @@ def func(): ) -@pytest.mark.xfail(sys.platform == 'win32', reason='yet unknown windows problem') +@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_await(): async def foo(): return 1 @@ -269,8 +269,9 @@ 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()) + loop.close() s = re.sub(r':\d{2,}', ':', str(v)) assert ( 'tests/test_expr_render.py: bar\n' From 746dfb227914dbf43772a761c329b7cc512257fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Eren=20=C3=96zt=C3=BCrk?= Date: Sun, 5 Sep 2021 19:40:48 +0300 Subject: [PATCH 13/53] Implement parsing SQLAlchemy objects (#94) * Implement parsing SQLAlchemy objects * Update devtools/prettier.py Co-authored-by: Alex Hall * new MetaClass for checking sqlalchemy and some code improvements * removed json.dumps from _format_sqlalchemy_class function * Update devtools/prettier.py Co-authored-by: Alex Hall * Update devtools/prettier.py Co-authored-by: Samuel Colvin * add missing import and apply format * small optimisation to `_format_fields` Co-authored-by: Alex Hall Co-authored-by: Samuel Colvin --- .github/workflows/ci.yml | 2 +- devtools/prettier.py | 43 +++++++++++++++++++++++----------------- devtools/utils.py | 14 +++++++++++++ tests/requirements.txt | 1 + tests/test_prettier.py | 30 ++++++++++++++++++++++++++++ 5 files changed, 71 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75b669f..d616961 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -68,7 +68,7 @@ jobs: 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: | diff --git a/devtools/prettier.py b/devtools/prettier.py index 4d8f941..a2fbae3 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -3,12 +3,12 @@ from collections import OrderedDict from collections.abc import Generator -from .utils import DataClassType, LaxMapping, env_true, isatty +from .utils import DataClassType, LaxMapping, SQLAlchemyClassType, env_true, isatty __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: - from typing import Any, Iterable, Union + from typing import Any, Iterable, Tuple, Union PARENTHESES_LOOKUP = [ (list, '[', ']'), @@ -69,6 +69,7 @@ def __init__( # put this last as the check can be slow (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): @@ -169,15 +170,7 @@ def _format_tuples(self, value: tuple, value_repr: str, indent_current: int, ind 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) @@ -231,13 +224,15 @@ def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_ne def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int): from dataclasses import asdict - before_ = indent_new * self._c - self._stream.write(f'{value.__class__.__name__}(\n') - for k, v in asdict(value).items(): - self._stream.write(f'{before_}{k}=') - self._format(v, indent_new, False) - self._stream.write(',\n') - self._stream.write(indent_current * self._c + ')') + self._format_fields(value, asdict(value).items(), indent_current, indent_new) + + def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int): + fields = [ + (field, getattr(value, field)) + 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): lines = value_repr.splitlines(True) @@ -256,6 +251,18 @@ def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new 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) diff --git a/devtools/utils.py b/devtools/utils.py index 78b4c20..3377dc3 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -135,3 +135,17 @@ def __instancecheck__(self, instance: 'Any') -> bool: class DataClassType(metaclass=MetaDataClassType): pass + + +class MetaSQLAlchemyClassType(type): + def __instancecheck__(self, instance: 'Any') -> bool: + try: + from sqlalchemy.ext.declarative import DeclarativeMeta + except ImportError: + return False + else: + return isinstance(instance.__class__, DeclarativeMeta) + + +class SQLAlchemyClassType(metaclass=MetaSQLAlchemyClassType): + pass diff --git a/tests/requirements.txt b/tests/requirements.txt index 77c76b9..3570e26 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -9,3 +9,4 @@ pydantic asyncpg numpy multidict +sqlalchemy \ No newline at end of file diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 88b5ec3..9f8d5c0 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -27,6 +27,13 @@ except ImportError: Record = None +try: + from sqlalchemy import Column, Integer, String + from sqlalchemy.ext.declarative import declarative_base + SQLAlchemyBase = declarative_base() +except ImportError: + SQLAlchemyBase = None + def test_dict(): v = pformat({1: 2, 3: 4}) @@ -424,3 +431,26 @@ def test_asyncpg_record(): 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" + ")" + ) From b30eead5764b43e07504f99fba4ff7f32837f12b Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sun, 5 Sep 2021 17:45:31 +0100 Subject: [PATCH 14/53] correct utils.__all__ --- devtools/utils.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/devtools/utils.py b/devtools/utils.py index 3377dc3..034ece8 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -1,7 +1,16 @@ import os import sys -__all__ = 'isatty', 'env_true', 'env_bool', 'use_highlight', 'is_literal', 'LaxMapping', 'DataClassType' +__all__ = ( + 'isatty', + 'env_true', + 'env_bool', + 'use_highlight', + 'is_literal', + 'LaxMapping', + 'DataClassType', + 'SQLAlchemyClassType', +) MYPY = False if MYPY: From bb413fb0d4d0d885f869e4d7a31969d316cb8e9a Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Sun, 5 Sep 2021 19:27:14 +0200 Subject: [PATCH 15/53] Trying to fix tests on windows (#93) * .startswith('/') -> .is_absolute() * normalise_output function in tests * Remove xfails for windows * Fix test_os_environ for windows * Remove unused re import * Handle test_print_subprocess and test_odd_path in windows * normalise_output in test_colours * Relative imports of tests.utils Co-authored-by: Samuel Colvin * defer pathlib import * set CI to work on windows * fix test_use_highlight_auto_win * fix test_use_highlight_auto_win, take 2 * xfail test_starred_kwargs on windows Co-authored-by: Samuel Colvin Co-authored-by: Samuel Colvin --- .github/workflows/ci.yml | 12 ++++----- devtools/debug.py | 13 +++++----- tests/test_expr_render.py | 53 +++++++++++++++----------------------- tests/test_main.py | 54 ++++++++++++++++++++++++--------------- tests/test_prettier.py | 3 ++- tests/test_utils.py | 4 --- tests/utils.py | 8 ++++++ 7 files changed, 76 insertions(+), 71 deletions(-) create mode 100644 tests/utils.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d616961..1c200a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,9 +58,9 @@ jobs: - run: pip freeze - name: test with extras - run: | - make test - coverage xml + run: make test + + - run: coverage xml - uses: codecov/codecov-action@v2.0.3 with: @@ -71,9 +71,9 @@ jobs: 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@v2.0.3 with: diff --git a/devtools/debug.py b/devtools/debug.py index b9f7182..1954bf5 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -141,15 +141,16 @@ def _process(self, args, kwargs) -> 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 @@ -173,7 +174,7 @@ def _process(self, args, kwargs) -> DebugOutput: arguments = list(self._process_args(ex, args, kwargs)) return self.output_class( - filename=filename, + filename=str(path), lineno=lineno, frame=function, arguments=arguments, diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index 7d66268..9ff5fa6 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -1,21 +1,21 @@ 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='as 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' @@ -23,18 +23,16 @@ def test_simple(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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='as yet unknown windows problem') def test_exotic_types(): aa = [1, 2, 3] v = debug.format( @@ -49,8 +47,7 @@ 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) + s = normalise_output(str(v)) print('\n---\n{}\n---'.format(v)) # Generator expression source changed in 3.8 to include parentheses, see: @@ -83,11 +80,10 @@ def test_exotic_types(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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' @@ -95,12 +91,11 @@ def test_newline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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' @@ -108,14 +103,13 @@ def test_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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' @@ -123,12 +117,11 @@ def test_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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' @@ -136,7 +129,6 @@ def test_multiline_trailing_bracket(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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( @@ -144,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' @@ -153,7 +145,6 @@ def test_kwargs(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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( @@ -162,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' @@ -171,20 +162,18 @@ def test_kwargs_multiline(): ) == s -@pytest.mark.xfail(sys.platform == 'win32', reason='as 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='as yet unknown windows problem') def test_very_nested_last_statement(): def func(): return debug.format( @@ -201,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' ' abs( abs( abs( abs( -1 ) ) ) ): 1 (int)' ) -@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_syntax_warning(): def func(): return debug.format( @@ -227,7 +215,7 @@ 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' ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' @@ -254,14 +242,13 @@ def func(): ) v = func() - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert s == ( 'tests/test_expr_render.py: func\n' ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' ) -@pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') def test_await(): async def foo(): return 1 @@ -272,7 +259,7 @@ async def bar(): loop = asyncio.new_event_loop() v = loop.run_until_complete(bar()) loop.close() - s = re.sub(r':\d{2,}', ':', str(v)) + s = normalise_output(str(v)) assert ( 'tests/test_expr_render.py: bar\n' ' await foo(): 1 (int)' @@ -284,7 +271,7 @@ 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' @@ -297,7 +284,7 @@ def test_other_debug_arg_not_literal(): y = 2 v = debug.format([x, y]) - s = re.sub(r':\d{2,}', ':', str(v)) + 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' @@ -310,7 +297,7 @@ def test_executing_failure(): y = 2 # executing fails inside a pytest assert ast the AST is modified - assert re.sub(r':\d{2,}', ':', str(debug.format([x, y]))) == ( + 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' @@ -326,7 +313,7 @@ def test_format_inside_error(): except RuntimeError as e: v = str(e) - s = re.sub(r':\d{2,}', ':', str(v)) + 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_main.py b/tests/test_main.py index 9b51b72..667a79a 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -9,7 +9,7 @@ from devtools import Debug, debug from devtools.ansi import strip_ansi -pytestmark = pytest.mark.xfail(sys.platform == 'win32', reason='as yet unknown windows problem') +from .utils import normalise_output def test_print(capsys): @@ -18,7 +18,7 @@ def test_print(capsys): result = debug(a, b) stdout, stderr = capsys.readouterr() print(stdout) - assert re.sub(r':\d{2,}', ':', stdout) == ( + assert normalise_output(stdout) == ( 'tests/test_main.py: test_print\n' ' a: 1 (int)\n' ' b: 2 (int)\n' @@ -33,7 +33,7 @@ def test_print_kwargs(capsys): result = debug(a, b, foo=[1, 2, 3]) stdout, stderr = capsys.readouterr() print(stdout) - assert re.sub(r':\d{2,}', ':', stdout) == ( + assert normalise_output(stdout) == ( 'tests/test_main.py: test_print_kwargs\n' ' a: 1 (int)\n' ' b: 2 (int)\n' @@ -49,7 +49,7 @@ def test_print_generator(capsys): result = debug(gen) stdout, stderr = capsys.readouterr() print(stdout) - assert re.sub(r':\d{2,}', ':', stdout) == ( + assert normalise_output(stdout) == ( 'tests/test_main.py: test_print_generator\n' ' gen: (\n' ' 1,\n' @@ -66,7 +66,7 @@ def test_format(): a = b'i might bite' 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" @@ -75,6 +75,10 @@ def test_format(): ) +@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("""\ @@ -109,7 +113,12 @@ def test_odd_path(mocker): 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 def test_small_call_frame(): @@ -119,7 +128,7 @@ def test_small_call_frame(): 2, 3, ) - assert re.sub(r':\d{2,}', ':', str(v)) == ( + assert normalise_output(str(v)) == ( 'tests/test_main.py: test_small_call_frame\n' ' 1 (int)\n' ' 2 (int)\n' @@ -135,7 +144,7 @@ def test_small_call_frame_warning(): 3, ) print('\n---\n{}\n---'.format(v)) - assert re.sub(r':\d{2,}', ':', str(v)) == ( + assert normalise_output(str(v)) == ( 'tests/test_main.py: test_small_call_frame_warning\n' ' 1 (int)\n' ' 2 (int)\n' @@ -147,7 +156,7 @@ def test_small_call_frame_warning(): 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" @@ -160,7 +169,7 @@ 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", " first: 'variable' (str) len=8 variable=a", @@ -170,14 +179,14 @@ def test_kwargs_orderless(): 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)" ) - 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)>" ) @@ -239,17 +248,17 @@ def test_exec(capsys): 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) @@ -270,10 +279,14 @@ def test_breakpoint(mocker): assert mocked_set_trace.called +@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)', @@ -289,11 +302,10 @@ 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')" ) @@ -302,7 +314,7 @@ 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\n' ' [i * 2 for i in range(2)]: [0, 2] (list) len=2' diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 9f8d5c0..34c507d 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -361,7 +361,8 @@ def test_cimultidict(): 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: 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 From 67a74cd1eaf008a374580d224063f4c0bcc22932 Mon Sep 17 00:00:00 2001 From: Alex Hall Date: Wed, 8 Sep 2021 11:12:02 +0200 Subject: [PATCH 16/53] Show function qualname (#95) --- devtools/debug.py | 2 +- tests/test_expr_render.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/devtools/debug.py b/devtools/debug.py index 1954bf5..c56588c 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -166,7 +166,7 @@ def _process(self, args, kwargs) -> DebugOutput: arguments = list(self._args_inspection_failed(args, kwargs)) else: ex = source.executing(call_frame) - # function = ex.code_qualname() + function = ex.code_qualname() if not ex.node: warning = "executing failed to find the calling node" arguments = list(self._args_inspection_failed(args, kwargs)) diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index 9ff5fa6..6b30bad 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -192,7 +192,7 @@ def func(): # check only the original code is included in the warning 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)' ) @@ -217,7 +217,7 @@ def func(): # check only the original code is included in the warning s = normalise_output(str(v)) assert s == ( - 'tests/test_expr_render.py: func\n' + 'tests/test_expr_render.py: test_syntax_warning..func\n' ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' ) @@ -244,7 +244,7 @@ def func(): v = func() s = normalise_output(str(v)) assert s == ( - 'tests/test_expr_render.py: func\n' + 'tests/test_expr_render.py: test_no_syntax_warning..func\n' ' abs( abs( abs( abs( abs( -1 ) ) ) ) ): 1 (int)' ) @@ -261,7 +261,7 @@ async def bar(): loop.close() s = normalise_output(str(v)) assert ( - 'tests/test_expr_render.py: bar\n' + 'tests/test_expr_render.py: test_await..bar\n' ' await foo(): 1 (int)' ) == s From 54b2b2b672bb76dfb14f875b4b41957162bb257f Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 8 Sep 2021 11:38:28 +0100 Subject: [PATCH 17/53] cache pygments load (#96) * cache pygments load * fix cache import * linting * replace "nocover" --- devtools/prettier.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/devtools/prettier.py b/devtools/prettier.py index a2fbae3..36f2d57 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -5,6 +5,13 @@ from .utils import DataClassType, LaxMapping, SQLAlchemyClassType, env_true, isatty +try: + from functools import cache +except ImportError: + from functools import lru_cache + + cache = lru_cache() + __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: @@ -28,6 +35,7 @@ class SkipPretty(Exception): pass +@cache def get_pygments(): try: import pygments From 1e3e9427c46f69dcec897d507e04a53d4281aaab Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 10:35:20 +0100 Subject: [PATCH 18/53] uprev deps (#106) * uprev deps * drop 3.6, uprev, fix warnings * fix netlify, xfail on 3.10 * fix mkdocs * remove 3.6 comments * linting --- .github/workflows/ci.yml | 10 +++------- Makefile | 4 ++-- README.md | 4 ++-- devtools/prettier.py | 2 +- devtools/utils.py | 10 +++------- docs/install.md | 4 ++-- docs/requirements.txt | 11 +++++------ docs/usage.md | 2 +- mkdocs.yml | 13 ++++++++++--- runtime.txt | 2 +- setup.py | 3 +-- tests/requirements-linting.txt | 12 ++++++------ tests/requirements.txt | 14 ++++++-------- tests/test_main.py | 11 +++++------ tests/test_prettier.py | 2 -- 15 files changed, 48 insertions(+), 56 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c200a7..b8353a3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.10' - run: pip install -U pip wheel - run: pip install -r tests/requirements-linting.txt @@ -31,11 +31,7 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python-version: ['3.6', '3.7', '3.8', '3.9', '3.10.0-rc.1'] - exclude: - # numpy currently get's upset with macos and python 3.10 - - os: macos - python-version: '3.10.0-rc.1' + python-version: ['3.7', '3.8', '3.9', '3.10'] env: PYTHON: ${{ matrix.python-version }} @@ -95,7 +91,7 @@ jobs: - name: set up python uses: actions/setup-python@v2 with: - python-version: '3.8' + python-version: '3.10' - name: install run: make install diff --git a/Makefile b/Makefile index fe19741..bc3b058 100644 --- a/Makefile +++ b/Makefile @@ -21,11 +21,11 @@ lint: .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 diff --git a/README.md b/README.md index ab64a57..05383e4 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ pip install devtools[pygments] `pygments` is not required but if it's installed, output will be highlighted and easier to read. -`devtools` has no other required dependencies except python 3.6, 3.7, 3.8 or 3.9. -If you've got python 3.6+ and `pip` installed, you're good to go. +`devtools` has no other required dependencies except python 3.7, 3.8, 3.9 or 3.10. +If you've got python 3.7+ and `pip` installed, you're good to go. ## Usage diff --git a/devtools/prettier.py b/devtools/prettier.py index 36f2d57..d4e2b1f 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -74,7 +74,7 @@ def __init__( ((list, set, frozenset), self._format_list_like), (bytearray, self._format_bytearray), (generator_types, self._format_generator), - # put this last as the check can be slow + # put these last as the check can be slow (LaxMapping, self._format_dict), (DataClassType, self._format_dataclass), (SQLAlchemyClassType, self._format_sqlalchemy_class), diff --git a/devtools/utils.py b/devtools/utils.py index 034ece8..5956073 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -133,13 +133,9 @@ class LaxMapping(metaclass=MetaLaxMapping): class MetaDataClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: - try: - from dataclasses import _is_dataclass_instance - except ImportError: - # python 3.6 - return False - else: - return _is_dataclass_instance(instance) + from dataclasses import is_dataclass + + return is_dataclass(instance) class DataClassType(metaclass=MetaDataClassType): diff --git a/docs/install.md b/docs/install.md index d81e5f6..36cc597 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,5 +6,5 @@ pip install devtools[pygments] `pygments` is not required but if it's installed, output will be highlighted and easier to read. -`devtools` has no other required dependencies except python 3.6, 3.7, 3.8, or 3.9. -If you've got python 3.6+ and `pip` installed, you're good to go. +`devtools` has no other required dependencies except python 3.7, 3.8, 3.9 or 3.10. +If you've got python 3.7+ and `pip` installed, you're good to go. diff --git a/docs/requirements.txt b/docs/requirements.txt index 20804a2..7ecdc7d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,7 +1,6 @@ -ansi2html==1.5.2 -mkdocs==1.1.2 -markdown==3.2.2 +ansi2html==1.8.0 +mkdocs==1.3.1 mkdocs-exclude==1.0.2 -mkdocs-material==5.5.0 -markdown-include==0.5.1 -pygments==2.7.4 +mkdocs-material==8.3.9 +markdown-include==0.7.0 +pygments==2.12.0 diff --git a/docs/usage.md b/docs/usage.md index ad92c16..3e5a490 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -89,7 +89,7 @@ We all know the annoyance of running code only to discover a missing import, thi 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 +with ubuntu and python3.8 this file can be found at `/usr/lib/python3.8/sitecustomize.py` but you might need to look elsewhere depending on your OS/python version. Add the following to `sitecustomize.py` diff --git a/mkdocs.yml b/mkdocs.yml index d6de97d..268ac76 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' diff --git a/runtime.txt b/runtime.txt index 475ba51..cc1923a 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -3.7 +3.8 diff --git a/setup.py b/setup.py index d699598..7bf8f5b 100644 --- a/setup.py +++ b/setup.py @@ -30,7 +30,6 @@ 'Programming Language :: Python', 'Programming Language :: Python :: 3', 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3.7', 'Programming Language :: Python :: 3.8', 'Programming Language :: Python :: 3.9', @@ -49,7 +48,7 @@ url='https://github.com/samuelcolvin/python-devtools', license='MIT', packages=['devtools'], - python_requires='>=3.6', + python_requires='>=3.7', install_requires=[ 'executing>=0.8.0,<1.0.0', 'asttokens>=2.0.0,<3.0.0', diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt index 593bd2e..cf57c5c 100644 --- a/tests/requirements-linting.txt +++ b/tests/requirements-linting.txt @@ -1,6 +1,6 @@ -black==20.8b1 -flake8==3.9.2 -isort==5.9.3 -mypy==0.910 -pycodestyle==2.7.0 -pyflakes==2.3.1 +black==22.6.0 +flake8==4.0.1 +isort==5.10.1 +mypy==0.971 +pycodestyle==2.8.0 +pyflakes==2.4.0 diff --git a/tests/requirements.txt b/tests/requirements.txt index 3570e26..56a5dfa 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,12 +1,10 @@ -coverage==5.5 -Pygments==2.7.4 -pytest==6.2.5 -pytest-cov==2.12.1 -pytest-mock==3.6.1 -pytest-sugar==0.9.4 -pytest-toolbox==0.4 +coverage==6.4.2 +Pygments==2.12.0 +pytest==7.1.2 +pytest-mock==3.8.2 +pytest-sugar==0.9.5 pydantic asyncpg numpy multidict -sqlalchemy \ No newline at end of file +sqlalchemy diff --git a/tests/test_main.py b/tests/test_main.py index 667a79a..5693061 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -177,6 +177,7 @@ def test_kwargs_orderless(): } +@pytest.mark.xfail(sys.version_info >= (3, 10), reason='https://github.com/alexmojaki/executing/issues/40') def test_simple_vars(): v = debug.format('test', 1, 2) s = normalise_output(str(v)) @@ -212,12 +213,10 @@ 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(): diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 34c507d..7ec2b66 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -1,6 +1,5 @@ import os import string -import sys from collections import Counter, OrderedDict, namedtuple from dataclasses import dataclass from typing import List @@ -220,7 +219,6 @@ def test_counter(): })>""" -@pytest.mark.skipif(sys.version_info > (3, 7), reason='no datalcasses before 3.6') def test_dataclass(): @dataclass class FooDataclass: From 3838cc8a1539488767ab4c94f706a09d3ffd54ab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ali=20Eren=20=C3=96zt=C3=BCrk?= Date: Tue, 26 Jul 2022 12:50:18 +0300 Subject: [PATCH 19/53] fix format of nested dataclasses (#99) * fix format of nested dataclasses * import sys * unskip Co-authored-by: Samuel Colvin --- devtools/prettier.py | 4 +--- tests/test_prettier.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+), 3 deletions(-) diff --git a/devtools/prettier.py b/devtools/prettier.py index d4e2b1f..1154668 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -230,9 +230,7 @@ def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_ne self._str_lines(lines, indent_current, indent_new) def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int): - from dataclasses import asdict - - self._format_fields(value, asdict(value).items(), indent_current, indent_new) + self._format_fields(value, value.__dict__.items(), indent_current, indent_new) def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int): fields = [ diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 7ec2b66..6dea882 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -240,6 +240,29 @@ class FooDataclass: )""" +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, + ), +)""" + + @pytest.mark.skipif(numpy is None, reason='numpy not installed') def test_indent_numpy(): v = pformat({'numpy test': numpy.array(range(20))}) From cd5d511b6005caf41838e402218616fa76fa017a Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 15:18:52 +0100 Subject: [PATCH 20/53] Moving to `pyproject.toml` and complete type hints (#107) * moving to pyproject.toml * fixing mkdocs * tweak pyproject.toml * switch to hatch/hatchling * linting * revert removal of editable builds * bump * fix docs build * lint plugins.py * add history file * complete type hints * fix tests * revert "str->as_str" name change * tweak HISTORY * tweak pyproject.toml --- .github/workflows/ci.yml | 7 +-- .gitignore | 1 + HISTORY.md | 12 +++++ MANIFEST.in | 3 -- Makefile | 9 ++-- devtools/__init__.py | 2 + devtools/ansi.py | 28 +++++++---- devtools/debug.py | 40 +++++++++------ devtools/prettier.py | 68 ++++++++++++++------------ devtools/py.typed | 0 devtools/timer.py | 35 +++++++------ devtools/utils.py | 19 +++++--- devtools/version.py | 2 - docs/build/gen_html.py | 32 ------------ docs/build/main.py | 28 ----------- docs/index.md | 4 +- docs/plugins.py | 83 +++++++++++++++++++++++++++++++ docs/requirements.txt | 1 + docs/usage.md | 10 ++-- mkdocs.yml | 10 ++++ pyproject.toml | 89 ++++++++++++++++++++++++++++++++++ setup.cfg | 28 ----------- setup.py | 60 ----------------------- tests/requirements-linting.txt | 2 +- tests/requirements.txt | 3 +- 25 files changed, 327 insertions(+), 249 deletions(-) delete mode 100644 MANIFEST.in create mode 100644 devtools/py.typed delete mode 100644 docs/build/gen_html.py delete mode 100755 docs/build/main.py create mode 100755 docs/plugins.py create mode 100644 pyproject.toml delete mode 100644 setup.cfg delete mode 100644 setup.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b8353a3..59a9382 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,13 +94,10 @@ jobs: python-version: '3.10' - name: install - run: make install - - - name: set version - run: VERSION_PATH='devtools/version.py' python <(curl -Ls https://git.io/JT3rm) + run: pip install -U build twine setuptools - name: build - run: python setup.py sdist bdist_wheel + run: python -m build - run: twine check dist/* diff --git a/.gitignore b/.gitignore index 1b7451b..a0f90dd 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ old-version/ *.swp /site/ /site.zip +/build/ diff --git a/HISTORY.md b/HISTORY.md index 61dcf05..6f8c2c5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,11 @@ +## 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/) @@ -9,6 +17,10 @@ * display `dataclasses` properly, #88 * uprev test dependencies, #81, #83, #90 +## v0.6.1 (2020-10-22) + +compatibility with python 3.8.6 + ## v0.6.0 (2020-07-29) * improve `__pretty__` to work better with pydantic classes, #52 diff --git a/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 bc3b058..a899e5a 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ .DEFAULT_GOAL := all -isort = isort devtools tests -black = black -S -l 120 --target-version py37 devtools +isort = isort devtools tests docs/plugins.py +black = black -S -l 120 --target-version py37 devtools docs/plugins.py .PHONY: install install: @@ -15,9 +15,10 @@ format: .PHONY: lint lint: - flake8 devtools/ tests/ + flake8 --max-complexity 10 --max-line-length 120 --ignore E203,W503 devtools tests docs/plugins.py $(isort) --check-only --df $(black) --check --diff + mypy devtools .PHONY: test test: @@ -50,12 +51,10 @@ clean: .PHONY: docs docs: flake8 --max-line-length=80 docs/examples/ - python docs/build/main.py mkdocs build .PHONY: docs-serve docs-serve: - python docs/build/main.py mkdocs serve .PHONY: publish-docs diff --git a/devtools/__init__.py b/devtools/__init__.py index 6808bf5..d6a6564 100644 --- a/devtools/__init__.py +++ b/devtools/__init__.py @@ -4,3 +4,5 @@ from .prettier import * from .timer import * from .version import VERSION + +__version__ = VERSION diff --git a/devtools/ansi.py b/devtools/ansi.py index e0e7b3d..daa4c9d 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -6,10 +6,10 @@ MYPY = False if MYPY: - from typing import Any, Union + from typing import Any, Mapping, Union -def strip_ansi(value): +def strip_ansi(value: str) -> str: import re return re.sub('\033\\[((?:\\d|;)*)([a-zA-Z])', '', value) @@ -62,7 +62,7 @@ class Style(IntEnum): # this is a meta value used for the "Style" instance which is the "style" function function = -1 - def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bool = True) -> str: + def __call__(self, input: 'Any', *styles: 'Union[Style, int, str]', reset: bool = True, apply: bool = True) -> str: """ Styles text with ANSI styles and returns the new string. @@ -91,7 +91,7 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo s = self.styles[s] except KeyError: raise ValueError(f'invalid style "{s}"') - codes.append(_style_as_int(s.value)) + codes.append(_style_as_int(s.value)) # type: ignore if codes: r = _as_ansi(';'.join(codes)) + text @@ -103,16 +103,16 @@ def __call__(self, input: 'Any', *styles: 'Style', reset: bool = True, apply: bo return r @property - def styles(self): + def styles(self) -> 'Mapping[str, Style]': return self.__class__.__members__ - def __repr__(self): + def __repr__(self) -> str: if self == self.function: return '' else: return super().__repr__() - def __str__(self): + def __str__(self) -> str: if self == self.function: return repr(self) else: @@ -139,14 +139,22 @@ class StylePrint: for that mistake. """ - def __call__(self, input, *styles, reset=True, flush=True, file=None, **print_kwargs): + def __call__( + self, + input: str, + *styles: 'Union[Style, int, str]', + reset: bool = True, + flush: bool = True, + file: 'Any' = None, + **print_kwargs: 'Any', + ) -> None: text = sformat(input, *styles, reset=reset, apply=isatty(file)) print(text, flush=flush, file=file, **print_kwargs) - def __getattr__(self, item): + def __getattr__(self, item: str) -> str: return getattr(sformat, item) - def __repr__(self): + def __repr__(self) -> str: return '' diff --git a/devtools/debug.py b/devtools/debug.py index c56588c..657859c 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -10,7 +10,7 @@ MYPY = False if MYPY: from types import FrameType - from typing import Any, Generator, List, Optional + from typing import Any, Generator, List, Optional, Union pformat = PrettyFormat( indent_step=int(os.getenv('PY_DEVTOOLS_INDENT', 4)), @@ -18,12 +18,14 @@ width=int(os.getenv('PY_DEVTOOLS_WIDTH', 120)), yield_from_generators=env_true('PY_DEVTOOLS_YIELD_FROM_GEN', True), ) +# required for type hinting because I (stupidly) added methods called `str` +StrType = str class DebugArgument: __slots__ = 'value', 'name', 'extra' - def __init__(self, value, *, name=None, **extra): + def __init__(self, value: 'Any', *, name: 'Optional[str]' = None, **extra: 'Any') -> None: self.value = value self.name = name self.extra = [] @@ -35,7 +37,7 @@ def __init__(self, value, *, name=None, **extra): self.extra.append(('len', length)) self.extra += [(k, v) for k, v in extra.items() if v is not None] - def str(self, highlight=False) -> str: + def str(self, highlight: bool = False) -> StrType: s = '' if self.name and not is_literal(self.name): s = f'{sformat(self.name, sformat.blue, apply=highlight)}: ' @@ -54,7 +56,7 @@ def str(self, highlight=False) -> str: s += suffix return s - def __str__(self) -> str: + def __str__(self) -> StrType: return self.str() @@ -66,14 +68,22 @@ class DebugOutput: arg_class = DebugArgument __slots__ = 'filename', 'lineno', 'frame', 'arguments', 'warning' - def __init__(self, *, filename: str, lineno: int, frame: str, arguments: 'List[DebugArgument]', warning=None): + def __init__( + self, + *, + filename: str, + lineno: int, + frame: str, + arguments: 'List[DebugArgument]', + warning: 'Union[None, str, bool]' = None, + ) -> None: self.filename = filename self.lineno = lineno self.frame = frame self.arguments = arguments self.warning = warning - def str(self, highlight=False) -> str: + def str(self, highlight: bool = False) -> StrType: if highlight: prefix = ( f'{sformat(self.filename, sformat.magenta)}:{sformat(self.lineno, sformat.green)} ' @@ -87,10 +97,10 @@ def str(self, highlight=False) -> str: prefix += f' ({self.warning})' return f'{prefix}\n ' + '\n '.join(a.str(highlight) for a in self.arguments) - def __str__(self) -> str: + def __str__(self) -> StrType: return self.str() - def __repr__(self) -> str: + def __repr__(self) -> StrType: arguments = ' '.join(str(a) for a in self.arguments) return f'' @@ -102,7 +112,7 @@ def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bo self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True) self._highlight = highlight - def __call__(self, *args, file_=None, flush_=True, **kwargs) -> 'Any': + def __call__(self, *args: 'Any', file_: 'Any' = None, flush_: bool = True, **kwargs: 'Any') -> 'Any': d_out = self._process(args, kwargs) s = d_out.str(use_highlight(self._highlight, file_)) print(s, file=file_, flush=flush_) @@ -113,18 +123,18 @@ def __call__(self, *args, file_=None, flush_=True, **kwargs) -> 'Any': else: return args - def format(self, *args, **kwargs) -> DebugOutput: + def format(self, *args: 'Any', **kwargs: 'Any') -> DebugOutput: return self._process(args, kwargs) - def breakpoint(self): + def breakpoint(self) -> None: import pdb pdb.Pdb(skip=['devtools.*']).set_trace() - def timer(self, name=None, *, verbose=True, file=None, dp=3) -> Timer: + def timer(self, name: 'Optional[str]' = None, *, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> Timer: return Timer(name=name, verbose=verbose, file=file, dp=dp) - def _process(self, args, kwargs) -> DebugOutput: + def _process(self, args: 'Any', kwargs: 'Any') -> DebugOutput: """ BEWARE: this must be called from a function exactly 2 levels below the top of the stack. """ @@ -181,13 +191,13 @@ def _process(self, args, kwargs) -> DebugOutput: warning=self._show_warnings and warning, ) - def _args_inspection_failed(self, args, kwargs): + def _args_inspection_failed(self, args: 'Any', kwargs: 'Any') -> 'Generator[DebugArgument, None, None]': for arg in args: yield self.output_class.arg_class(arg) for name, value in kwargs.items(): yield self.output_class.arg_class(value, name=name) - def _process_args(self, ex, args, kwargs) -> 'Generator[DebugArgument, None, None]': + def _process_args(self, ex: 'Any', args: 'Any', kwargs: 'Any') -> 'Generator[DebugArgument, None, None]': import ast func_ast = ex.node diff --git a/devtools/prettier.py b/devtools/prettier.py index 1154668..bb5974c 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -15,7 +15,7 @@ __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: - from typing import Any, Iterable, Tuple, Union + from typing import Any, Callable, Iterable, List, Set, Tuple, Union PARENTHESES_LOOKUP = [ (list, '[', ']'), @@ -27,7 +27,7 @@ PRETTY_KEY = '__prettier_formatted_value__' -def fmt(v): +def fmt(v: 'Any') -> 'Any': return {PRETTY_KEY: v} @@ -36,11 +36,11 @@ class SkipPretty(Exception): @cache -def get_pygments(): +def get_pygments() -> 'Tuple[Any, Any, Any]': try: - import pygments - from pygments.formatters import Terminal256Formatter - from pygments.lexers import PythonLexer + import pygments # type: ignore + from pygments.formatters import Terminal256Formatter # type: ignore + from pygments.lexers import PythonLexer # type: ignore except ImportError: # pragma: no cover return None, None, None else: @@ -54,12 +54,12 @@ def get_pygments(): class PrettyFormat: def __init__( self, - indent_step=4, - indent_char=' ', - repr_strings=False, - simple_cutoff=10, - width=120, - yield_from_generators=True, + indent_step: int = 4, + indent_char: str = ' ', + repr_strings: bool = False, + simple_cutoff: int = 10, + width: int = 120, + yield_from_generators: bool = True, ): self._indent_step = indent_step self._c = indent_char @@ -67,7 +67,7 @@ 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), @@ -80,7 +80,7 @@ def __init__( (SQLAlchemyClassType, self._format_sqlalchemy_class), ] - def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, highlight: bool = False): + def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, highlight: bool = False) -> str: self._stream = io.StringIO() self._format(value, indent_current=indent, indent_first=indent_first) s = self._stream.getvalue() @@ -90,7 +90,7 @@ def __call__(self, value: 'Any', *, indent: int = 0, indent_first: bool = False, s = pygments.highlight(s, lexer=pyg_lexer, formatter=pyg_formatter).rstrip('\n') return s - def _format(self, value: 'Any', indent_current: int, indent_first: bool): + def _format(self, value: 'Any', indent_current: int, indent_first: bool) -> None: if indent_first: self._stream.write(indent_current * self._c) @@ -110,7 +110,7 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): except SkipPretty: pass else: - return + return None value_repr = repr(value) if len(value_repr) <= self._simple_cutoff and not isinstance(value, generator_types): @@ -120,11 +120,11 @@ def _format(self, value: 'Any', indent_current: int, indent_first: bool): for t, func in self._type_lookup: if isinstance(value, t): func(value, value_repr, indent_current, indent_new) - return + return None self._format_raw(value, value_repr, indent_current, indent_new) - def _render_pretty(self, gen, indent: int): + def _render_pretty(self, gen: 'Iterable[Any]', indent: int) -> None: prefix = False for v in gen: if isinstance(v, int) and v in {-1, 0, 1}: @@ -144,7 +144,7 @@ def _render_pretty(self, gen, indent: int): # shouldn't happen but will self._stream.write(repr(v)) - def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: open_, before_, split_, after_, close_ = '{\n', indent_new * self._c, ': ', ',\n', '}' if isinstance(value, OrderedDict): open_, split_, after_, close_ = 'OrderedDict([\n', ', ', '),\n', '])' @@ -161,7 +161,9 @@ def _format_dict(self, value: 'Any', _: str, indent_current: int, indent_new: in self._stream.write(after_) self._stream.write(indent_current * self._c + close_) - def _format_list_like(self, value: 'Union[list, tuple, set]', _: str, indent_current: int, indent_new: int): + def _format_list_like( + self, value: 'Union[List[Any], Tuple[Any, ...], Set[Any]]', _: str, indent_current: int, indent_new: int + ) -> None: open_, close_ = '(', ')' for t, *oc in PARENTHESES_LOOKUP: if isinstance(value, t): @@ -174,16 +176,18 @@ def _format_list_like(self, value: 'Union[list, tuple, set]', _: str, indent_cur self._stream.write(',\n') self._stream.write(indent_current * self._c + close_) - def _format_tuples(self, value: tuple, value_repr: str, indent_current: int, indent_new: int): + def _format_tuples(self, value: 'Tuple[Any, ...]', value_repr: str, indent_current: int, indent_new: int) -> None: fields = getattr(value, '_fields', None) if fields: # named tuple self._format_fields(value, zip(fields, value), indent_current, indent_new) else: # normal tuples are just like other similar iterables - return self._format_list_like(value, value_repr, indent_current, indent_new) + self._format_list_like(value, value_repr, indent_current, indent_new) - def _format_str_bytes(self, value: 'Union[str, bytes]', value_repr: str, indent_current: int, indent_new: int): + def _format_str_bytes( + self, value: 'Union[str, bytes]', value_repr: str, indent_current: int, indent_new: int + ) -> None: if self._repr_strings: self._stream.write(value_repr) else: @@ -193,14 +197,14 @@ def _format_str_bytes(self, value: 'Union[str, bytes]', value_repr: str, indent_ else: self._stream.write(value_repr) - def _str_lines(self, lines: 'Iterable[str]', indent_current: int, indent_new: int) -> None: + def _str_lines(self, lines: 'Iterable[Union[str, bytes]]', indent_current: int, indent_new: int) -> None: self._stream.write('(\n') prefix = indent_new * self._c for line in lines: self._stream.write(prefix + repr(line) + '\n') self._stream.write(indent_current * self._c + ')') - def _wrap_lines(self, s, indent_new) -> 'Generator[str, None, None]': + def _wrap_lines(self, s: 'Union[str, bytes]', indent_new: int) -> 'Generator[Union[str, bytes], None, None]': width = self._width - indent_new - 3 for line in s.splitlines(True): start = 0 @@ -209,7 +213,9 @@ def _wrap_lines(self, s, indent_new) -> 'Generator[str, None, None]': start = pos yield line[start:] - def _format_generator(self, value: Generator, value_repr: str, indent_current: int, indent_new: int): + def _format_generator( + self, value: 'Generator[Any, None, None]', value_repr: str, indent_current: int, indent_new: int + ) -> None: if self._repr_generators: self._stream.write(value_repr) else: @@ -224,15 +230,15 @@ def _format_generator(self, value: Generator, value_repr: str, indent_current: i self._stream.write(',\n') self._stream.write(indent_current * self._c + ')') - def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: self._stream.write('bytearray') lines = self._wrap_lines(bytes(value), indent_new) self._str_lines(lines, indent_current, indent_new) - def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: self._format_fields(value, value.__dict__.items(), indent_current, indent_new) - def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int): + def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: fields = [ (field, getattr(value, field)) for field in dir(value) @@ -240,7 +246,7 @@ def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, in ] self._format_fields(value, fields, indent_current, indent_new) - def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new: int): + def _format_raw(self, _: 'Any', value_repr: str, indent_current: int, indent_new: int) -> None: lines = value_repr.splitlines(True) if len(lines) > 1 or (len(value_repr) + indent_current) >= self._width: self._stream.write('(\n') @@ -274,6 +280,6 @@ def _format_fields( force_highlight = env_true('PY_DEVTOOLS_HIGHLIGHT', None) -def pprint(s, file=None): +def pprint(s: 'Any', file: 'Any' = None) -> None: highlight = isatty(file) if force_highlight is None else force_highlight print(pformat(s, highlight=highlight), file=file, flush=True) diff --git a/devtools/py.typed b/devtools/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/devtools/timer.py b/devtools/timer.py index 3182d1f..dd6c139 100644 --- a/devtools/timer.py +++ b/devtools/timer.py @@ -2,30 +2,37 @@ __all__ = ('Timer',) +MYPY = False +if MYPY: + from typing import Any, List, Optional, Set + +# 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.finish: 'Optional[float]' = None self.start = time() - def capture(self): + def capture(self) -> None: self.finish = time() - def elapsed(self): + def elapsed(self) -> float: if self.finish: return self.finish - self.start else: return -1 - def str(self, dp=3): + def str(self, dp: int = 3) -> StrType: if self._name: return f'{self._name}: {self.elapsed():0.{dp}f}s elapsed' else: return f'{self.elapsed():0.{dp}f}s elapsed' - def __str__(self): + def __str__(self) -> StrType: return self.str() @@ -33,25 +40,25 @@ def __str__(self): class Timer: - def __init__(self, name=None, verbose=True, file=None, dp=3): + def __init__(self, name: 'Optional[str]' = None, verbose: bool = True, file: 'Any' = None, dp: int = 3) -> None: self.file = file self.dp = dp self._name = name self._verbose = verbose - self.results = [] + self.results: 'List[TimerResult]' = [] - def __call__(self, name=None, verbose=None): + def __call__(self, name: 'Optional[str]' = None, verbose: 'Optional[bool]' = None) -> 'Timer': if name: self._name = name if verbose is not None: self._verbose = verbose return self - def start(self, name=None, verbose=None): + def start(self, name: 'Optional[str]' = None, verbose: 'Optional[bool]' = None) -> 'Timer': self.results.append(TimerResult(name or self._name, self._verbose if verbose is None else verbose)) return self - def capture(self, verbose=None): + def capture(self, verbose: 'Optional[bool]' = None) -> 'TimerResult': r = self.results[-1] r.capture() print_ = r.verbose if verbose is None else verbose @@ -59,7 +66,7 @@ def capture(self, verbose=None): print(r.str(self.dp), file=self.file, flush=True) return r - def summary(self, verbose=False): + def summary(self, verbose: bool = False) -> 'Set[float]': times = set() for r in self.results: if not r.finish: @@ -84,9 +91,9 @@ def summary(self, verbose=False): raise RuntimeError('timer not started') return times - def __enter__(self): + def __enter__(self) -> 'Timer': self.start() return self - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self, *args: 'Any') -> None: self.capture() diff --git a/devtools/utils.py b/devtools/utils.py index 5956073..994027e 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -14,10 +14,14 @@ MYPY = False if MYPY: - from typing import Any, Optional + from typing import Any, Optional, no_type_check +else: + def no_type_check(x: 'Any') -> 'Any': + return x -def isatty(stream=None): + +def isatty(stream: 'Any' = None) -> bool: stream = stream or sys.stdout try: return stream.isatty() @@ -25,7 +29,7 @@ def isatty(stream=None): return False -def env_true(var_name: str, alt: 'Optional[bool]' = None) -> 'Optional[bool]': +def env_true(var_name: str, alt: 'Optional[bool]' = None) -> 'Any': env = os.getenv(var_name, None) if env: return env.upper() in {'1', 'TRUE'} @@ -40,6 +44,7 @@ def env_bool(value: 'Optional[bool]', env_name: str, env_default: 'Optional[bool return value +@no_type_check def activate_win_color() -> bool: # pragma: no cover """ Activate ANSI support on windows consoles. @@ -88,14 +93,14 @@ def _set_conout_mode(new_mode, mask=0xFFFFFFFF): mode = mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING try: _set_conout_mode(mode, mask) - except WindowsError as e: + except WindowsError as e: # type: ignore if e.winerror == ERROR_INVALID_PARAMETER: return False raise return True -def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool: +def use_highlight(highlight: 'Optional[bool]' = None, file_: 'Any' = None) -> bool: highlight = env_bool(highlight, 'PY_DEVTOOLS_HIGHLIGHT', None) if highlight is not None: @@ -106,7 +111,7 @@ def use_highlight(highlight: 'Optional[bool]' = None, file_=None) -> bool: return isatty(file_) -def is_literal(s): +def is_literal(s: 'Any') -> bool: import ast try: @@ -145,7 +150,7 @@ class DataClassType(metaclass=MetaDataClassType): class MetaSQLAlchemyClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: try: - from sqlalchemy.ext.declarative import DeclarativeMeta + from sqlalchemy.ext.declarative import DeclarativeMeta # type: ignore except ImportError: return False else: diff --git a/devtools/version.py b/devtools/version.py index 34672f0..2810d0a 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1,3 +1 @@ -__all__ = ('VERSION',) - VERSION = '0.7.0' 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/index.md b/docs/index.md index 5098d6a..8a0b125 100644 --- a/docs/index.md +++ b/docs/index.md @@ -6,7 +6,7 @@ [![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) -{!.version.md!} +{{ version }} **Python's missing debug print command and other development tools.** @@ -14,6 +14,6 @@ {!examples/example.py!} ``` -{!examples/example.html!} +{{ example_html(examples/example.py) }} Python devtools can do much more, see [Usage](./usage.md) for examples. diff --git a/docs/plugins.py b/docs/plugins.py new file mode 100755 index 0000000..6e51aef --- /dev/null +++ b/docs/plugins.py @@ -0,0 +1,83 @@ +#!/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) + (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: **{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 in {'plugins.py', 'requirements.txt'}: + to_remove.append(file) + elif file.src_path.startswith('__pycache__/'): + 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 7ecdc7d..540379d 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -2,5 +2,6 @@ ansi2html==1.8.0 mkdocs==1.3.1 mkdocs-exclude==1.0.2 mkdocs-material==8.3.9 +mkdocs-simple-hooks==0.1.5 markdown-include==0.7.0 pygments==2.12.0 diff --git a/docs/usage.md b/docs/usage.md index 3e5a490..eb8f3e1 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -9,7 +9,7 @@ and readable way to print stuff during development. (If you know why this is, I' {!examples/example.py!} ``` -{!examples/example.html!} +{{ example_html(examples/example.py) }} `debug` is like `print` after a good night's sleep and lots of coffee: @@ -24,7 +24,7 @@ A more complex example of `debug` shows more of what it can do. {!examples/complex.py!} ``` -{!examples/complex.html!} +{{ example_html(examples/complex.py) }} ### Returning the arguments @@ -40,7 +40,7 @@ The returned arguments work as follows: {!examples/return_args.py!} ``` -{!examples/return_args.html!} +{{ example_html(examples/return_args.py) }} ## Other debug tools @@ -54,7 +54,7 @@ The debug namespace includes a number of other useful functions: {!examples/other.py!} ``` -{!examples/other.html!} +{{ example_html(examples/other.py) }} ### Prettier print @@ -69,7 +69,7 @@ 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). diff --git a/mkdocs.yml b/mkdocs.yml index 268ac76..b088763 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -43,6 +43,11 @@ markdown_extensions: - codehilite - extra - attr_list +- pymdownx.highlight: + anchor_linenums: true +- pymdownx.inlinehilite +- pymdownx.snippets +- pymdownx.superfences plugins: - search @@ -51,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..80ecf52 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,89 @@ +[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', + 'Topic :: Software Development :: Libraries :: Python Modules', + 'Topic :: Internet', + 'Typing :: Typed', +] +requires-python = '>=3.7' +dependencies = [ + 'executing>=0.8.0,<1.0.0', + 'asttokens>=2.0.0,<3.0.0', +] +optional-dependencies = {pygments = ['Pygments>=2.2.0'] } +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' + +[tool.pytest.ini_options] +testpaths = 'tests' +filterwarnings = 'error' + +[tool.coverage.run] +source = ['devtools'] +branch = true + +[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'] +skip-string-normalization = true + +[tool.isort] +line_length = 120 +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +combine_as_imports = true +color_output = true + +[tool.mypy] +strict = true +warn_return_any = false + +[[tool.mypy.overrides]] +module = ['executing.*'] +ignore_missing_imports = true diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index f58b3c4..0000000 --- a/setup.cfg +++ /dev/null @@ -1,28 +0,0 @@ -[tool:pytest] -testpaths = tests -filterwarnings = error - -[flake8] -max-line-length = 120 -max-complexity = 12 - -[coverage:run] -source = devtools -branch = True - -[coverage:report] -precision = 2 -exclude_lines = - pragma: no cover - raise NotImplementedError - raise NotImplemented - if MYPY: - @overload - -[isort] -line_length=120 -known_first_party=devtools -multi_line_output=3 -include_trailing_comma=True -force_grid_wrap=0 -combine_as_imports=True diff --git a/setup.py b/setup.py deleted file mode 100644 index 7bf8f5b..0000000 --- a/setup.py +++ /dev/null @@ -1,60 +0,0 @@ -import re -from importlib.machinery import SourceFileLoader -from pathlib import Path -from setuptools import setup - -description = "Python's missing debug print command and other development tools." -THIS_DIR = Path(__file__).resolve().parent -try: - history = (THIS_DIR / 'HISTORY.md').read_text() - history = re.sub(r'#(\d+)', r'[#\1](https://github.com/samuelcolvin/python-devtools/issues/\1)', history) - history = re.sub(r'( +)@([\w\-]+)', r'\1[@\2](https://github.com/\2)', history, flags=re.I) - history = re.sub('@@', '@', history) - - long_description = (THIS_DIR / 'README.md').read_text() + '\n\n' + history -except FileNotFoundError: - long_description = description + '.\n\nSee https://python-devtools.helpmanual.io/ for documentation.' - -# avoid loading the package before requirements are installed: -version = SourceFileLoader('version', 'devtools/version.py').load_module() - -setup( - name='devtools', - version=str(version.VERSION), - description=description, - long_description=long_description, - long_description_content_type='text/markdown', - classifiers=[ - 'Development Status :: 5 - Production/Stable', - 'Environment :: Console', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3 :: Only', - 'Programming Language :: Python :: 3.7', - 'Programming Language :: Python :: 3.8', - 'Programming Language :: Python :: 3.9', - 'Programming Language :: Python :: 3.10', - 'Intended Audience :: Developers', - 'Intended Audience :: Information Technology', - 'Intended Audience :: System Administrators', - 'License :: OSI Approved :: MIT License', - 'Operating System :: Unix', - 'Operating System :: POSIX :: Linux', - 'Environment :: MacOS X', - 'Topic :: Software Development :: Libraries :: Python Modules', - ], - author='Samuel Colvin', - author_email='s@muelcolvin.com', - url='https://github.com/samuelcolvin/python-devtools', - license='MIT', - packages=['devtools'], - python_requires='>=3.7', - install_requires=[ - 'executing>=0.8.0,<1.0.0', - 'asttokens>=2.0.0,<3.0.0', - ], - extras_require={ - 'pygments': ['Pygments>=2.2.0'], - }, - zip_safe=True, -) diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt index cf57c5c..b84d0b2 100644 --- a/tests/requirements-linting.txt +++ b/tests/requirements-linting.txt @@ -1,6 +1,6 @@ black==22.6.0 flake8==4.0.1 -isort==5.10.1 +isort[colors]==5.10.1 mypy==0.971 pycodestyle==2.8.0 pyflakes==2.4.0 diff --git a/tests/requirements.txt b/tests/requirements.txt index 56a5dfa..bf6b169 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,8 +1,9 @@ -coverage==6.4.2 +coverage[toml]==6.4.2 Pygments==2.12.0 pytest==7.1.2 pytest-mock==3.8.2 pytest-sugar==0.9.5 +# these packages are used in tests so install the latest version pydantic asyncpg numpy From 352dc9e3cb82ba3281a42b87053a1dd5cc32b384 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 15:23:03 +0100 Subject: [PATCH 21/53] rename master -> main --- .github/workflows/ci.yml | 2 +- README.md | 8 ++++---- docs/index.md | 6 +++--- docs/usage.md | 4 ++-- 4 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 59a9382..89f98f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,7 @@ name: CI on: push: branches: - - master + - main tags: - '**' pull_request: {} diff --git a/README.md b/README.md index 05383e4..80cb404 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.** @@ -57,7 +57,7 @@ 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 diff --git a/docs/index.md b/docs/index.md index 8a0b125..b6bc7b1 100644 --- a/docs/index.md +++ b/docs/index.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) {{ version }} diff --git a/docs/usage.md b/docs/usage.md index eb8f3e1..8445c6f 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -72,7 +72,7 @@ in `debug()`, but it can also be used directly: {{ example_html(examples/prettier.py) }} For more details on prettier printing, see -[`prettier.py`](https://github.com/samuelcolvin/python-devtools/blob/master/devtools/prettier.py). +[`prettier.py`](https://github.com/samuelcolvin/python-devtools/blob/main/devtools/prettier.py). ## ANSI terminal colours @@ -81,7 +81,7 @@ 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 From 4cd875cef1f972a5c798d712ae3a342c10f415ea Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 16:47:05 +0100 Subject: [PATCH 22/53] add install command (#108) * add install command * improve usage without import docs * fix linting, tweak __main__.py --- README.md | 14 +++----- devtools/__main__.py | 64 ++++++++++++++++++++++++++++++++++ docs/examples/sitecustomize.py | 8 ----- docs/plugins.py | 8 +++-- docs/usage.md | 36 +++++++++++++++---- pyproject.toml | 1 + 6 files changed, 105 insertions(+), 26 deletions(-) create mode 100644 devtools/__main__.py delete mode 100644 docs/examples/sitecustomize.py diff --git a/README.md b/README.md index 80cb404..80218cc 100644 --- a/README.md +++ b/README.md @@ -61,14 +61,8 @@ outputs: ## 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/__main__.py b/devtools/__main__.py new file mode 100644 index 0000000..bf84fbf --- /dev/null +++ b/devtools/__main__.py @@ -0,0 +1,64 @@ +import os +import sys +from pathlib import Path + +from .version import VERSION + +# language=python +install_code = """ +# add devtools `debug` function to builtins +try: + from devtools import debug +except ImportError: + pass +else: + __builtins__['debug'] = debug +""" + + +def print_code() -> int: + print(install_code) + return 0 + + +def install() -> int: + print('[WARNING: this command is experimental, report issues at github.com/samuelcolvin/python-devtools]\n') + + if 'debug' in __builtins__.__dict__: + print('Looks like devtools is already installed.') + return 0 + + 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__) + + print(f'Found path "{install_path}" to install devtools into __builtins__') + print('To install devtools, run the following command:\n') + if os.access(install_path, os.W_OK): + print(f' python -m devtools print-code >> {install_path}\n') + else: + print(f' python -m devtools print-code | sudo tee -a {install_path} > /dev/null\n') + print('Note: "sudo" is required because the path is not writable by the current user.') + + 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/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/plugins.py b/docs/plugins.py index 6e51aef..d3ec8c4 100755 --- a/docs/plugins.py +++ b/docs/plugins.py @@ -39,7 +39,9 @@ def build_history(): 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) - (THIS_DIR / '.history.md').write_text(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): @@ -71,10 +73,12 @@ def set_version(markdown: str, page: Page) -> str: def remove_files(files: Files) -> Files: to_remove = [] for file in files: - if file.src_path in {'plugins.py', 'requirements.txt'}: + 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: diff --git a/docs/usage.md b/docs/usage.md index 8445c6f..ee5c15a 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -83,19 +83,43 @@ For more details on prettier printing, see For more details on ansi colours, see [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.8 this file can be found at `/usr/lib/python3.8/sitecustomize.py` but you might -need to look elsewhere depending on your OS/python version. +devtool's `debug` function can be used without import if you add `debug` to `__builtins__` +in `sitecustomize.py`. -Add the following to `sitecustomize.py` +Two ways to do this: + +### Automatic install + +!!! warning + This is experimental, please [create an issue](https://github.com/samuelcolvin/python-devtools/issues) + if you encounter any problems. + +To install `debug` into `__builtins__` automatically, run: + +```bash +python -m devtools install +``` + +This command won't write to any files, but it should print a command for you to run to add/edit `sitecustomize.py`. + +### Manual install + +To manually add `debug` to `__builtins__`, add the following to `sitecustomize.py` or any code +which is always imported. ```py -{!examples/sitecustomize.py!} +# add devtools `debug` function to builtins +try: + from devtools import debug +except ImportError: + pass +else: + __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/pyproject.toml b/pyproject.toml index 80ecf52..2153541 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,6 +54,7 @@ filterwarnings = 'error' [tool.coverage.run] source = ['devtools'] branch = true +omit = ['devtools/__main__.py'] [tool.coverage.report] precision = 2 From b4c4275de315ec19e1d365c6eeabcdb154f71f36 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 16:54:09 +0100 Subject: [PATCH 23/53] prepare for release --- HISTORY.md | 6 ++++++ devtools/version.py | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 6f8c2c5..fcbebe3 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,9 @@ +## 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 diff --git a/devtools/version.py b/devtools/version.py index 2810d0a..b9b28aa 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.7.0' +VERSION = '0.9.0' From fe1cac92394cb9f2b904da4aa4e2895d920459c1 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 17:04:27 +0100 Subject: [PATCH 24/53] fix deploy --- .github/workflows/ci.yml | 2 +- Makefile | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89f98f7..69abdd2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -94,7 +94,7 @@ jobs: python-version: '3.10' - name: install - run: pip install -U build twine setuptools + run: make install - name: build run: python -m build diff --git a/Makefile b/Makefile index a899e5a..15225e9 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,7 @@ black = black -S -l 120 --target-version py37 devtools docs/plugins.py .PHONY: install install: - python -m pip install -U setuptools pip wheel twine + python -m pip install -U setuptools pip wheel twine build pip install -U -r requirements.txt pip install -e . From 297e9229fd6189588e515ed6cb2e99b2ad6c7ae5 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Tue, 26 Jul 2022 17:06:09 +0100 Subject: [PATCH 25/53] fix version in docs --- docs/plugins.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plugins.py b/docs/plugins.py index d3ec8c4..aa46876 100755 --- a/docs/plugins.py +++ b/docs/plugins.py @@ -65,7 +65,7 @@ def gen_examples_html(m: re.Match) -> str: 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: **{version}**' + version_str = f'Documentation for version: **v{version.VERSION}**' markdown = re.sub(r'{{ *version *}}', version_str, markdown) return markdown From 0909f014e0d248b2a8f616525c2f3569e7251fc1 Mon Sep 17 00:00:00 2001 From: 0xsirsaif Date: Wed, 27 Jul 2022 11:16:56 +0200 Subject: [PATCH 26/53] Use secure builtins standard module, instead of the __builtins__ (#109) * Use secure builtins standard module, instead of the __builtins__ * use single quotes, and update the docs example * single quotes again, use hasattr() --- devtools/__main__.py | 6 ++++-- docs/usage.md | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/devtools/__main__.py b/devtools/__main__.py index bf84fbf..726f825 100644 --- a/devtools/__main__.py +++ b/devtools/__main__.py @@ -1,3 +1,4 @@ +import builtins import os import sys from pathlib import Path @@ -7,12 +8,13 @@ # language=python install_code = """ # add devtools `debug` function to builtins +import builtins try: from devtools import debug except ImportError: pass else: - __builtins__['debug'] = debug + setattr(builtins, 'debug', debug) """ @@ -24,7 +26,7 @@ def print_code() -> int: def install() -> int: print('[WARNING: this command is experimental, report issues at github.com/samuelcolvin/python-devtools]\n') - if 'debug' in __builtins__.__dict__: + if hasattr(builtins, 'debug'): print('Looks like devtools is already installed.') return 0 diff --git a/docs/usage.md b/docs/usage.md index ee5c15a..68bb6cb 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -114,12 +114,13 @@ which is always imported. ```py # add devtools `debug` function to builtins +import builtins try: from devtools import debug except ImportError: pass else: - __builtins__['debug'] = debug + setattr(builtins, 'debug', debug) ``` The `ImportError` exception is important since you'll want python to run fine even if *devtools* isn't installed. From f4dfed484ce246943da324f0e24a13d80fbc0558 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 29 Jul 2022 12:26:53 +0100 Subject: [PATCH 27/53] upgrade executing to fix 3.10 (#110) --- pyproject.toml | 2 +- tests/test_main.py | 1 - 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 2153541..c21820a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] requires-python = '>=3.7' dependencies = [ - 'executing>=0.8.0,<1.0.0', + 'executing>=0.9.1,<1.0.0', 'asttokens>=2.0.0,<3.0.0', ] optional-dependencies = {pygments = ['Pygments>=2.2.0'] } diff --git a/tests/test_main.py b/tests/test_main.py index 5693061..f0cce7d 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -177,7 +177,6 @@ def test_kwargs_orderless(): } -@pytest.mark.xfail(sys.version_info >= (3, 10), reason='https://github.com/alexmojaki/executing/issues/40') def test_simple_vars(): v = debug.format('test', 1, 2) s = normalise_output(str(v)) From 5efff5fdcf8539e7a4f539d018f785ba4d0e59c4 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Mon, 1 Aug 2022 13:13:33 +0100 Subject: [PATCH 28/53] Fix windows build (#111) * Fix windows build * try again :sleeping: --- .github/workflows/ci.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 69abdd2..3980217 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,6 @@ jobs: with: python-version: '3.10' - - run: pip install -U pip wheel - run: pip install -r tests/requirements-linting.txt - run: pip install . @@ -48,7 +47,6 @@ jobs: with: python-version: ${{ matrix.python-version }} - - run: pip install -U pip wheel - run: pip install -r tests/requirements.txt - run: pip install . - run: pip freeze From ba0507a369311b1e5d2b99599dd56905bdbf9358 Mon Sep 17 00:00:00 2001 From: Riley <44530786+staticf0x@users.noreply.github.com> Date: Fri, 25 Nov 2022 11:23:41 +0100 Subject: [PATCH 29/53] Allow executing dependency to be >1.0.0 (#115) * Allow executing dependency to be >1.0.0 * Constrain executing on >=1.1.1 * Remove upper bound --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index c21820a..ebe9dd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,7 +34,7 @@ classifiers = [ ] requires-python = '>=3.7' dependencies = [ - 'executing>=0.9.1,<1.0.0', + 'executing>=1.1.1', 'asttokens>=2.0.0,<3.0.0', ] optional-dependencies = {pygments = ['Pygments>=2.2.0'] } From 560b9884919bf06cef6e91125b4f9af93299f6fe Mon Sep 17 00:00:00 2001 From: banteg <4562643+banteg@users.noreply.github.com> Date: Fri, 25 Nov 2022 13:25:10 +0300 Subject: [PATCH 30/53] more precise timer summary (#113) * fix: use all timers in summary * fix: lint * chore: lint --- devtools/timer.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/devtools/timer.py b/devtools/timer.py index dd6c139..822ea44 100644 --- a/devtools/timer.py +++ b/devtools/timer.py @@ -1,10 +1,10 @@ -from time import time +from time import perf_counter __all__ = ('Timer',) MYPY = False if MYPY: - from typing import Any, List, Optional, Set + from typing import Any, List, Optional # required for type hinting because I (stupidly) added methods called `str` StrType = str @@ -15,10 +15,10 @@ def __init__(self, name: 'Optional[str]' = None, verbose: bool = True) -> None: self._name = name self.verbose = verbose self.finish: 'Optional[float]' = None - self.start = time() + self.start = perf_counter() def capture(self) -> None: - self.finish = time() + self.finish = perf_counter() def elapsed(self) -> float: if self.finish: @@ -66,14 +66,14 @@ def capture(self, verbose: 'Optional[bool]' = None) -> 'TimerResult': print(r.str(self.dp), file=self.file, flush=True) return r - def summary(self, verbose: bool = False) -> 'Set[float]': - times = set() + def summary(self, verbose: bool = False) -> 'List[float]': + times = [] for r in self.results: if not r.finish: r.capture() if verbose: print(f' {r.str(self.dp)}', file=self.file) - times.add(r.elapsed()) + times.append(r.elapsed()) if times: from statistics import mean, stdev From 90337c7f463fb941b6aa561d0fd640638375b34b Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Fri, 25 Nov 2022 11:37:17 +0000 Subject: [PATCH 31/53] Python 3.11 (#118) * add 3.11 * add check job * fix test swith 3.11 * fix docs build --- .github/workflows/ci.yml | 32 +++++++++++++++++++++----------- .gitignore | 4 +--- README.md | 2 +- devtools/ansi.py | 5 ++++- docs/install.md | 2 +- docs/requirements.txt | 2 +- pyproject.toml | 1 + tests/requirements.txt | 10 +++++----- 8 files changed, 35 insertions(+), 23 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3980217..7cd8862 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,9 +13,9 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - - uses: actions/setup-python@v2 + - uses: actions/setup-python@v4 with: python-version: '3.10' @@ -30,7 +30,7 @@ jobs: fail-fast: false matrix: os: [ubuntu, macos, windows] - python-version: ['3.7', '3.8', '3.9', '3.10'] + python-version: ['3.7', '3.8', '3.9', '3.10', '3.11'] env: PYTHON: ${{ matrix.python-version }} @@ -40,10 +40,10 @@ jobs: runs-on: ${{ matrix.os }}-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} @@ -56,7 +56,7 @@ jobs: - run: coverage xml - - uses: codecov/codecov-action@v2.0.3 + - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS @@ -69,25 +69,35 @@ jobs: - run: coverage xml - - uses: codecov/codecov-action@v2.0.3 + - uses: codecov/codecov-action@v3 with: file: ./coverage.xml env_vars: EXTRAS,PYTHON,OS env: EXTRAS: no + # https://github.com/marketplace/actions/alls-green#why used for branch protection checks + check: + if: always() + needs: [test, lint] + runs-on: ubuntu-latest + steps: + - name: Decide whether the needed jobs succeeded or failed + uses: re-actors/alls-green@release/v1 + with: + jobs: ${{ toJSON(needs) }} + deploy: needs: - - test - - lint + - check if: "success() && startsWith(github.ref, 'refs/tags/')" runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: set up python - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: '3.10' diff --git a/.gitignore b/.gitignore index a0f90dd..c6e6fb8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,6 @@ .idea/ env/ -env35/ -env36/ -env37/ +env*/ *.py[cod] *.egg-info/ dist/ diff --git a/README.md b/README.md index 80218cc..6a78b47 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ pip install devtools[pygments] `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.7, 3.8, 3.9 or 3.10. +`devtools` has no other required dependencies except python 3.7, 3.8, 3.9, 3.10 or 3.11. If you've got python 3.7+ and `pip` installed, you're good to go. ## Usage diff --git a/devtools/ansi.py b/devtools/ansi.py index daa4c9d..e31a2d1 100644 --- a/devtools/ansi.py +++ b/devtools/ansi.py @@ -116,7 +116,10 @@ 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: diff --git a/docs/install.md b/docs/install.md index 36cc597..94630bb 100644 --- a/docs/install.md +++ b/docs/install.md @@ -6,5 +6,5 @@ pip install devtools[pygments] `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.7, 3.8, 3.9 or 3.10. +`devtools` has no other required dependencies except python 3.7, 3.8, 3.9, 3.10 or 3.11. If you've got python 3.7+ and `pip` installed, you're good to go. diff --git a/docs/requirements.txt b/docs/requirements.txt index 540379d..3a7c1d2 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ mkdocs-exclude==1.0.2 mkdocs-material==8.3.9 mkdocs-simple-hooks==0.1.5 markdown-include==0.7.0 -pygments==2.12.0 +pygments==2.13.0 diff --git a/pyproject.toml b/pyproject.toml index ebe9dd4..87bff20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ classifiers = [ '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', diff --git a/tests/requirements.txt b/tests/requirements.txt index bf6b169..cd9c6b2 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,8 +1,8 @@ -coverage[toml]==6.4.2 -Pygments==2.12.0 -pytest==7.1.2 -pytest-mock==3.8.2 -pytest-sugar==0.9.5 +coverage[toml]==6.5.0 +Pygments==2.13.0 +pytest==7.2.0 +pytest-mock==3.10.0 +pytest-pretty==0.0.1 # these packages are used in tests so install the latest version pydantic asyncpg From 8811bfed6166284179a9b09a4a18390224bf61c8 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Mon, 28 Nov 2022 10:39:26 +0000 Subject: [PATCH 32/53] uprev --- devtools/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/version.py b/devtools/version.py index b9b28aa..ea301cb 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.9.0' +VERSION = '0.10.0' From abed0a58929311475d74f48d6080c948661e0879 Mon Sep 17 00:00:00 2001 From: Victor Naumov Date: Mon, 6 Feb 2023 17:22:45 +0100 Subject: [PATCH 33/53] added support for sqlalchemy2 (#120) * added support for sqlalchemy2 * added declarative_base support for sqlalchemy 2.0 * fixed sqlalchemy.exc.MovedIn20Warning. using sqlalchemy.orm.declarative_base * make check happy * simplified the sqlalchemy check * making black happy * added support of deferred fields * yet another sqlalchemy check * made mypy happier --------- Co-authored-by: victor naumov --- devtools/prettier.py | 13 ++++++++++++- devtools/utils.py | 12 +++++++++++- tests/test_prettier.py | 6 +++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/devtools/prettier.py b/devtools/prettier.py index bb5974c..fe01128 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -12,6 +12,11 @@ cache = lru_cache() +try: + from sqlalchemy import inspect as sa_inspect # type: ignore +except ImportError: + sa_inspect = None + __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False if MYPY: @@ -239,8 +244,14 @@ def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_ne self._format_fields(value, value.__dict__.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)) + (field, getattr(value, field) if field not in deferred else "") for field in dir(value) if not (field.startswith('_') or field in ['metadata', 'registry']) ] diff --git a/devtools/utils.py b/devtools/utils.py index 994027e..c0ac1a3 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -149,13 +149,23 @@ class DataClassType(metaclass=MetaDataClassType): class MetaSQLAlchemyClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: + try: + from sqlalchemy.orm import DeclarativeBase # type: ignore + except ImportError: + pass + else: + if isinstance(instance, DeclarativeBase): + return True + try: from sqlalchemy.ext.declarative import DeclarativeMeta # type: ignore except ImportError: - return False + pass else: return isinstance(instance.__class__, DeclarativeMeta) + return False + class SQLAlchemyClassType(metaclass=MetaSQLAlchemyClassType): pass diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 6dea882..657e1b7 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -28,7 +28,11 @@ try: from sqlalchemy import Column, Integer, String - from sqlalchemy.ext.declarative import declarative_base + try: + from sqlalchemy.orm import declarative_base + except ImportError: + from sqlalchemy.ext.declarative import declarative_base + SQLAlchemyBase = declarative_base() except ImportError: SQLAlchemyBase = None From 8f087b210bc41a0a02be689c582050a796e19867 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 12:41:16 +0100 Subject: [PATCH 34/53] switch to ruff (#124) * switch to ruff * revert tests/test_expr_render.py * fix pyproject.toml, etc. * switch to pinned dependencies * switch to 3.7 deps * skip some tests on 3.7, add pre-commit --- .github/workflows/ci.yml | 13 ++- .pre-commit-config.yaml | 25 +++++ HISTORY.md | 2 +- Makefile | 31 ++++-- README.md | 2 +- devtools/debug.py | 2 +- devtools/prettier.py | 6 +- devtools/utils.py | 6 +- docs/plugins.py | 2 +- docs/usage.md | 2 +- pyproject.toml | 19 ++-- requirements.txt | 3 - requirements/all.txt | 4 + requirements/docs.in | 7 ++ requirements/docs.txt | 67 ++++++++++++ requirements/linting.in | 5 + requirements/linting.txt | 34 +++++++ requirements/pyproject.txt | 12 +++ requirements/testing.in | 11 ++ requirements/testing.txt | 55 ++++++++++ tests/requirements-linting.txt | 6 -- tests/requirements.txt | 11 -- tests/test_custom_pretty.py | 5 +- tests/test_expr_render.py | 6 +- tests/test_main.py | 54 ++++------ tests/test_prettier.py | 181 ++++++++++++++------------------- 26 files changed, 378 insertions(+), 193 deletions(-) create mode 100644 .pre-commit-config.yaml delete mode 100644 requirements.txt create mode 100644 requirements/all.txt create mode 100644 requirements/docs.in create mode 100644 requirements/docs.txt create mode 100644 requirements/linting.in create mode 100644 requirements/linting.txt create mode 100644 requirements/pyproject.txt create mode 100644 requirements/testing.in create mode 100644 requirements/testing.txt delete mode 100644 tests/requirements-linting.txt delete mode 100644 tests/requirements.txt diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7cd8862..ad2c060 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,10 +19,13 @@ jobs: with: python-version: '3.10' - - run: pip install -r tests/requirements-linting.txt - - run: pip install . + - run: pip install -r requirements/linting.txt -r requirements/pyproject.txt + + - run: mypy devtools - - run: make lint + - uses: pre-commit/action@v3.0.0 + with: + extra_args: --all-files --verbose test: name: test py${{ matrix.python-version }} on ${{ matrix.os }} @@ -47,7 +50,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - - run: pip install -r tests/requirements.txt + - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt - run: pip install . - run: pip freeze @@ -102,7 +105,7 @@ jobs: python-version: '3.10' - name: install - run: make install + run: pip install build twine - name: build run: python -m 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 fcbebe3..2ebbb56 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -14,7 +14,7 @@ ## v0.7.0 (2021-09-03) -* switch to [`executing`](https://pypi.org/project/executing/) and [`asttokens`](https://pypi.org/project/asttokens/) +* switch to [`executing`](https://pypi.org/project/executing/) and [`asttokens`](https://pypi.org/project/asttokens/) for finding and printing debug arguments, #82, thanks @alexmojaki * correct changelog links, #76, thanks @Cielquan * return `debug()` arguments, #87 diff --git a/Makefile b/Makefile index 15225e9..3a464eb 100644 --- a/Makefile +++ b/Makefile @@ -1,23 +1,36 @@ .DEFAULT_GOAL := all -isort = isort devtools tests docs/plugins.py -black = black -S -l 120 --target-version py37 devtools docs/plugins.py +sources = devtools tests docs/plugins.py .PHONY: install install: - python -m pip install -U setuptools pip wheel twine build - 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 --exit-zero .PHONY: lint lint: - flake8 --max-complexity 10 --max-line-length 120 --ignore E203,W503 devtools tests docs/plugins.py - $(isort) --check-only --df - $(black) --check --diff + black $(sources) --check --diff + ruff $(sources) mypy devtools .PHONY: test diff --git a/README.md b/README.md index 6a78b47..55e69e0 100644 --- a/README.md +++ b/README.md @@ -64,5 +64,5 @@ outputs: devtools can be used without `from devtools import debug` if you add `debug` into `__builtins__` in `sitecustomize.py`. -For instructions on adding `debug` to `__builtins__`, +For instructions on adding `debug` to `__builtins__`, see the [installation docs](https://python-devtools.helpmanual.io/usage/#usage-without-import). diff --git a/devtools/debug.py b/devtools/debug.py index 657859c..5ea836a 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -178,7 +178,7 @@ def _process(self, args: 'Any', kwargs: 'Any') -> DebugOutput: ex = source.executing(call_frame) function = ex.code_qualname() if not ex.node: - warning = "executing failed to find the calling node" + warning = 'executing failed to find the calling node' arguments = list(self._args_inspection_failed(args, kwargs)) else: arguments = list(self._process_args(ex, args, kwargs)) diff --git a/devtools/prettier.py b/devtools/prettier.py index fe01128..7489d33 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -13,9 +13,9 @@ cache = lru_cache() try: - from sqlalchemy import inspect as sa_inspect # type: ignore + from sqlalchemy import inspect as sa_inspect except ImportError: - sa_inspect = None + sa_inspect = None # type: ignore[assignment] __all__ = 'PrettyFormat', 'pformat', 'pprint' MYPY = False @@ -251,7 +251,7 @@ def _format_sqlalchemy_class(self, value: 'Any', _: str, indent_current: int, in deferred = set() fields = [ - (field, getattr(value, field) if field not in deferred else "") + (field, getattr(value, field) if field not in deferred else '') for field in dir(value) if not (field.startswith('_') or field in ['metadata', 'registry']) ] diff --git a/devtools/utils.py b/devtools/utils.py index c0ac1a3..2a96765 100644 --- a/devtools/utils.py +++ b/devtools/utils.py @@ -93,7 +93,7 @@ def _set_conout_mode(new_mode, mask=0xFFFFFFFF): mode = mask = ENABLE_VIRTUAL_TERMINAL_PROCESSING try: _set_conout_mode(mode, mask) - except WindowsError as e: # type: ignore + except OSError as e: if e.winerror == ERROR_INVALID_PARAMETER: return False raise @@ -150,7 +150,7 @@ class DataClassType(metaclass=MetaDataClassType): class MetaSQLAlchemyClassType(type): def __instancecheck__(self, instance: 'Any') -> bool: try: - from sqlalchemy.orm import DeclarativeBase # type: ignore + from sqlalchemy.orm import DeclarativeBase except ImportError: pass else: @@ -158,7 +158,7 @@ def __instancecheck__(self, instance: 'Any') -> bool: return True try: - from sqlalchemy.ext.declarative import DeclarativeMeta # type: ignore + from sqlalchemy.ext.declarative import DeclarativeMeta except ImportError: pass else: diff --git a/docs/plugins.py b/docs/plugins.py index aa46876..5f40bf1 100755 --- a/docs/plugins.py +++ b/docs/plugins.py @@ -55,7 +55,7 @@ def gen_examples_html(m: re.Match) -> str: conv = Ansi2HTMLConverter() name = THIS_DIR / Path(m.group(1)) - logger.info("running %s to generate HTML...", name) + 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/', '') diff --git a/docs/usage.md b/docs/usage.md index 68bb6cb..0388a2b 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -96,7 +96,7 @@ Two ways to do this: ### Automatic install !!! warning - This is experimental, please [create an issue](https://github.com/samuelcolvin/python-devtools/issues) + 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: diff --git a/pyproject.toml b/pyproject.toml index 87bff20..193aabc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -71,18 +71,21 @@ exclude_lines = [ [tool.black] color = true line-length = 120 -target-version = ['py37', 'py38', 'py39', 'py310'] +target-version = ['py37', 'py38', 'py39', 'py310', 'py311'] skip-string-normalization = true +extend-exclude = ['tests/test_expr_render.py'] -[tool.isort] -line_length = 120 -multi_line_output = 3 -include_trailing_comma = true -force_grid_wrap = 0 -combine_as_imports = true -color_output = true +[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 diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 357dec2..0000000 --- a/requirements.txt +++ /dev/null @@ -1,3 +0,0 @@ --r docs/requirements.txt --r tests/requirements-linting.txt --r tests/requirements.txt diff --git a/requirements/all.txt b/requirements/all.txt new file mode 100644 index 0000000..3e6af75 --- /dev/null +++ b/requirements/all.txt @@ -0,0 +1,4 @@ +-r ./docs.txt +-r ./linting.txt +-r ./testing.txt +-r ./pyproject.txt diff --git a/requirements/docs.in b/requirements/docs.in new file mode 100644 index 0000000..f4c60e6 --- /dev/null +++ b/requirements/docs.in @@ -0,0 +1,7 @@ +ansi2html==1.8.0 +mkdocs==1.3.1 +mkdocs-exclude==1.0.2 +mkdocs-material==8.3.9 +mkdocs-simple-hooks==0.1.5 +markdown-include==0.7.0 +pygments diff --git a/requirements/docs.txt b/requirements/docs.txt new file mode 100644 index 0000000..d39c76c --- /dev/null +++ b/requirements/docs.txt @@ -0,0 +1,67 @@ +# +# 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 +click==8.1.3 + # via mkdocs +ghp-import==2.1.0 + # via mkdocs +importlib-metadata==6.1.0 + # via mkdocs +jinja2==3.1.2 + # via + # mkdocs + # mkdocs-material +markdown==3.3.7 + # via + # markdown-include + # mkdocs + # mkdocs-material + # pymdown-extensions +markdown-include==0.7.0 + # via -r requirements/docs.in +markupsafe==2.1.2 + # via jinja2 +mergedeep==1.3.4 + # via mkdocs +mkdocs==1.3.1 + # via + # -r requirements/docs.in + # mkdocs-exclude + # mkdocs-material + # mkdocs-simple-hooks +mkdocs-exclude==1.0.2 + # via -r requirements/docs.in +mkdocs-material==8.3.9 + # 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 +packaging==23.0 + # via mkdocs +pygments==2.14.0 + # via + # -r requirements/docs.in + # mkdocs-material +pymdown-extensions==9.10 + # 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 +six==1.16.0 + # via python-dateutil +watchdog==3.0.0 + # via mkdocs +zipp==3.15.0 + # via importlib-metadata diff --git a/requirements/linting.in b/requirements/linting.in new file mode 100644 index 0000000..44df076 --- /dev/null +++ b/requirements/linting.in @@ -0,0 +1,5 @@ +black +mypy==0.971 +ruff +# required so mypy can find stubs +sqlalchemy diff --git a/requirements/linting.txt b/requirements/linting.txt new file mode 100644 index 0000000..92279cd --- /dev/null +++ b/requirements/linting.txt @@ -0,0 +1,34 @@ +# +# 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 +# +black==23.3.0 + # via -r requirements/linting.in +click==8.1.3 + # via black +mypy==0.971 + # via -r requirements/linting.in +mypy-extensions==1.0.0 + # via + # black + # mypy +packaging==23.0 + # via black +pathspec==0.11.1 + # via black +platformdirs==3.2.0 + # via black +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 +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..7b1ad14 --- /dev/null +++ b/requirements/testing.in @@ -0,0 +1,11 @@ +coverage[toml] +pygments +pytest +pytest-mock +pytest-pretty +# these packages are used in tests so install the latest version +pydantic +asyncpg +numpy; python_version>='3.8' +multidict; python_version>='3.8' +sqlalchemy diff --git a/requirements/testing.txt b/requirements/testing.txt new file mode 100644 index 0000000..b62eaba --- /dev/null +++ b/requirements/testing.txt @@ -0,0 +1,55 @@ +# +# 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 + # via -r requirements/testing.in +attrs==22.2.0 + # via pytest +coverage[toml]==7.2.2 + # via -r requirements/testing.in +exceptiongroup==1.1.1 + # 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 +numpy==1.24.2 ; python_version >= "3.8" + # via -r requirements/testing.in +packaging==23.0 + # via pytest +pluggy==1.0.0 + # via pytest +pydantic==1.10.7 + # via -r requirements/testing.in +pygments==2.14.0 + # via + # -r requirements/testing.in + # 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.1.1 + # 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 + # coverage + # pytest +typing-extensions==4.5.0 + # via + # pydantic + # sqlalchemy diff --git a/tests/requirements-linting.txt b/tests/requirements-linting.txt deleted file mode 100644 index b84d0b2..0000000 --- a/tests/requirements-linting.txt +++ /dev/null @@ -1,6 +0,0 @@ -black==22.6.0 -flake8==4.0.1 -isort[colors]==5.10.1 -mypy==0.971 -pycodestyle==2.8.0 -pyflakes==2.4.0 diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index cd9c6b2..0000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -coverage[toml]==6.5.0 -Pygments==2.13.0 -pytest==7.2.0 -pytest-mock==3.10.0 -pytest-pretty==0.0.1 -# these packages are used in tests so install the latest version -pydantic -asyncpg -numpy -multidict -sqlalchemy diff --git a/tests/test_custom_pretty.py b/tests/test_custom_pretty.py index d63392b..552e5d3 100644 --- a/tests/test_custom_pretty.py +++ b/tests/test_custom_pretty.py @@ -23,12 +23,15 @@ def __pretty__(self, fmt, **kwargs): my_cls = CustomCls() v = pformat(my_cls) - assert v == """\ + assert ( + v + == """\ Thing( [], [0], [0, 1], )""" + ) def test_skip(): diff --git a/tests/test_expr_render.py b/tests/test_expr_render.py index 6b30bad..eed9469 100644 --- a/tests/test_expr_render.py +++ b/tests/test_expr_render.py @@ -48,14 +48,14 @@ def test_exotic_types(): (a for a in aa), ) s = normalise_output(str(v)) - print('\n---\n{}\n---'.format(v)) + print(f'\n---\n{v}\n---') # Generator expression source changed in 3.8 to include parentheses, see: # https://github.com/gristlabs/asttokens/pull/50 # https://bugs.python.org/issue31241 - genexpr_source = "a for a in aa" + genexpr_source = 'a for a in aa' if sys.version_info[:2] > (3, 7): - genexpr_source = f"({genexpr_source})" + genexpr_source = f'({genexpr_source})' assert ( "tests/test_expr_render.py: test_exotic_types\n" diff --git a/tests/test_main.py b/tests/test_main.py index f0cce7d..1057313 100644 --- a/tests/test_main.py +++ b/tests/test_main.py @@ -2,7 +2,7 @@ import sys from collections.abc import Generator from pathlib import Path -from subprocess import PIPE, run +from subprocess import run import pytest @@ -19,9 +19,7 @@ def test_print(capsys): stdout, stderr = capsys.readouterr() print(stdout) assert normalise_output(stdout) == ( - 'tests/test_main.py: test_print\n' - ' a: 1 (int)\n' - ' b: 2 (int)\n' + 'tests/test_main.py: test_print\n' ' a: 1 (int)\n' ' b: 2 (int)\n' ) assert stderr == '' assert result == (1, 2) @@ -64,7 +62,7 @@ def test_print_generator(capsys): def test_format(): a = b'i might bite' - b = "hello this is a test" + b = 'hello this is a test' v = debug.format(a, b) s = normalise_output(str(v)) print(s) @@ -81,7 +79,8 @@ def test_format(): ) def test_print_subprocess(tmpdir): f = tmpdir.join('test.py') - f.write("""\ + f.write( + """\ from devtools import debug def test_func(v): @@ -92,9 +91,10 @@ def test_func(v): debug(foobar) test_func(42) print('debug run.') - """) + """ + ) env = {'PYTHONPATH': str(Path(__file__).parent.parent.resolve())} - p = run([sys.executable, str(f)], stdout=PIPE, stderr=PIPE, universal_newlines=True, env=env) + p = run([sys.executable, str(f)], capture_output=True, text=True, env=env) assert p.stderr == '' assert p.returncode == 0, (p.stderr, p.stdout) assert p.stdout.replace(str(f), '/path/to/test.py') == ( @@ -113,10 +113,10 @@ def test_odd_path(mocker): mocked_relative_to = mocker.patch('pathlib.Path.relative_to') mocked_relative_to.side_effect = ValueError() v = debug.format('test') - if sys.platform == "win32": - pattern = r"\w:\\.*?\\" + if sys.platform == 'win32': + pattern = r'\w:\\.*?\\' else: - pattern = r"/.*?/" + pattern = r'/.*?/' pattern += r"test_main.py:\d{2,} test_odd_path\n 'test' \(str\) len=4" assert re.search(pattern, str(v)), v @@ -129,10 +129,7 @@ def test_small_call_frame(): 3, ) assert normalise_output(str(v)) == ( - 'tests/test_main.py: test_small_call_frame\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + 'tests/test_main.py: test_small_call_frame\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' ) @@ -143,12 +140,9 @@ def test_small_call_frame_warning(): 2, 3, ) - print('\n---\n{}\n---'.format(v)) + print(f'\n---\n{v}\n---') assert normalise_output(str(v)) == ( - 'tests/test_main.py: test_small_call_frame_warning\n' - ' 1 (int)\n' - ' 2 (int)\n' - ' 3 (int)' + 'tests/test_main.py: test_small_call_frame_warning\n' ' 1 (int)\n' ' 2 (int)\n' ' 3 (int)' ) @@ -171,7 +165,7 @@ def test_kwargs_orderless(): v = debug.format(first=a, second='literal') s = normalise_output(str(v)) assert set(s.split('\n')) == { - "tests/test_main.py: test_kwargs_orderless", + 'tests/test_main.py: test_kwargs_orderless', " first: 'variable' (str) len=8 variable=a", " second: 'literal' (str) len=7", } @@ -181,10 +175,7 @@ def test_simple_vars(): v = debug.format('test', 1, 2) s = normalise_output(str(v)) assert s == ( - "tests/test_main.py: test_simple_vars\n" - " 'test' (str) len=4\n" - " 1 (int)\n" - " 2 (int)" + "tests/test_main.py: test_simple_vars\n" " 'test' (str) len=4\n" " 1 (int)\n" " 2 (int)" ) r = normalise_output(repr(v)) assert r == ( @@ -222,18 +213,14 @@ 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 == ( @@ -314,8 +301,7 @@ def test_multiple_debugs(): v = debug.format([i * 2 for i in range(2)]) s = normalise_output(str(v)) assert s == ( - 'tests/test_main.py: test_multiple_debugs\n' - ' [i * 2 for i in range(2)]: [0, 2] (list) len=2' + 'tests/test_main.py: test_multiple_debugs\n' ' [i * 2 for i in range(2)]: [0, 2] (list) len=2' ) diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 657e1b7..364447b 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -28,6 +28,7 @@ try: from sqlalchemy import Column, Integer, String + try: from sqlalchemy.orm import declarative_base except ImportError: @@ -41,21 +42,13 @@ def test_dict(): v = pformat({1: 2, 3: 4}) print(v) - assert v == ( - '{\n' - ' 1: 2,\n' - ' 3: 4,\n' - '}') + assert v == ('{\n' ' 1: 2,\n' ' 3: 4,\n' '}') def test_print(capsys): pprint({1: 2, 3: 4}) stdout, stderr = capsys.readouterr() - assert strip_ansi(stdout) == ( - '{\n' - ' 1: 2,\n' - ' 3: 4,\n' - '}\n') + assert strip_ansi(stdout) == ('{\n' ' 1: 2,\n' ' 3: 4,\n' '}\n') assert stderr == '' @@ -68,64 +61,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 ') @@ -157,7 +119,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' @@ -165,6 +129,7 @@ def test_bytes(): b'uvwxy' b'z' )""" + ) def test_short_bytes(): @@ -174,40 +139,52 @@ def test_short_bytes(): def test_bytearray(): pformat_ = PrettyFormat(width=18) v = pformat_(bytearray(string.ascii_lowercase.encode())) - assert v == """\ + assert ( + v + == """\ bytearray( b'abcdefghijk' b'lmnopqrstuv' b'wxyz' )""" + ) def test_bytearray_short(): v = pformat(bytearray(b'boo')) - assert v == """\ + assert ( + v + == """\ bytearray( b'boo' )""" + ) def test_map(): v = pformat(map(str.strip, ['x', 'y ', ' z'])) - assert v == """\ + assert ( + v + == """\ map( 'x', 'y', 'z', )""" + ) def test_filter(): v = pformat(filter(None, [1, 2, False, 3])) - assert v == """\ + assert ( + v + == """\ filter( 1, 2, 3, )""" + ) def test_counter(): @@ -216,11 +193,14 @@ def test_counter(): c['x'] += 1 c['y'] += 1 v = pformat(c) - assert v == """\ + assert ( + v + == """\ """ + ) def test_dataclass(): @@ -232,7 +212,9 @@ class FooDataclass: f = FooDataclass(123, [1, 2, 3, 4]) v = pformat(f) print(v) - assert v == """\ + assert ( + v + == """\ FooDataclass( x=123, y=[ @@ -242,6 +224,7 @@ class FooDataclass: 4, ], )""" + ) def test_nested_dataclasses(): @@ -258,68 +241,78 @@ class BarDataclass: b = BarDataclass(10.0, f) v = pformat(b) print(v) - assert v == """\ + assert ( + v + == """\ BarDataclass( a=10.0, b=FooDataclass( x=123, ), )""" + ) @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( @@ -344,6 +337,7 @@ def test_deep_objects(): ), {1, 2, 3}, )""" + ) def test_call_args(): @@ -351,11 +345,14 @@ def test_call_args(): m(1, 2, 3, a=4) v = pformat(m.call_args) - assert v == """\ + assert ( + v + == """\ _Call( _fields=(1, 2, 3), {'a': 4}, )""" + ) @pytest.mark.skipif(MultiDict is None, reason='MultiDict not installed') @@ -364,11 +361,11 @@ def test_multidict(): d.add('b', 3) v = pformat(d) assert set(v.split('\n')) == { - "", + '})>', } @@ -376,10 +373,10 @@ def test_multidict(): def test_cimultidict(): v = pformat(CIMultiDict({'a': 1, 'b': 2})) assert set(v.split('\n')) == { - "", + '})>', } @@ -399,21 +396,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(): @@ -434,25 +421,14 @@ def items(self): def __getitem__(self, item): return self._d[item] - assert pformat(Dictlike()) == ( - "" - ) + assert pformat(Dictlike()) == ("") @pytest.mark.skipif(Record is None, reason='asyncpg not installed') def test_asyncpg_record(): r = Record({'a': 0, 'b': 1}, (41, 42)) assert dict(r) == {'a': 41, 'b': 42} - assert pformat(r) == ( - "" - ) + assert pformat(r) == ("") def test_dict_type(): @@ -467,11 +443,12 @@ class User(SQLAlchemyBase): name = Column(String) fullname = Column(String) nickname = Column(String) + user = User() user.id = 1 - user.name = "Test" - user.fullname = "Test For SQLAlchemy" - user.nickname = "test" + user.name = 'Test' + user.fullname = 'Test For SQLAlchemy' + user.nickname = 'test' assert pformat(user) == ( "User(\n" " fullname='Test For SQLAlchemy',\n" From 3d5ff6597fe3fc176dfc83062a9cf4e1203cb83a Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 12:45:32 +0100 Subject: [PATCH 35/53] update licence and history --- HISTORY.md | 9 +++++++++ LICENSE | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/HISTORY.md b/HISTORY.md index 2ebbb56..1aa231e 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,12 @@ +## 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 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 From f0e0fb2b139e1980c87363a754b6a42bb1a9fbc4 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 13:48:42 +0100 Subject: [PATCH 36/53] support displaying ast types (#125) * support displaying ast types * support 3.7 & 3.8 --- devtools/prettier.py | 13 +++++++++++++ requirements/testing.in | 4 +++- requirements/testing.txt | 2 +- tests/test_prettier.py | 25 +++++++++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/devtools/prettier.py b/devtools/prettier.py index 7489d33..e378030 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -1,3 +1,4 @@ +import ast import io import os from collections import OrderedDict @@ -80,6 +81,7 @@ def __init__( (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), @@ -240,6 +242,17 @@ def _format_bytearray(self, value: 'Any', _: str, indent_current: int, indent_ne 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: self._format_fields(value, value.__dict__.items(), indent_current, indent_new) diff --git a/requirements/testing.in b/requirements/testing.in index 7b1ad14..1976d79 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -5,7 +5,9 @@ pytest-mock pytest-pretty # these packages are used in tests so install the latest version pydantic -asyncpg +# no binaries for 3.7 +asyncpg; python_version>='3.8' +# no version is compatible with 3.7 and 3.11 numpy; python_version>='3.8' multidict; python_version>='3.8' sqlalchemy diff --git a/requirements/testing.txt b/requirements/testing.txt index b62eaba..2ce58bf 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -4,7 +4,7 @@ # # pip-compile --output-file=requirements/testing.txt --resolver=backtracking requirements/testing.in # -asyncpg==0.27.0 +asyncpg==0.27.0 ; python_version >= "3.8" # via -r requirements/testing.in attrs==22.2.0 # via pytest diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 364447b..6a0247c 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -1,5 +1,7 @@ +import ast import os import string +import sys from collections import Counter, OrderedDict, namedtuple from dataclasses import dataclass from typing import List @@ -457,3 +459,26 @@ class User(SQLAlchemyBase): " nickname='test',\n" ")" ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='no indent on older versions') +def test_ast_expr(): + assert pformat(ast.parse('print(1, 2, round(3))', mode='eval')) == ( + "Expression(" + "\n body=Call(" + "\n func=Name(id='print', ctx=Load())," + "\n args=[" + "\n Constant(value=1)," + "\n Constant(value=2)," + "\n Call(" + "\n func=Name(id='round', ctx=Load())," + "\n args=[" + "\n Constant(value=3)]," + "\n keywords=[])]," + "\n keywords=[]))" + ) + + +@pytest.mark.skipif(sys.version_info < (3, 9), reason='no indent on older versions') +def test_ast_module(): + assert pformat(ast.parse('print(1, 2, round(3))')).startswith('Module(\n body=[') From 61c6b67472f7a0e818968ab73dcd56762f91d419 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 18:59:07 +0100 Subject: [PATCH 37/53] Insert assert (#126) * support displaying ast types * support 3.7 & 3.8 * skip tests on older python * add insert_assert pytest fixture * use newest pytest-pretty * try to fix CI * fix mypy and black * add pytest to for mypy * fix mypy * change code to install debug in fixture * tweak install instructions --- .github/workflows/ci.yml | 5 +- devtools/__main__.py | 27 ++-- devtools/prettier.py | 6 +- devtools/pytest_plugin.py | 301 ++++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +- requirements/linting.in | 1 + requirements/linting.txt | 15 +- requirements/testing.in | 5 +- requirements/testing.txt | 17 +- tests/conftest.py | 2 + tests/test_insert_assert.py | 169 ++++++++++++++++++++ 11 files changed, 531 insertions(+), 22 deletions(-) create mode 100644 devtools/pytest_plugin.py create mode 100644 tests/test_insert_assert.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ad2c060..e7b81b9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,6 +8,9 @@ on: - '**' pull_request: {} +env: + COLUMNS: 150 + jobs: lint: runs-on: ubuntu-latest @@ -51,7 +54,7 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install -r requirements/testing.txt -r requirements/pyproject.txt - - run: pip install . + - run: pip freeze - name: test with extras diff --git a/devtools/__main__.py b/devtools/__main__.py index 726f825..bfc6155 100644 --- a/devtools/__main__.py +++ b/devtools/__main__.py @@ -1,5 +1,4 @@ import builtins -import os import sys from pathlib import Path @@ -8,13 +7,17 @@ # language=python install_code = """ # add devtools `debug` function to builtins -import builtins -try: - from devtools import debug -except ImportError: - pass -else: - setattr(builtins, 'debug', debug) +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) """ @@ -47,11 +50,11 @@ def install() -> int: print(f'Found path "{install_path}" to install devtools into __builtins__') print('To install devtools, run the following command:\n') - if os.access(install_path, os.W_OK): - print(f' python -m devtools print-code >> {install_path}\n') - else: + print(f' python -m devtools print-code >> {install_path}\n') + if not install_path.is_relative_to(Path.home()): + print('or maybe\n') print(f' python -m devtools print-code | sudo tee -a {install_path} > /dev/null\n') - print('Note: "sudo" is required because the path is not writable by the current user.') + print('Note: "sudo" might be required because the path is in your home directory.') return 0 diff --git a/devtools/prettier.py b/devtools/prettier.py index e378030..4f274de 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -44,9 +44,9 @@ class SkipPretty(Exception): @cache def get_pygments() -> 'Tuple[Any, Any, Any]': try: - import pygments # type: ignore - from pygments.formatters import Terminal256Formatter # type: ignore - from pygments.lexers import PythonLexer # type: ignore + import pygments + from pygments.formatters import Terminal256Formatter + from pygments.lexers import PythonLexer except ImportError: # pragma: no cover return None, None, None else: 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/pyproject.toml b/pyproject.toml index 193aabc..7e7c87f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,6 +48,9 @@ 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' @@ -90,5 +93,5 @@ strict = true warn_return_any = false [[tool.mypy.overrides]] -module = ['executing.*'] +module = ['executing.*', 'pygments.*'] ignore_missing_imports = true diff --git a/requirements/linting.in b/requirements/linting.in index 44df076..e284107 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -3,3 +3,4 @@ mypy==0.971 ruff # required so mypy can find stubs sqlalchemy +pytest diff --git a/requirements/linting.txt b/requirements/linting.txt index 92279cd..21c1a03 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -4,10 +4,16 @@ # # 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==0.971 # via -r requirements/linting.in mypy-extensions==1.0.0 @@ -15,11 +21,17 @@ mypy-extensions==1.0.0 # black # mypy packaging==23.0 - # via black + # 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 @@ -28,6 +40,7 @@ tomli==2.0.1 # via # black # mypy + # pytest typing-extensions==4.5.0 # via # mypy diff --git a/requirements/testing.in b/requirements/testing.in index 1976d79..0543021 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -4,10 +4,11 @@ pytest pytest-mock pytest-pretty # these packages are used in tests so install the latest version -pydantic # 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' -multidict; python_version>='3.8' +pydantic sqlalchemy diff --git a/requirements/testing.txt b/requirements/testing.txt index 2ce58bf..9fb1f82 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -8,6 +8,10 @@ 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.1 @@ -20,10 +24,18 @@ 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 pytest + # 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 @@ -39,7 +51,7 @@ pytest==7.2.2 # pytest-pretty pytest-mock==3.10.0 # via -r requirements/testing.in -pytest-pretty==1.1.1 +pytest-pretty==1.2.0 # via -r requirements/testing.in rich==13.3.3 # via pytest-pretty @@ -47,6 +59,7 @@ sqlalchemy==2.0.8 # via -r requirements/testing.in tomli==2.0.1 # via + # black # coverage # pytest typing-extensions==4.5.0 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/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 From f416c9b8df4f087b2302cd0fe3f878ffecd40661 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 19:00:13 +0100 Subject: [PATCH 38/53] uprev --- devtools/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/devtools/version.py b/devtools/version.py index ea301cb..d3c01ed 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.10.0' +VERSION = '0.11.0' From 71edb0d6957895615ac258a2080e9424e99b0e99 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Wed, 5 Apr 2023 19:04:44 +0100 Subject: [PATCH 39/53] uprev mypy --- requirements/linting.in | 2 +- requirements/linting.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements/linting.in b/requirements/linting.in index e284107..41aa444 100644 --- a/requirements/linting.in +++ b/requirements/linting.in @@ -1,5 +1,5 @@ black -mypy==0.971 +mypy ruff # required so mypy can find stubs sqlalchemy diff --git a/requirements/linting.txt b/requirements/linting.txt index 21c1a03..bd6c1fc 100644 --- a/requirements/linting.txt +++ b/requirements/linting.txt @@ -14,7 +14,7 @@ exceptiongroup==1.1.1 # via pytest iniconfig==2.0.0 # via pytest -mypy==0.971 +mypy==1.1.1 # via -r requirements/linting.in mypy-extensions==1.0.0 # via From b6a98196677637323dc89bfb28cb1dc4e602ef44 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 6 Apr 2023 00:45:13 +0100 Subject: [PATCH 40/53] build docs on CI (#127) * build docs on ci * fix docs build * add numpy --- .github/workflows/ci.yml | 41 ++++++++++++++++++++++++++++++++--- Makefile | 2 +- devtools/__init__.py | 11 +++++----- docs/examples/ansi_colours.py | 2 +- docs/examples/complex.py | 3 ++- docs/examples/other.py | 2 +- docs/examples/prettier.py | 3 ++- requirements/docs.in | 14 +++++++----- requirements/docs.txt | 28 ++++++++++++++++++------ 9 files changed, 80 insertions(+), 26 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e7b81b9..bcfe8bf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,26 @@ jobs: 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: @@ -85,7 +105,7 @@ jobs: # https://github.com/marketplace/actions/alls-green#why used for branch protection checks check: if: always() - needs: [test, lint] + needs: [test, lint, docs-build] runs-on: ubuntu-latest steps: - name: Decide whether the needed jobs succeeded or failed @@ -107,6 +127,17 @@ jobs: with: python-version: '3.10' + - name: get docs + uses: actions/download-artifact@v3 + with: + name: docs + path: site + + - name: check GITHUB_REF matches package version + uses: samuelcolvin/check-python-version@v3 + with: + version_file_path: devtools/version.py + - name: install run: pip install build twine @@ -122,6 +153,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/Makefile b/Makefile index 3a464eb..eecc748 100644 --- a/Makefile +++ b/Makefile @@ -63,7 +63,7 @@ clean: .PHONY: docs docs: - flake8 --max-line-length=80 docs/examples/ + ruff --line-length=80 docs/examples/ mkdocs build .PHONY: docs-serve diff --git a/devtools/__init__.py b/devtools/__init__.py index d6a6564..b607d19 100644 --- a/devtools/__init__.py +++ b/devtools/__init__.py @@ -1,8 +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/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/requirements/docs.in b/requirements/docs.in index f4c60e6..be48235 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -1,7 +1,9 @@ -ansi2html==1.8.0 -mkdocs==1.3.1 -mkdocs-exclude==1.0.2 -mkdocs-material==8.3.9 -mkdocs-simple-hooks==0.1.5 -markdown-include==0.7.0 +ansi2html +mkdocs +mkdocs-exclude +mkdocs-material +mkdocs-simple-hooks +markdown-include pygments +ruff +numpy diff --git a/requirements/docs.txt b/requirements/docs.txt index d39c76c..2d242be 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -6,12 +6,18 @@ # ansi2html==1.8.0 # via -r requirements/docs.in +certifi==2022.12.7 + # 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 -importlib-metadata==6.1.0 - # via mkdocs +idna==3.4 + # via requests jinja2==3.1.2 # via # mkdocs @@ -22,13 +28,13 @@ markdown==3.3.7 # mkdocs # mkdocs-material # pymdown-extensions -markdown-include==0.7.0 +markdown-include==0.8.1 # via -r requirements/docs.in markupsafe==2.1.2 # via jinja2 mergedeep==1.3.4 # via mkdocs -mkdocs==1.3.1 +mkdocs==1.4.2 # via # -r requirements/docs.in # mkdocs-exclude @@ -36,12 +42,14 @@ mkdocs==1.3.1 # mkdocs-simple-hooks mkdocs-exclude==1.0.2 # via -r requirements/docs.in -mkdocs-material==8.3.9 +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.14.0 @@ -59,9 +67,15 @@ pyyaml==6.0 # pyyaml-env-tag pyyaml-env-tag==0.1 # via mkdocs +regex==2023.3.23 + # via mkdocs-material +requests==2.28.2 + # 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 -zipp==3.15.0 - # via importlib-metadata From 88e020616448f41d1a379aa3e4429047c4e10f84 Mon Sep 17 00:00:00 2001 From: Tom Hamilton Stubber Date: Thu, 27 Apr 2023 18:06:00 +0100 Subject: [PATCH 41/53] Update usage to reflect the recent addition of the pytest plugin (#128) --- docs/usage.md | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/docs/usage.md b/docs/usage.md index 0388a2b..3efce3e 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -113,14 +113,17 @@ To manually add `debug` to `__builtins__`, add the following to `sitecustomize.p which is always imported. ```py -# add devtools `debug` function to builtins -import builtins -try: - from devtools import debug -except ImportError: - pass -else: - setattr(builtins, 'debug', debug) +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. From 1172e81db7a05742e6f9a3db1ccec16045565b2c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 23 May 2023 16:06:17 +0100 Subject: [PATCH 42/53] Bump requests from 2.28.2 to 2.31.0 in /requirements (#130) Bumps [requests](https://github.com/psf/requests) from 2.28.2 to 2.31.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.28.2...v2.31.0) --- updated-dependencies: - dependency-name: requests dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 2d242be..c7e43ac 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -5,7 +5,7 @@ # pip-compile --output-file=requirements/docs.txt --resolver=backtracking requirements/docs.in # ansi2html==1.8.0 - # via -r requirements/docs.in + # via -r docs.in certifi==2022.12.7 # via requests charset-normalizer==3.1.0 @@ -29,32 +29,32 @@ markdown==3.3.7 # mkdocs-material # pymdown-extensions markdown-include==0.8.1 - # via -r requirements/docs.in + # via -r docs.in markupsafe==2.1.2 # via jinja2 mergedeep==1.3.4 # via mkdocs mkdocs==1.4.2 # via - # -r requirements/docs.in + # -r docs.in # mkdocs-exclude # mkdocs-material # mkdocs-simple-hooks mkdocs-exclude==1.0.2 - # via -r requirements/docs.in + # via -r docs.in mkdocs-material==9.1.5 - # via -r requirements/docs.in + # via -r docs.in mkdocs-material-extensions==1.1.1 # via mkdocs-material mkdocs-simple-hooks==0.1.5 - # via -r requirements/docs.in + # via -r docs.in numpy==1.24.2 - # via -r requirements/docs.in + # via -r docs.in packaging==23.0 # via mkdocs pygments==2.14.0 # via - # -r requirements/docs.in + # -r docs.in # mkdocs-material pymdown-extensions==9.10 # via mkdocs-material @@ -69,10 +69,10 @@ pyyaml-env-tag==0.1 # via mkdocs regex==2023.3.23 # via mkdocs-material -requests==2.28.2 +requests==2.31.0 # via mkdocs-material ruff==0.0.261 - # via -r requirements/docs.in + # via -r docs.in six==1.16.0 # via python-dateutil urllib3==1.26.15 From 2341ef37359b7d42a2c307207c50253343539d13 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:00:48 +0100 Subject: [PATCH 43/53] Bump pygments from 2.13.0 to 2.15.0 in /docs (#133) Bumps [pygments](https://github.com/pygments/pygments) from 2.13.0 to 2.15.0. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.13.0...2.15.0) --- updated-dependencies: - dependency-name: pygments dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/requirements.txt b/docs/requirements.txt index 3a7c1d2..d091120 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -4,4 +4,4 @@ mkdocs-exclude==1.0.2 mkdocs-material==8.3.9 mkdocs-simple-hooks==0.1.5 markdown-include==0.7.0 -pygments==2.13.0 +pygments==2.15.0 From 967121ff0b847756ef813c3ba80b4ab6d5ecde1c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:01:07 +0100 Subject: [PATCH 44/53] Bump pygments from 2.14.0 to 2.15.0 in /requirements (#134) Bumps [pygments](https://github.com/pygments/pygments) from 2.14.0 to 2.15.0. - [Release notes](https://github.com/pygments/pygments/releases) - [Changelog](https://github.com/pygments/pygments/blob/master/CHANGES) - [Commits](https://github.com/pygments/pygments/compare/2.14.0...2.15.0) --- updated-dependencies: - dependency-name: pygments dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- requirements/testing.txt | 33 ++++++++++++++------------------- 2 files changed, 15 insertions(+), 20 deletions(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index c7e43ac..dc0af30 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -52,7 +52,7 @@ numpy==1.24.2 # via -r docs.in packaging==23.0 # via mkdocs -pygments==2.14.0 +pygments==2.15.0 # via # -r docs.in # mkdocs-material diff --git a/requirements/testing.txt b/requirements/testing.txt index 9fb1f82..ace238f 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -5,17 +5,17 @@ # 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 + # via -r testing.in attrs==22.2.0 # via pytest black==23.3.0 - # via -r requirements/testing.in + # via -r testing.in click==8.1.3 # via black coverage[toml]==7.2.2 - # via -r requirements/testing.in -exceptiongroup==1.1.1 - # via pytest + # via -r testing.in +greenlet==2.0.2 + # via sqlalchemy iniconfig==2.0.0 # via pytest markdown-it-py==2.2.0 @@ -23,11 +23,11 @@ markdown-it-py==2.2.0 mdurl==0.1.2 # via markdown-it-py multidict==6.0.4 ; python_version >= "3.8" - # via -r requirements/testing.in + # via -r testing.in mypy-extensions==1.0.0 # via black numpy==1.24.2 ; python_version >= "3.8" - # via -r requirements/testing.in + # via -r testing.in packaging==23.0 # via # black @@ -39,29 +39,24 @@ platformdirs==3.2.0 pluggy==1.0.0 # via pytest pydantic==1.10.7 - # via -r requirements/testing.in -pygments==2.14.0 + # via -r testing.in +pygments==2.15.0 # via - # -r requirements/testing.in + # -r testing.in # rich pytest==7.2.2 # via - # -r requirements/testing.in + # -r testing.in # pytest-mock # pytest-pretty pytest-mock==3.10.0 - # via -r requirements/testing.in + # via -r testing.in pytest-pretty==1.2.0 - # via -r requirements/testing.in + # via -r 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 + # via -r testing.in typing-extensions==4.5.0 # via # pydantic From 3a6f5b991f4f1d5726f0308a92fc14554e2cef67 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:01:21 +0100 Subject: [PATCH 45/53] Bump pymdown-extensions from 9.10 to 10.0 in /requirements (#129) Bumps [pymdown-extensions](https://github.com/facelessuser/pymdown-extensions) from 9.10 to 10.0. - [Release notes](https://github.com/facelessuser/pymdown-extensions/releases) - [Commits](https://github.com/facelessuser/pymdown-extensions/compare/9.10...10.0) --- updated-dependencies: - dependency-name: pymdown-extensions dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index dc0af30..2782252 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -56,7 +56,7 @@ pygments==2.15.0 # via # -r docs.in # mkdocs-material -pymdown-extensions==9.10 +pymdown-extensions==10.0 # via mkdocs-material python-dateutil==2.8.2 # via ghp-import From c17c31021143f4d3feac0c47b897ae22b1aefb06 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 17 Aug 2023 12:01:29 +0100 Subject: [PATCH 46/53] Bump certifi from 2022.12.7 to 2023.7.22 in /requirements (#135) Bumps [certifi](https://github.com/certifi/python-certifi) from 2022.12.7 to 2023.7.22. - [Commits](https://github.com/certifi/python-certifi/compare/2022.12.07...2023.07.22) --- updated-dependencies: - dependency-name: certifi dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- requirements/docs.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/docs.txt b/requirements/docs.txt index 2782252..85ac479 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -6,7 +6,7 @@ # ansi2html==1.8.0 # via -r docs.in -certifi==2022.12.7 +certifi==2023.7.22 # via requests charset-normalizer==3.1.0 # via requests From 00047cb12c3b5245d9ad569e41b8b0d4c06bc8e5 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 17 Aug 2023 12:02:57 +0100 Subject: [PATCH 47/53] support dataclasses with slots (#136) --- devtools/prettier.py | 7 ++++++- tests/test_prettier.py | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/devtools/prettier.py b/devtools/prettier.py index 4f274de..c45bc6a 100644 --- a/devtools/prettier.py +++ b/devtools/prettier.py @@ -254,7 +254,12 @@ def _format_ast_expression(self, value: ast.AST, _: str, indent_current: int, in self._stream.write(indent_current * self._c + line) def _format_dataclass(self, value: 'Any', _: str, indent_current: int, indent_new: int) -> None: - self._format_fields(value, value.__dict__.items(), indent_current, indent_new) + 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: diff --git a/tests/test_prettier.py b/tests/test_prettier.py index 6a0247c..298dc58 100644 --- a/tests/test_prettier.py +++ b/tests/test_prettier.py @@ -255,6 +255,30 @@ class BarDataclass: ) +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))}) From 6eac2ca7e63114d4bb44f6e1366673d16b0eaef6 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 17 Aug 2023 12:18:53 +0100 Subject: [PATCH 48/53] Make `Pygments` required (#137) --- README.md | 5 +---- docs/install.md | 6 ++---- pyproject.toml | 4 +++- requirements/docs.in | 1 - requirements/docs.txt | 20 +++++++++----------- requirements/testing.in | 1 - requirements/testing.txt | 33 ++++++++++++++++++--------------- 7 files changed, 33 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 55e69e0..c660d28 100644 --- a/README.md +++ b/README.md @@ -15,12 +15,9 @@ 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.7, 3.8, 3.9, 3.10 or 3.11. If you've got python 3.7+ and `pip` installed, you're good to go. ## Usage diff --git a/docs/install.md b/docs/install.md index 94630bb..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.7, 3.8, 3.9, 3.10 or 3.11. +`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/pyproject.toml b/pyproject.toml index 7e7c87f..28a9ecf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -37,8 +37,10 @@ requires-python = '>=3.7' dependencies = [ 'executing>=1.1.1', 'asttokens>=2.0.0,<3.0.0', + 'Pygments>=2.15.0', ] -optional-dependencies = {pygments = ['Pygments>=2.2.0'] } +# keep this meaningless group around to avoid breaking installs using `pip install devtools[pygments]` +optional-dependencies = {pygments = [] } dynamic = ['version'] [project.urls] diff --git a/requirements/docs.in b/requirements/docs.in index be48235..caf159b 100644 --- a/requirements/docs.in +++ b/requirements/docs.in @@ -4,6 +4,5 @@ mkdocs-exclude mkdocs-material mkdocs-simple-hooks markdown-include -pygments ruff numpy diff --git a/requirements/docs.txt b/requirements/docs.txt index 85ac479..b2d5b9f 100644 --- a/requirements/docs.txt +++ b/requirements/docs.txt @@ -5,7 +5,7 @@ # pip-compile --output-file=requirements/docs.txt --resolver=backtracking requirements/docs.in # ansi2html==1.8.0 - # via -r docs.in + # via -r requirements/docs.in certifi==2023.7.22 # via requests charset-normalizer==3.1.0 @@ -29,33 +29,31 @@ markdown==3.3.7 # mkdocs-material # pymdown-extensions markdown-include==0.8.1 - # via -r docs.in + # via -r requirements/docs.in markupsafe==2.1.2 # via jinja2 mergedeep==1.3.4 # via mkdocs mkdocs==1.4.2 # via - # -r docs.in + # -r requirements/docs.in # mkdocs-exclude # mkdocs-material # mkdocs-simple-hooks mkdocs-exclude==1.0.2 - # via -r docs.in + # via -r requirements/docs.in mkdocs-material==9.1.5 - # via -r docs.in + # via -r requirements/docs.in mkdocs-material-extensions==1.1.1 # via mkdocs-material mkdocs-simple-hooks==0.1.5 - # via -r docs.in + # via -r requirements/docs.in numpy==1.24.2 - # via -r docs.in + # via -r requirements/docs.in packaging==23.0 # via mkdocs pygments==2.15.0 - # via - # -r docs.in - # mkdocs-material + # via mkdocs-material pymdown-extensions==10.0 # via mkdocs-material python-dateutil==2.8.2 @@ -72,7 +70,7 @@ regex==2023.3.23 requests==2.31.0 # via mkdocs-material ruff==0.0.261 - # via -r docs.in + # via -r requirements/docs.in six==1.16.0 # via python-dateutil urllib3==1.26.15 diff --git a/requirements/testing.in b/requirements/testing.in index 0543021..6a3232a 100644 --- a/requirements/testing.in +++ b/requirements/testing.in @@ -1,5 +1,4 @@ coverage[toml] -pygments pytest pytest-mock pytest-pretty diff --git a/requirements/testing.txt b/requirements/testing.txt index ace238f..a3b4099 100644 --- a/requirements/testing.txt +++ b/requirements/testing.txt @@ -5,17 +5,17 @@ # pip-compile --output-file=requirements/testing.txt --resolver=backtracking requirements/testing.in # asyncpg==0.27.0 ; python_version >= "3.8" - # via -r testing.in + # via -r requirements/testing.in attrs==22.2.0 # via pytest black==23.3.0 - # via -r testing.in + # via -r requirements/testing.in click==8.1.3 # via black coverage[toml]==7.2.2 - # via -r testing.in -greenlet==2.0.2 - # via sqlalchemy + # via -r requirements/testing.in +exceptiongroup==1.1.3 + # via pytest iniconfig==2.0.0 # via pytest markdown-it-py==2.2.0 @@ -23,11 +23,11 @@ markdown-it-py==2.2.0 mdurl==0.1.2 # via markdown-it-py multidict==6.0.4 ; python_version >= "3.8" - # via -r testing.in + # via -r requirements/testing.in mypy-extensions==1.0.0 # via black numpy==1.24.2 ; python_version >= "3.8" - # via -r testing.in + # via -r requirements/testing.in packaging==23.0 # via # black @@ -39,24 +39,27 @@ platformdirs==3.2.0 pluggy==1.0.0 # via pytest pydantic==1.10.7 - # via -r testing.in + # via -r requirements/testing.in pygments==2.15.0 - # via - # -r testing.in - # rich + # via rich pytest==7.2.2 # via - # -r testing.in + # -r requirements/testing.in # pytest-mock # pytest-pretty pytest-mock==3.10.0 - # via -r testing.in + # via -r requirements/testing.in pytest-pretty==1.2.0 - # via -r testing.in + # via -r requirements/testing.in rich==13.3.3 # via pytest-pretty sqlalchemy==2.0.8 - # via -r testing.in + # via -r requirements/testing.in +tomli==2.0.1 + # via + # black + # coverage + # pytest typing-extensions==4.5.0 # via # pydantic From e4937c984d516827577f4a909174d36339414a9a Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 17 Aug 2023 12:20:46 +0100 Subject: [PATCH 49/53] Uprev 0.12.0 (#138) --- HISTORY.md | 14 ++++++++++++++ devtools/version.py | 2 +- runtime.txt | 1 - 3 files changed, 15 insertions(+), 2 deletions(-) delete mode 100644 runtime.txt diff --git a/HISTORY.md b/HISTORY.md index 1aa231e..8cf5103 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,17 @@ +## 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 diff --git a/devtools/version.py b/devtools/version.py index d3c01ed..bf3b89a 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.11.0' +VERSION = '0.12.0' diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index cc1923a..0000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -3.8 From f080d39dfe8ff86ef3d2483616cd70c2fe8b5cc9 Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Thu, 17 Aug 2023 15:49:53 +0100 Subject: [PATCH 50/53] fix docs publish --- .github/workflows/ci.yml | 3 ++- HISTORY.md | 4 ++++ devtools/version.py | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bcfe8bf..3df53d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -134,7 +134,8 @@ jobs: path: site - name: check GITHUB_REF matches package version - uses: samuelcolvin/check-python-version@v3 + id: check-tag + uses: samuelcolvin/check-python-version@v4.1 with: version_file_path: devtools/version.py diff --git a/HISTORY.md b/HISTORY.md index 8cf5103..ddf0bda 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,3 +1,7 @@ +## v0.12.1 (2023-08-17) + +fix docs release + ## v0.12.0 (2023-08-17) * build docs on CI by @samuelcolvin in #127 diff --git a/devtools/version.py b/devtools/version.py index bf3b89a..b0ec321 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.12.0' +VERSION = '0.12.1' From ec406ffdd841f65b132e81f3d715321d3cfb5efa Mon Sep 17 00:00:00 2001 From: Samuel Colvin Date: Sun, 3 Sep 2023 17:50:32 +0100 Subject: [PATCH 51/53] install debug into `builtins` via `DebugProxy` (#139) --- Makefile | 2 +- devtools/__main__.py | 51 +++++++++++++++++++++++++++----------------- devtools/debug.py | 21 ++++++++++++------ devtools/version.py | 2 +- 4 files changed, 48 insertions(+), 28 deletions(-) diff --git a/Makefile b/Makefile index eecc748..51b877c 100644 --- a/Makefile +++ b/Makefile @@ -25,7 +25,7 @@ update-lockfiles: .PHONY: format format: black $(sources) - ruff $(sources) --fix --exit-zero + ruff $(sources) --fix-only .PHONY: lint lint: diff --git a/devtools/__main__.py b/devtools/__main__.py index bfc6155..2fbc79a 100644 --- a/devtools/__main__.py +++ b/devtools/__main__.py @@ -7,17 +7,32 @@ # language=python install_code = """ # add devtools `debug` function to builtins -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) +# 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()) """ @@ -27,12 +42,6 @@ def print_code() -> int: def install() -> int: - print('[WARNING: this command is experimental, report issues at github.com/samuelcolvin/python-devtools]\n') - - if hasattr(builtins, 'debug'): - print('Looks like devtools is already installed.') - return 0 - try: import sitecustomize # type: ignore except ImportError: @@ -48,7 +57,11 @@ def install() -> int: else: install_path = Path(sitecustomize.__file__) - print(f'Found path "{install_path}" to install devtools into __builtins__') + 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') if not install_path.is_relative_to(Path.home()): @@ -65,5 +78,5 @@ def install() -> int: 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]') + print(f'python-devtools v{VERSION}, CLI usage: `python -m devtools install|print-code`') sys.exit(1) diff --git a/devtools/debug.py b/devtools/debug.py index 5ea836a..89cda24 100644 --- a/devtools/debug.py +++ b/devtools/debug.py @@ -112,8 +112,15 @@ def __init__(self, *, warnings: 'Optional[bool]' = None, highlight: 'Optional[bo self._show_warnings = env_bool(warnings, 'PY_DEVTOOLS_WARNINGS', True) self._highlight = highlight - def __call__(self, *args: 'Any', file_: 'Any' = None, flush_: bool = True, **kwargs: 'Any') -> 'Any': - d_out = self._process(args, kwargs) + def __call__( + self, + *args: 'Any', + file_: 'Any' = None, + flush_: bool = True, + frame_depth_: int = 2, + **kwargs: 'Any', + ) -> 'Any': + d_out = self._process(args, kwargs, frame_depth_) s = d_out.str(use_highlight(self._highlight, file_)) print(s, file=file_, flush=flush_) if kwargs: @@ -123,8 +130,8 @@ def __call__(self, *args: 'Any', file_: 'Any' = None, flush_: bool = True, **kwa else: return args - def format(self, *args: 'Any', **kwargs: 'Any') -> DebugOutput: - return self._process(args, kwargs) + def format(self, *args: 'Any', frame_depth_: int = 2, **kwargs: 'Any') -> DebugOutput: + return self._process(args, kwargs, frame_depth_) def breakpoint(self) -> None: import pdb @@ -134,13 +141,13 @@ def breakpoint(self) -> None: 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: 'Any', kwargs: 'Any') -> 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( diff --git a/devtools/version.py b/devtools/version.py index b0ec321..a4ba93b 100644 --- a/devtools/version.py +++ b/devtools/version.py @@ -1 +1 @@ -VERSION = '0.12.1' +VERSION = '0.12.2' From 2aea99d95f4766fbca86139f72a1e4985d10a30e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20S=C5=82awecki?= Date: Fri, 26 Jan 2024 20:10:51 +0100 Subject: [PATCH 52/53] BUG FIX: Use `Path.relative_to()` for Python 3.8 compatibility (#150) --- devtools/__main__.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/devtools/__main__.py b/devtools/__main__.py index 2fbc79a..a5fa2f5 100644 --- a/devtools/__main__.py +++ b/devtools/__main__.py @@ -64,7 +64,9 @@ def install() -> int: 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') - if not install_path.is_relative_to(Path.home()): + 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.') From 5022f1f6f1c55d871b1db208c9c1b2ab0df488fe Mon Sep 17 00:00:00 2001 From: Kinuax Date: Fri, 24 Jan 2025 20:11:42 +0100 Subject: [PATCH 53/53] Fix macos-latest and 3.7 tests (#155) --- .github/workflows/ci.yml | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3df53d6..e131174 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -55,15 +55,21 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu, macos, windows] + 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@v3