diff --git a/.appveyor.yml b/.appveyor.yml deleted file mode 100644 index 71d0450..0000000 --- a/.appveyor.yml +++ /dev/null @@ -1,78 +0,0 @@ -version: '{branch}-{build}' -build: off -environment: - matrix: - - TOXENV: check - TOXPYTHON: C:\Python36\python.exe - PYTHON_HOME: C:\Python36 - PYTHON_VERSION: '3.6' - PYTHON_ARCH: '32' - - TOXENV: py27,codecov - TOXPYTHON: C:\Python27\python.exe - PYTHON_HOME: C:\Python27 - PYTHON_VERSION: '2.7' - PYTHON_ARCH: '32' - - TOXENV: py27,codecov - TOXPYTHON: C:\Python27-x64\python.exe - PYTHON_HOME: C:\Python27-x64 - PYTHON_VERSION: '2.7' - PYTHON_ARCH: '64' - WINDOWS_SDK_VERSION: v7.0 - - TOXENV: py35,codecov - TOXPYTHON: C:\Python35\python.exe - PYTHON_HOME: C:\Python35 - PYTHON_VERSION: '3.5' - PYTHON_ARCH: '32' - - TOXENV: py35,codecov - TOXPYTHON: C:\Python35-x64\python.exe - PYTHON_HOME: C:\Python35-x64 - PYTHON_VERSION: '3.5' - PYTHON_ARCH: '64' - - TOXENV: py36,codecov - TOXPYTHON: C:\Python36\python.exe - PYTHON_HOME: C:\Python36 - PYTHON_VERSION: '3.6' - PYTHON_ARCH: '32' - - TOXENV: py36,codecov - TOXPYTHON: C:\Python36-x64\python.exe - PYTHON_HOME: C:\Python36-x64 - PYTHON_VERSION: '3.6' - PYTHON_ARCH: '64' - - TOXENV: py37,codecov - TOXPYTHON: C:\Python37\python.exe - PYTHON_HOME: C:\Python37 - PYTHON_VERSION: '3.7' - PYTHON_ARCH: '32' - - TOXENV: py37,codecov - TOXPYTHON: C:\Python37-x64\python.exe - PYTHON_HOME: C:\Python37-x64 - PYTHON_VERSION: '3.7' - PYTHON_ARCH: '64' - - TOXENV: py38,codecov - TOXPYTHON: C:\Python38\python.exe - PYTHON_HOME: C:\Python38 - PYTHON_VERSION: '3.8' - PYTHON_ARCH: '32' - - TOXENV: py38,codecov - TOXPYTHON: C:\Python38-x64\python.exe - PYTHON_HOME: C:\Python38-x64 - PYTHON_VERSION: '3.8' - PYTHON_ARCH: '64' -init: - - ps: echo $env:TOXENV - - ps: ls C:\Python* -install: - - '%PYTHON_HOME%\python -mpip install --progress-bar=off tox -rci/requirements.txt' - - '%PYTHON_HOME%\Scripts\virtualenv --version' - - '%PYTHON_HOME%\Scripts\easy_install --version' - - '%PYTHON_HOME%\Scripts\pip --version' - - '%PYTHON_HOME%\Scripts\tox --version' -test_script: - - cmd /E:ON /V:ON /C .\ci\appveyor-with-compiler.cmd %PYTHON_HOME%\Scripts\tox -on_failure: - - ps: dir "env:" - - ps: get-content .tox\*\log\* - -### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): -# on_finish: -# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index 1d8622c..2658a5d 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 1.7.0 +current_version = 3.0.0 commit = True tag = True @@ -7,9 +7,13 @@ tag = True search = version='{current_version}' replace = version='{new_version}' -[bumpversion:file:README.rst] -search = v{current_version}. -replace = v{new_version}. +[bumpversion:file (badge):README.rst] +search = /v{current_version}.svg +replace = /v{new_version}.svg + +[bumpversion:file (link):README.rst] +search = /v{current_version}...master +replace = /v{new_version}...master [bumpversion:file:docs/conf.py] search = version = release = '{current_version}' @@ -19,3 +23,6 @@ replace = version = release = '{new_version}' search = __version__ = '{current_version}' replace = __version__ = '{new_version}' +[bumpversion:file:.cookiecutterrc] +search = version: {current_version} +replace = version: {new_version} diff --git a/.cookiecutterrc b/.cookiecutterrc index 1836a3d..6b22c96 100644 --- a/.cookiecutterrc +++ b/.cookiecutterrc @@ -1,52 +1,47 @@ # Generated by cookiepatcher, a small shim around cookiecutter (pip install cookiepatcher) -cookiecutter: - full_name: Ionel Cristian Mărieș +default_context: + allow_tests_inside_package: 'no' + c_extension_function: '-' + c_extension_module: '-' + c_extension_optional: 'no' + c_extension_support: 'no' + codacy: 'no' + codacy_projectid: '-' + codeclimate: 'no' + codecov: 'yes' + command_line_interface: 'no' + command_line_interface_bin_name: '-' + coveralls: 'no' + distribution_name: tblib email: contact@ionelmc.ro - website: https://blog.ionelmc.ro/ + formatter_quote_style: single + full_name: Ionel Cristian Mărieș + github_actions: 'yes' + github_actions_osx: 'yes' + github_actions_windows: 'yes' + license: BSD 2-Clause License + package_name: tblib + pre_commit: 'yes' project_name: tblib - repo_name: python-tblib + project_short_description: Traceback serialization library. + pypi_badge: 'yes' + pypi_disable_upload: 'no' + release_date: '2020-07-24' repo_hosting: github.com repo_hosting_domain: github.com + repo_main_branch: master + repo_name: python-tblib repo_username: ionelmc - package_name: tblib - distribution_name: tblib - project_short_description: Traceback serialization library. - release_date: '2020-03-07' - year_from: '2013' - year_to: '2' - version: 1.6.0 - license: BSD 2-Clause License - c_extension_support: no - c_extension_optional: no - c_extension_module: '-' - c_extension_function: '-' - c_extension_test_pypi: no - c_extension_test_pypi_username: '-' - test_matrix_configurator: no - test_matrix_separate_coverage: no - test_runner: pytest - setup_py_uses_test_runner: no - setup_py_uses_setuptools_scm: no - pypi_badge: yes - pypi_disable_upload: no - allow_tests_inside_package: no - linter: flake8 - command_line_interface: no - command_line_interface_bin_name: '-' - coveralls: no - coveralls_token: '-' - codecov: yes - landscape: no - scrutinizer: no - codacy: no - codacy_projectid: '-' - codeclimate: no - sphinx_docs: yes - sphinx_theme: sphinx-py3doc-enhanced-theme - sphinx_doctest: no + scrutinizer: 'no' + setup_py_uses_setuptools_scm: 'no' + sphinx_docs: 'yes' sphinx_docs_hosting: https://python-tblib.readthedocs.io/ - travis: yes - travis_osx: no - appveyor: yes - requiresio: yes + sphinx_doctest: 'no' + sphinx_theme: sphinx-py3doc-enhanced-theme + test_matrix_separate_coverage: 'no' + version: 3.0.0 + version_manager: bump2version + website: https://blog.ionelmc.ro/ + year_from: '2013' + year_to: '2023' diff --git a/.editorconfig b/.editorconfig index 6eb7567..586c736 100644 --- a/.editorconfig +++ b/.editorconfig @@ -2,12 +2,19 @@ root = true [*] +# Use Unix-style newlines for most files (except Windows files, see below). end_of_line = lf trim_trailing_whitespace = true -insert_final_newline = true indent_style = space +insert_final_newline = true indent_size = 4 charset = utf-8 [*.{bat,cmd,ps1}] end_of_line = crlf + +[*.{yml,yaml}] +indent_size = 2 + +[*.tsv] +indent_style = tab diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml new file mode 100644 index 0000000..7ea058e --- /dev/null +++ b/.github/workflows/github-actions.yml @@ -0,0 +1,185 @@ +name: build +on: [push, pull_request] +jobs: + test: + name: ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: 'check' + python: '3.11' + toxpython: 'python3.11' + tox_env: 'check' + os: 'ubuntu-latest' + - name: 'docs' + python: '3.11' + toxpython: 'python3.11' + tox_env: 'docs' + os: 'ubuntu-latest' + - name: 'py38 (ubuntu)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38' + os: 'ubuntu-latest' + - name: 'py38 (windows)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38' + os: 'windows-latest' + - name: 'py38 (macos)' + python: '3.8' + toxpython: 'python3.8' + python_arch: 'x64' + tox_env: 'py38' + os: 'macos-latest' + - name: 'py39 (ubuntu)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39' + os: 'ubuntu-latest' + - name: 'py39 (windows)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39' + os: 'windows-latest' + - name: 'py39 (macos)' + python: '3.9' + toxpython: 'python3.9' + python_arch: 'x64' + tox_env: 'py39' + os: 'macos-latest' + - name: 'py310 (ubuntu)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310' + os: 'ubuntu-latest' + - name: 'py310 (windows)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310' + os: 'windows-latest' + - name: 'py310 (macos)' + python: '3.10' + toxpython: 'python3.10' + python_arch: 'x64' + tox_env: 'py310' + os: 'macos-latest' + - name: 'py311 (ubuntu)' + python: '3.11' + toxpython: 'python3.11' + python_arch: 'x64' + tox_env: 'py311' + os: 'ubuntu-latest' + - name: 'py311 (windows)' + python: '3.11' + toxpython: 'python3.11' + python_arch: 'x64' + tox_env: 'py311' + os: 'windows-latest' + - name: 'py311 (macos)' + python: '3.11' + toxpython: 'python3.11' + python_arch: 'x64' + tox_env: 'py311' + os: 'macos-latest' + - name: 'py312 (ubuntu)' + python: '3.12' + toxpython: 'python3.12' + python_arch: 'x64' + tox_env: 'py312' + os: 'ubuntu-latest' + - name: 'py312 (windows)' + python: '3.12' + toxpython: 'python3.12' + python_arch: 'x64' + tox_env: 'py312' + os: 'windows-latest' + - name: 'py312 (macos)' + python: '3.12' + toxpython: 'python3.12' + python_arch: 'x64' + tox_env: 'py312' + os: 'macos-latest' + - name: 'pypy38 (ubuntu)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38' + os: 'ubuntu-latest' + - name: 'pypy38 (windows)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38' + os: 'windows-latest' + - name: 'pypy38 (macos)' + python: 'pypy-3.8' + toxpython: 'pypy3.8' + python_arch: 'x64' + tox_env: 'pypy38' + os: 'macos-latest' + - name: 'pypy39 (ubuntu)' + python: 'pypy-3.9' + toxpython: 'pypy3.9' + python_arch: 'x64' + tox_env: 'pypy39' + os: 'ubuntu-latest' + - name: 'pypy39 (windows)' + python: 'pypy-3.9' + toxpython: 'pypy3.9' + python_arch: 'x64' + tox_env: 'pypy39' + os: 'windows-latest' + - name: 'pypy39 (macos)' + python: 'pypy-3.9' + toxpython: 'pypy3.9' + python_arch: 'x64' + tox_env: 'pypy39' + os: 'macos-latest' + - name: 'pypy310 (ubuntu)' + python: 'pypy-3.10' + toxpython: 'pypy3.10' + python_arch: 'x64' + tox_env: 'pypy310' + os: 'ubuntu-latest' + - name: 'pypy310 (windows)' + python: 'pypy-3.10' + toxpython: 'pypy3.10' + python_arch: 'x64' + tox_env: 'pypy310' + os: 'windows-latest' + - name: 'pypy310 (macos)' + python: 'pypy-3.10' + toxpython: 'pypy3.10' + python_arch: 'x64' + tox_env: 'pypy310' + os: 'macos-latest' + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python }} + architecture: ${{ matrix.python_arch }} + - name: install dependencies + run: | + python -mpip install --progress-bar=off -r ci/requirements.txt + virtualenv --version + pip --version + tox --version + pip list --format=freeze + - name: test + env: + TOXPYTHON: '${{ matrix.toxpython }}' + run: > + tox -e ${{ matrix.tox_env }} -v diff --git a/.gitignore b/.gitignore index dfe5838..77973dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,51 +1,67 @@ *.py[cod] __pycache__ +# Temp files +.*.sw[po] +*~ +*.bak +.DS_Store + # C extensions *.so -# Packages +# Build and package files *.egg *.egg-info -dist -build -eggs +.bootstrap +.build +.cache .eggs -parts +.env +.installed.cfg +.ve bin -var -sdist -wheelhouse +build develop-eggs -.installed.cfg +dist +eggs lib lib64 -venv*/ -pyvenv*/ +parts pip-wheel-metadata/ +pyvenv*/ +sdist +var +venv*/ +wheelhouse # Installer logs pip-log.txt # Unit test / coverage reports +.benchmarks .coverage -.tox .coverage.* +.pytest .pytest_cache/ -nosetests.xml +.tox coverage.xml htmlcov +nosetests.xml # Translations *.mo -# Mr Developer +# Buildout .mr.developer.cfg -.project -.pydevproject -.idea + +# IDE project files *.iml *.komodoproject +.idea +.project +.pydevproject +.vscode # Complexity output/*.html @@ -54,18 +70,5 @@ output/*/index.html # Sphinx docs/_build -.DS_Store -*~ -.*.sw[po] -.build -.ve -.env -.cache -.pytest -.benchmarks -.bootstrap -.appveyor.token -*.bak - # Mypy Cache .mypy_cache/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..e96cf93 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,22 @@ +# To install the git pre-commit hooks run: +# pre-commit install --install-hooks +# To update the versions: +# pre-commit autoupdate +exclude: '^(\.tox|ci/templates|\.bumpversion\.cfg|tests/bad.*.py)(/|$)' +# Note the order is intentional to avoid multiple passes of the hooks +repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.1.1 + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --show-fixes] + - repo: https://github.com/psf/black + rev: 23.10.0 + hooks: + - id: black + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: debug-statements diff --git a/.readthedocs.yml b/.readthedocs.yml index 59ff5c0..009a913 100644 --- a/.readthedocs.yml +++ b/.readthedocs.yml @@ -3,6 +3,10 @@ version: 2 sphinx: configuration: docs/conf.py formats: all +build: + os: ubuntu-22.04 + tools: + python: "3" python: install: - requirements: docs/requirements.txt diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 0e947cb..0000000 --- a/.travis.yml +++ /dev/null @@ -1,56 +0,0 @@ -language: python -dist: xenial -cache: false -env: - global: - - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so - - SEGFAULT_SIGNALS=all -matrix: - include: - - python: '3.6' - env: - - TOXENV=check - - python: '3.6' - env: - - TOXENV=docs - - env: - - TOXENV=py27,codecov - python: '2.7' - - env: - - TOXENV=py35,codecov - python: '3.5' - - env: - - TOXENV=py36,codecov - python: '3.6' - - env: - - TOXENV=py37,codecov - python: '3.7' - - env: - - TOXENV=py38,codecov - python: '3.8' - - env: - - TOXENV=pypy,codecov - python: 'pypy' - - env: - - TOXENV=pypy3,codecov - - TOXPYTHON=pypy3 - python: 'pypy3' -before_install: - - python --version - - uname -a - - lsb_release -a || true -install: - - python -mpip install --progress-bar=off tox -rci/requirements.txt - - virtualenv --version - - easy_install --version - - pip --version - - tox --version -script: - - tox -v -after_failure: - - more .tox/log/* | cat - - more .tox/*/log/* | cat -notifications: - email: - on_success: never - on_failure: always diff --git a/AUTHORS.rst b/AUTHORS.rst index 5f6547d..5d3dcd3 100644 --- a/AUTHORS.rst +++ b/AUTHORS.rst @@ -10,4 +10,6 @@ Authors * Elliott Sales de Andrade - https://github.com/QuLogic * Victor Stinner - https://github.com/vstinner * Guido Imperiale - https://github.com/crusaderky -* Ivanq - https://github.com/imachug +* Alisa Sireneva - https://github.com/purplesyringa +* Michał Górny - https://github.com/mgorny +* Tim Maxwell - https://github.com/tmaxwell-anthropic diff --git a/CHANGELOG.rst b/CHANGELOG.rst index c3c5aeb..e315ea4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -2,6 +2,21 @@ Changelog ========= +3.0.0 (2023-10-22) +~~~~~~~~~~~~~~~~~~ + +* Added support for ``__context__``, ``__suppress_context__`` and ``__notes__``. + Contributed by Tim Maxwell in `#72 `_. +* Added the ``get_locals`` argument to ``tblib.pickling_support.install()``, ``tblib.Traceback`` and ``tblib.Frame``. + Fixes `#41 `_. +* Dropped support for now-EOL Python 3.7 and added 3.12 in the test grid. + +2.0.0 (2023-06-22) +~~~~~~~~~~~~~~~~~~ + +* Removed support for legacy Pythons (2.7 and 3.6) and added Python 3.11 in the test grid. +* Some cleanups and refactors (mostly from ruff). + 1.7.0 (2020-07-24) ~~~~~~~~~~~~~~~~~~ @@ -36,7 +51,7 @@ Changelog * Add support for PyPy3.5-5.7.1-beta. Previously ``AttributeError: 'Frame' object has no attribute 'clear'`` could be raised. See PyPy - issue `#2532 `_. + issue `#2532 `_. 1.3.1 (2017-03-27) ~~~~~~~~~~~~~~~~~~ diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 1d6bfc5..bba6ab4 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -41,7 +41,7 @@ To set up `python-tblib` for local development: (look for the "Fork" button). 2. Clone your fork locally:: - git clone git@github.com:ionelmc/python-tblib.git + git clone git@github.com:YOURGITHUBNAME/python-tblib.git 3. Create a branch for local development:: @@ -49,7 +49,7 @@ To set up `python-tblib` for local development: Now you can make your changes locally. -4. When you're done making changes run all the checks and docs builder with `tox `_ one command:: +4. When you're done making changes run all the checks and docs builder with one command:: tox @@ -68,16 +68,11 @@ If you need some code review or feedback while you're developing the code just m For merging, you should: -1. Include passing tests (run ``tox``) [1]_. +1. Include passing tests (run ``tox``). 2. Update documentation when there's new API, functionality etc. 3. Add a note to ``CHANGELOG.rst`` about the changes. 4. Add yourself to ``AUTHORS.rst``. -.. [1] If you don't have all the necessary python versions available locally you can rely on Travis - it will - `run the tests `_ for each change you add in the pull request. - - It will be slower though ... - Tips ---- @@ -85,6 +80,6 @@ To run a subset of tests:: tox -e envname -- pytest -k test_myfeature -To run all the test environments in *parallel* (you need to ``pip install detox``):: +To run all the test environments in *parallel*:: - detox + tox -p auto diff --git a/LICENSE b/LICENSE index 7dac254..9bb39bf 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ BSD 2-Clause License -Copyright (c) 2013-2020, Ionel Cristian Mărieș. All rights reserved. +Copyright (c) 2013-2023, Ionel Cristian Mărieș. All rights reserved. Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: diff --git a/MANIFEST.in b/MANIFEST.in index 8b9e93d..d0dac9c 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,9 +4,14 @@ graft ci graft tests include .bumpversion.cfg -include .coveragerc include .cookiecutterrc +include .coveragerc include .editorconfig +include .github/workflows/github-actions.yml +include .pre-commit-config.yaml +include .readthedocs.yml +include pytest.ini +include tox.ini include AUTHORS.rst include CHANGELOG.rst @@ -14,6 +19,4 @@ include CONTRIBUTING.rst include LICENSE include README.rst -include tox.ini .travis.yml .appveyor.yml .readthedocs.yml - global-exclude *.py[cod] __pycache__/* *.so *.dylib diff --git a/README.rst b/README.rst index 3d003da..49604e5 100644 --- a/README.rst +++ b/README.rst @@ -10,31 +10,22 @@ Overview * - docs - |docs| * - tests - - | |travis| |appveyor| |requires| + - | |github-actions| | |codecov| * - package - | |version| |wheel| |supported-versions| |supported-implementations| | |commits-since| - -.. |docs| image:: https://codecov.io/gh/ionelmc/python-tblib/branch/master/graphs/badge.svg?branch=master - :target: https://readthedocs.org/projects/python-tblib +.. |docs| image:: https://readthedocs.org/projects/python-tblib/badge/?style=flat + :target: https://python-tblib.readthedocs.io/ :alt: Documentation Status -.. |travis| image:: https://api.travis-ci.org/ionelmc/python-tblib.svg?branch=master - :alt: Travis-CI Build Status - :target: https://travis-ci.org/ionelmc/python-tblib - -.. |appveyor| image:: https://ci.appveyor.com/api/projects/status/github/ionelmc/python-tblib?branch=master&svg=true - :alt: AppVeyor Build Status - :target: https://ci.appveyor.com/project/ionelmc/python-tblib - -.. |requires| image:: https://requires.io/github/ionelmc/python-tblib/requirements.svg?branch=master - :alt: Requirements Status - :target: https://requires.io/github/ionelmc/python-tblib/requirements/?branch=master +.. |github-actions| image:: https://github.com/ionelmc/python-tblib/actions/workflows/github-actions.yml/badge.svg + :alt: GitHub Actions Build Status + :target: https://github.com/ionelmc/python-tblib/actions -.. |codecov| image:: https://codecov.io/github/ionelmc/python-tblib/coverage.svg?branch=master +.. |codecov| image:: https://codecov.io/gh/ionelmc/python-tblib/branch/master/graphs/badge.svg?branch=master :alt: Coverage Status - :target: https://codecov.io/github/ionelmc/python-tblib + :target: https://app.codecov.io/github/ionelmc/python-tblib .. |version| image:: https://img.shields.io/pypi/v/tblib.svg :alt: PyPI Package latest release @@ -52,9 +43,9 @@ Overview :alt: Supported implementations :target: https://pypi.org/project/tblib -.. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-tblib/v1.7.0.svg +.. |commits-since| image:: https://img.shields.io/github/commits-since/ionelmc/python-tblib/v3.0.0.svg :alt: Commits since latest release - :target: https://github.com/ionelmc/python-tblib/compare/v1.7.0...master + :target: https://github.com/ionelmc/python-tblib/compare/v3.0.0...master .. end-badges diff --git a/ci/appveyor-download.py b/ci/appveyor-download.py deleted file mode 100755 index 8373863..0000000 --- a/ci/appveyor-download.py +++ /dev/null @@ -1,109 +0,0 @@ -#!/usr/bin/env python -""" -Use the AppVeyor API to download Windows artifacts. - -Taken from: https://bitbucket.org/ned/coveragepy/src/tip/ci/download_appveyor.py -# Licensed under the Apache License: https://www.apache.org/licenses/LICENSE-2.0 -# For details: https://bitbucket.org/ned/coveragepy/src/default/NOTICE.txt -""" -from __future__ import unicode_literals - -import argparse -import os -import zipfile - -import requests - - -def make_auth_headers(): - """Make the authentication headers needed to use the Appveyor API.""" - path = os.path.expanduser("~/.appveyor.token") - if not os.path.exists(path): - raise RuntimeError( - "Please create a file named `.appveyor.token` in your home directory. " - "You can get the token from https://ci.appveyor.com/api-token" - ) - with open(path) as f: - token = f.read().strip() - - headers = { - 'Authorization': 'Bearer {}'.format(token), - } - return headers - - -def download_latest_artifacts(account_project, build_id): - """Download all the artifacts from the latest build.""" - if build_id is None: - url = "https://ci.appveyor.com/api/projects/{}".format(account_project) - else: - url = "https://ci.appveyor.com/api/projects/{}/build/{}".format(account_project, build_id) - build = requests.get(url, headers=make_auth_headers()).json() - jobs = build['build']['jobs'] - print(u"Build {0[build][version]}, {1} jobs: {0[build][message]}".format(build, len(jobs))) - - for job in jobs: - name = job['name'] - print(u" {0}: {1[status]}, {1[artifactsCount]} artifacts".format(name, job)) - - url = "https://ci.appveyor.com/api/buildjobs/{}/artifacts".format(job['jobId']) - response = requests.get(url, headers=make_auth_headers()) - artifacts = response.json() - - for artifact in artifacts: - is_zip = artifact['type'] == "Zip" - filename = artifact['fileName'] - print(u" {0}, {1} bytes".format(filename, artifact['size'])) - - url = "https://ci.appveyor.com/api/buildjobs/{}/artifacts/{}".format(job['jobId'], filename) - download_url(url, filename, make_auth_headers()) - - if is_zip: - unpack_zipfile(filename) - os.remove(filename) - - -def ensure_dirs(filename): - """Make sure the directories exist for `filename`.""" - dirname = os.path.dirname(filename) - if dirname and not os.path.exists(dirname): - os.makedirs(dirname) - - -def download_url(url, filename, headers): - """Download a file from `url` to `filename`.""" - ensure_dirs(filename) - response = requests.get(url, headers=headers, stream=True) - if response.status_code == 200: - with open(filename, 'wb') as f: - for chunk in response.iter_content(16 * 1024): - f.write(chunk) - else: - print(u" Error downloading {}: {}".format(url, response)) - - -def unpack_zipfile(filename): - """Unpack a zipfile, using the names in the zip.""" - with open(filename, 'rb') as fzip: - z = zipfile.ZipFile(fzip) - for name in z.namelist(): - print(u" extracting {}".format(name)) - ensure_dirs(name) - z.extract(name) - - -parser = argparse.ArgumentParser(description='Download artifacts from AppVeyor.') -parser.add_argument('--id', - metavar='PROJECT_ID', - default='ionelmc/python-tblib', - help='Project ID in AppVeyor.') -parser.add_argument('build', - nargs='?', - metavar='BUILD_ID', - help='Build ID in AppVeyor. Eg: master-123') - -if __name__ == "__main__": - # import logging - # logging.basicConfig(level="DEBUG") - args = parser.parse_args() - download_latest_artifacts(args.id, args.build) diff --git a/ci/appveyor-with-compiler.cmd b/ci/appveyor-with-compiler.cmd deleted file mode 100644 index 289585f..0000000 --- a/ci/appveyor-with-compiler.cmd +++ /dev/null @@ -1,23 +0,0 @@ -:: Very simple setup: -:: - if WINDOWS_SDK_VERSION is set then activate the SDK. -:: - disable the WDK if it's around. - -SET COMMAND_TO_RUN=%* -SET WIN_SDK_ROOT=C:\Program Files\Microsoft SDKs\Windows -SET WIN_WDK="c:\Program Files (x86)\Windows Kits\10\Include\wdf" -ECHO SDK: %WINDOWS_SDK_VERSION% ARCH: %PYTHON_ARCH% - -IF EXIST %WIN_WDK% ( - REM See: https://connect.microsoft.com/VisualStudio/feedback/details/1610302/ - REN %WIN_WDK% 0wdf -) -IF "%WINDOWS_SDK_VERSION%"=="" GOTO main - -SET DISTUTILS_USE_SDK=1 -SET MSSdk=1 -"%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Setup\WindowsSdkVer.exe" -q -version:%WINDOWS_SDK_VERSION% -CALL "%WIN_SDK_ROOT%\%WINDOWS_SDK_VERSION%\Bin\SetEnv.cmd" /x64 /release - -:main -ECHO Executing: %COMMAND_TO_RUN% -CALL %COMMAND_TO_RUN% || EXIT 1 diff --git a/ci/bootstrap.py b/ci/bootstrap.py index 2597983..f3c9a7e 100755 --- a/ci/bootstrap.py +++ b/ci/bootstrap.py @@ -1,64 +1,57 @@ #!/usr/bin/env python -# -*- coding: utf-8 -*- -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - import os +import pathlib import subprocess import sys -from os.path import abspath -from os.path import dirname -from os.path import exists -from os.path import join -base_path = dirname(dirname(abspath(__file__))) +base_path: pathlib.Path = pathlib.Path(__file__).resolve().parent.parent +templates_path = base_path / 'ci' / 'templates' def check_call(args): - print("+", *args) + print('+', *args) subprocess.check_call(args) def exec_in_env(): - env_path = join(base_path, ".tox", "bootstrap") - if sys.platform == "win32": - bin_path = join(env_path, "Scripts") + env_path = base_path / '.tox' / 'bootstrap' + if sys.platform == 'win32': + bin_path = env_path / 'Scripts' else: - bin_path = join(env_path, "bin") - if not exists(env_path): + bin_path = env_path / 'bin' + if not env_path.exists(): import subprocess - print("Making bootstrap env in: {0} ...".format(env_path)) + print(f'Making bootstrap env in: {env_path} ...') try: - check_call([sys.executable, "-m", "venv", env_path]) + check_call([sys.executable, '-m', 'venv', env_path]) except subprocess.CalledProcessError: try: - check_call([sys.executable, "-m", "virtualenv", env_path]) + check_call([sys.executable, '-m', 'virtualenv', env_path]) except subprocess.CalledProcessError: - check_call(["virtualenv", env_path]) - print("Installing `jinja2` into bootstrap environment...") - check_call([join(bin_path, "pip"), "install", "jinja2", "tox"]) - python_executable = join(bin_path, "python") - if not os.path.exists(python_executable): - python_executable += '.exe' + check_call(['virtualenv', env_path]) + print('Installing `jinja2` into bootstrap environment...') + check_call([bin_path / 'pip', 'install', 'jinja2', 'tox']) + python_executable = bin_path / 'python' + if not python_executable.exists(): + python_executable = python_executable.with_suffix('.exe') + + print(f'Re-executing with: {python_executable}') + print('+ exec', python_executable, __file__, '--no-env') + os.execv(python_executable, [python_executable, __file__, '--no-env']) - print("Re-executing with: {0}".format(python_executable)) - print("+ exec", python_executable, __file__, "--no-env") - os.execv(python_executable, [python_executable, __file__, "--no-env"]) def main(): import jinja2 - print("Project path: {0}".format(base_path)) + print(f'Project path: {base_path}') jinja = jinja2.Environment( - loader=jinja2.FileSystemLoader(join(base_path, "ci", "templates")), + loader=jinja2.FileSystemLoader(str(templates_path)), trim_blocks=True, lstrip_blocks=True, - keep_trailing_newline=True + keep_trailing_newline=True, ) - tox_environments = [ line.strip() # 'tox' need not be installed globally, but must be importable @@ -69,21 +62,22 @@ def main(): for line in subprocess.check_output([sys.executable, '-m', 'tox', '--listenvs'], universal_newlines=True).splitlines() ] tox_environments = [line for line in tox_environments if line.startswith('py')] + for template in templates_path.rglob('*'): + if template.is_file(): + template_path = template.relative_to(templates_path).as_posix() + destination = base_path / template_path + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_text(jinja.get_template(template_path).render(tox_environments=tox_environments)) + print(f'Wrote {template_path}') + print('DONE.') - for name in os.listdir(join("ci", "templates")): - with open(join(base_path, name), "w") as fh: - fh.write(jinja.get_template(name).render(tox_environments=tox_environments)) - print("Wrote {}".format(name)) - print("DONE.") - -if __name__ == "__main__": +if __name__ == '__main__': args = sys.argv[1:] - if args == ["--no-env"]: + if args == ['--no-env']: main() elif not args: exec_in_env() else: - print("Unexpected arguments {0}".format(args), file=sys.stderr) + print(f'Unexpected arguments: {args}', file=sys.stderr) sys.exit(1) - diff --git a/ci/requirements.txt b/ci/requirements.txt index b2a21e5..a1708f4 100644 --- a/ci/requirements.txt +++ b/ci/requirements.txt @@ -1,4 +1,6 @@ virtualenv>=16.6.0 pip>=19.1.1 setuptools>=18.0.1 -six>=1.12.0 +six>=1.14.0 +tox +twine diff --git a/ci/templates/.appveyor.yml b/ci/templates/.appveyor.yml deleted file mode 100644 index bb4a055..0000000 --- a/ci/templates/.appveyor.yml +++ /dev/null @@ -1,49 +0,0 @@ -version: '{branch}-{build}' -build: off -environment: - matrix: - - TOXENV: check - TOXPYTHON: C:\Python36\python.exe - PYTHON_HOME: C:\Python36 - PYTHON_VERSION: '3.6' - PYTHON_ARCH: '32' -{% for env in tox_environments %} -{% if env.startswith(('py2', 'py3')) %} - - TOXENV: {{ env }},codecov{{ "" }} - TOXPYTHON: C:\Python{{ env[2:4] }}\python.exe - PYTHON_HOME: C:\Python{{ env[2:4] }} - PYTHON_VERSION: '{{ env[2] }}.{{ env[3] }}' - PYTHON_ARCH: '32' -{% if 'nocov' in env %} - WHEEL_PATH: .tox/dist -{% endif %} - - TOXENV: {{ env }},codecov{{ "" }} - TOXPYTHON: C:\Python{{ env[2:4] }}-x64\python.exe - PYTHON_HOME: C:\Python{{ env[2:4] }}-x64 - PYTHON_VERSION: '{{ env[2] }}.{{ env[3] }}' - PYTHON_ARCH: '64' -{% if 'nocov' in env %} - WHEEL_PATH: .tox/dist -{% endif %} -{% if env.startswith('py2') %} - WINDOWS_SDK_VERSION: v7.0 -{% endif %} -{% endif %}{% endfor %} -init: - - ps: echo $env:TOXENV - - ps: ls C:\Python* -install: - - '%PYTHON_HOME%\python -mpip install --progress-bar=off tox -rci/requirements.txt' - - '%PYTHON_HOME%\Scripts\virtualenv --version' - - '%PYTHON_HOME%\Scripts\easy_install --version' - - '%PYTHON_HOME%\Scripts\pip --version' - - '%PYTHON_HOME%\Scripts\tox --version' -test_script: - - cmd /E:ON /V:ON /C .\ci\appveyor-with-compiler.cmd %PYTHON_HOME%\Scripts\tox -on_failure: - - ps: dir "env:" - - ps: get-content .tox\*\log\* - -### To enable remote debugging uncomment this (also, see: http://www.appveyor.com/docs/how-to/rdp-to-build-worker): -# on_finish: -# - ps: $blockRdp = $true; iex ((new-object net.webclient).DownloadString('https://raw.githubusercontent.com/appveyor/ci/master/scripts/enable-rdp.ps1')) diff --git a/ci/templates/.github/workflows/github-actions.yml b/ci/templates/.github/workflows/github-actions.yml new file mode 100644 index 0000000..a99ff59 --- /dev/null +++ b/ci/templates/.github/workflows/github-actions.yml @@ -0,0 +1,65 @@ +name: build +on: [push, pull_request] +jobs: + test: + name: {{ '${{ matrix.name }}' }} + runs-on: {{ '${{ matrix.os }}' }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - name: 'check' + python: '3.11' + toxpython: 'python3.11' + tox_env: 'check' + os: 'ubuntu-latest' + - name: 'docs' + python: '3.11' + toxpython: 'python3.11' + tox_env: 'docs' + os: 'ubuntu-latest' +{% for env in tox_environments %} +{% set prefix = env.split('-')[0] -%} +{% if prefix.startswith('pypy') %} +{% set python %}pypy-{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} +{% set cpython %}pp{{ prefix[4:5] }}{% endset %} +{% set toxpython %}pypy{{ prefix[4] }}.{{ prefix[5:] }}{% endset %} +{% else %} +{% set python %}{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} +{% set cpython %}cp{{ prefix[2:] }}{% endset %} +{% set toxpython %}python{{ prefix[2] }}.{{ prefix[3:] }}{% endset %} +{% endif %} +{% for os, python_arch in [ + ['ubuntu', 'x64'], + ['windows', 'x64'], + ['macos', 'x64'], +] %} + - name: '{{ env }} ({{ os }})' + python: '{{ python }}' + toxpython: '{{ toxpython }}' + python_arch: '{{ python_arch }}' + tox_env: '{{ env }}' + os: '{{ os }}-latest' +{% endfor %} +{% endfor %} + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + - uses: actions/setup-python@v4 + with: + python-version: {{ '${{ matrix.python }}' }} + architecture: {{ '${{ matrix.python_arch }}' }} + - name: install dependencies + run: | + python -mpip install --progress-bar=off -r ci/requirements.txt + virtualenv --version + pip --version + tox --version + pip list --format=freeze + - name: test + env: + TOXPYTHON: '{{ '${{ matrix.toxpython }}' }}' + run: > + tox -e {{ '${{ matrix.tox_env }}' }} -v diff --git a/ci/templates/.travis.yml b/ci/templates/.travis.yml deleted file mode 100644 index 03110ef..0000000 --- a/ci/templates/.travis.yml +++ /dev/null @@ -1,46 +0,0 @@ -language: python -dist: xenial -cache: false -env: - global: - - LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so - - SEGFAULT_SIGNALS=all -matrix: - include: - - python: '3.6' - env: - - TOXENV=check - - python: '3.6' - env: - - TOXENV=docs -{%- for env in tox_environments %}{{ '' }} - - env: - - TOXENV={{ env }},codecov -{%- if env.startswith('pypy3') %}{{ '' }} - - TOXPYTHON=pypy3 - python: 'pypy3' -{%- elif env.startswith('pypy') %}{{ '' }} - python: 'pypy' -{%- else %}{{ '' }} - python: '{{ '{0[2]}.{0[3]}'.format(env) }}' -{%- endif %} -{%- endfor %}{{ '' }} -before_install: - - python --version - - uname -a - - lsb_release -a || true -install: - - python -mpip install --progress-bar=off tox -rci/requirements.txt - - virtualenv --version - - easy_install --version - - pip --version - - tox --version -script: - - tox -v -after_failure: - - more .tox/log/* | cat - - more .tox/*/log/* | cat -notifications: - email: - on_success: never - on_failure: always diff --git a/docs/conf.py b/docs/conf.py index af0256c..c9f498a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -import os +import sphinx_py3doc_enhanced_theme extensions = [ 'autoapi.extension', @@ -19,10 +16,10 @@ source_suffix = '.rst' master_doc = 'index' project = 'tblib' -year = '2013-2020' +year = '2013-2022' author = 'Ionel Cristian Mărieș' -copyright = '{0}, {1}'.format(year, author) -version = release = '1.7.0' +copyright = f'{year}, {author}' +version = release = '3.0.0' pygments_style = 'trac' templates_path = ['.'] @@ -30,20 +27,19 @@ 'issue': ('https://github.com/ionelmc/python-tblib/issues/%s', '#'), 'pr': ('https://github.com/ionelmc/python-tblib/pull/%s', 'PR #'), } -import sphinx_py3doc_enhanced_theme -html_theme = "sphinx_py3doc_enhanced_theme" +html_theme = 'sphinx_py3doc_enhanced_theme' html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()] html_theme_options = { - 'githuburl': 'https://github.com/ionelmc/python-tblib/' + 'githuburl': 'https://github.com/ionelmc/python-tblib/', } html_use_smartypants = True html_last_updated_fmt = '%b %d, %Y' html_split_index = False html_sidebars = { - '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], + '**': ['searchbox.html', 'globaltoc.html', 'sourcelink.html'], } -html_short_title = '%s-%s' % (project, version) +html_short_title = f'{project}-{version}' napoleon_use_ivar = True napoleon_use_rtype = False diff --git a/docs/index.rst b/docs/index.rst index e55d633..040ea20 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -9,6 +9,7 @@ Contents installation usage contributing + autoapi/index authors changelog @@ -18,4 +19,3 @@ Indices and tables * :ref:`genindex` * :ref:`modindex` * :ref:`search` - diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..af75660 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,57 @@ +[build-system] +requires = [ + "setuptools>=30.3.0", +] + +[tool.ruff.per-file-ignores] +"ci/*" = ["S"] + +[tool.ruff] +extend-exclude = ["static", "ci/templates"] +ignore = [ + "RUF001", # ruff-specific rules ambiguous-unicode-character-string + "S101", # flake8-bandit assert + "S301", # flake8-bandit pickle + "S308", # flake8-bandit suspicious-mark-safe-usage + "E501", # pycodestyle line-too-long +] +line-length = 140 +select = [ + "B", # flake8-bugbear + "C4", # flake8-comprehensions + "DTZ", # flake8-datetimez + "E", # pycodestyle errors + "EXE", # flake8-executable + "F", # pyflakes + "I", # isort + "INT", # flake8-gettext + "PIE", # flake8-pie + "PLC", # pylint convention + "PLE", # pylint errors + "PT", # flake8-pytest-style + "PTH", # flake8-use-pathlib + "Q", # flake8-quotes + "RSE", # flake8-raise + "RUF", # ruff-specific rules + "S", # flake8-bandit + "UP", # pyupgrade + "W", # pycodestyle warnings +] +src = ["src", "tests"] +target-version = "py38" + +[tool.ruff.flake8-pytest-style] +fixture-parentheses = false +mark-parentheses = false + +[tool.ruff.isort] +forced-separate = ["conftest"] +force-single-line = true + +[tool.black] +line-length = 140 +target-version = ["py38"] +skip-string-normalization = true + +[tool.ruff.flake8-quotes] +inline-quotes = "single" diff --git a/setup.cfg b/pytest.ini similarity index 60% rename from setup.cfg rename to pytest.ini index 1348d5f..5f7ccc6 100644 --- a/setup.cfg +++ b/pytest.ini @@ -1,11 +1,4 @@ -[bdist_wheel] -universal = 1 - -[flake8] -max-line-length = 140 -exclude = */migrations/* - -[tool:pytest] +[pytest] # If a pytest section is found in one of the possible config files # (pytest.ini, tox.ini or setup.cfg), then pytest will not look for any others, # so if you add a pytest config section elsewhere, @@ -19,21 +12,19 @@ python_files = tests.py addopts = -ra - --strict + --strict-markers --ignore=tests/badmodule.py --ignore=tests/badsyntax.py --doctest-modules - --doctest-continue-on-failure --doctest-glob=\*.rst --tb=short testpaths = tests -[tool:isort] -force_single_line = True -line_length = 120 -known_first_party = tblib -default_section = THIRDPARTY -forced_separate = test_tblib -not_skip = __init__.py -skip = migrations +# Idea from: https://til.simonwillison.net/pytest/treat-warnings-as-errors +filterwarnings = + error +# You can add exclusions, some examples: +# ignore:'tblib' defines default_app_config:PendingDeprecationWarning:: +# ignore:The {{% if::: +# ignore:Coverage disabled via --no-cov switch! diff --git a/setup.py b/setup.py old mode 100644 new mode 100755 index 9193b68..c5364a1 --- a/setup.py +++ b/setup.py @@ -1,43 +1,31 @@ #!/usr/bin/env python -# -*- encoding: utf-8 -*- -from __future__ import absolute_import -from __future__ import print_function - -import io import re -from glob import glob -from os.path import basename -from os.path import dirname -from os.path import join -from os.path import splitext +from pathlib import Path from setuptools import find_packages from setuptools import setup def read(*names, **kwargs): - with io.open( - join(dirname(__file__), *names), - encoding=kwargs.get('encoding', 'utf8') - ) as fh: + with Path(__file__).parent.joinpath(*names).open(encoding=kwargs.get('encoding', 'utf8')) as fh: return fh.read() setup( name='tblib', - version='1.7.0', + version='3.0.0', license='BSD-2-Clause', description='Traceback serialization library.', - long_description='%s\n%s' % ( + long_description='{}\n{}'.format( re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')), - re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')) + re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst')), ), author='Ionel Cristian Mărieș', author_email='contact@ionelmc.ro', url='https://github.com/ionelmc/python-tblib', packages=find_packages('src'), package_dir={'': 'src'}, - py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')], + py_modules=[path.stem for path in Path('src').glob('*.py')], include_package_data=True, zip_safe=False, classifiers=[ @@ -49,18 +37,19 @@ def read(*names, **kwargs): 'Operating System :: POSIX', 'Operating System :: Microsoft :: Windows', 'Programming Language :: Python', - 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3 :: Only', 'Programming Language :: Python :: 3.8', + 'Programming Language :: Python :: 3.9', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', 'Programming Language :: Python :: Implementation :: CPython', 'Programming Language :: Python :: Implementation :: PyPy', # uncomment if you test on these interpreters: - # 'Programming Language :: Python :: Implementation :: IronPython', - # 'Programming Language :: Python :: Implementation :: Jython', - # 'Programming Language :: Python :: Implementation :: Stackless', + # "Programming Language :: Python :: Implementation :: IronPython", + # "Programming Language :: Python :: Implementation :: Jython", + # "Programming Language :: Python :: Implementation :: Stackless", 'Topic :: Utilities', ], project_urls={ @@ -69,15 +58,17 @@ def read(*names, **kwargs): 'Issue Tracker': 'https://github.com/ionelmc/python-tblib/issues', }, keywords=[ - 'traceback', 'debugging', 'exceptions', + 'traceback', + 'debugging', + 'exceptions', ], - python_requires='>=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*', + python_requires='>=3.8', install_requires=[ - # eg: 'aspectlib==1.1.1', 'six>=1.7', + # eg: "aspectlib==1.1.1", "six>=1.7", ], extras_require={ # eg: - # 'rst': ['docutils>=0.11'], - # ':python_version=="2.6"': ['argparse'], + # "rst": ["docutils>=0.11"], + # ":python_version=="2.6"": ["argparse"], }, ) diff --git a/src/tblib/__init__.py b/src/tblib/__init__.py index 7e717b4..70af7ff 100644 --- a/src/tblib/__init__.py +++ b/src/tblib/__init__.py @@ -1,25 +1,10 @@ import re import sys from types import CodeType -from types import FrameType -from types import TracebackType - -try: - from __pypy__ import tproxy -except ImportError: - tproxy = None -try: - from .cpython import tb_set_next -except ImportError: - tb_set_next = None - -if not tb_set_next and not tproxy: - raise ImportError("Cannot use tblib. Runtime not supported.") - -__version__ = '1.7.0' + +__version__ = '3.0.0' __all__ = 'Traceback', 'TracebackParseError', 'Frame', 'Code' -PY3 = sys.version_info[0] == 3 FRAME_RE = re.compile(r'^\s*File "(?P.+)", line (?P\d+)(, in (?P.+))?$') @@ -30,7 +15,7 @@ def __getattr__(self, name): try: return self[name] except KeyError: - raise AttributeError(name) + raise AttributeError(name) from None # noinspection PyPep8Naming @@ -42,10 +27,11 @@ class TracebackParseError(Exception): pass -class Code(object): +class Code: """ Class that replicates just enough of the builtin Code object to enable serialization and traceback rendering. """ + co_code = None def __init__(self, code): @@ -59,28 +45,21 @@ def __init__(self, code): self.co_flags = 64 self.co_firstlineno = 0 - # noinspection SpellCheckingInspection - def __tproxy__(self, operation, *args, **kwargs): - """ - Necessary for PyPy's tproxy. - """ - if operation in ('__getattribute__', '__getattr__'): - return getattr(self, args[0]) - else: - return getattr(self, operation)(*args, **kwargs) - -class Frame(object): +class Frame: """ Class that replicates just enough of the builtin Frame object to enable serialization and traceback rendering. + + Args: + + get_locals (callable): A function that take a frame argument and returns a dict. + + See :class:`Traceback` class for example. """ - def __init__(self, frame): - self.f_locals = {} - self.f_globals = { - k: v - for k, v in frame.f_globals.items() - if k in ("__file__", "__name__") - } + + def __init__(self, frame, *, get_locals=None): + self.f_locals = {} if get_locals is None else get_locals(frame) + self.f_globals = {k: v for k, v in frame.f_globals.items() if k in ('__file__', '__name__')} self.f_code = Code(frame.f_code) self.f_lineno = frame.f_lineno @@ -92,29 +71,31 @@ def clear(self): in turn is called by unittest.TestCase.assertRaises """ - # noinspection SpellCheckingInspection - def __tproxy__(self, operation, *args, **kwargs): - """ - Necessary for PyPy's tproxy. - """ - if operation in ('__getattribute__', '__getattr__'): - if args[0] == 'f_code': - return tproxy(CodeType, self.f_code.__tproxy__) - else: - return getattr(self, args[0]) - else: - return getattr(self, operation)(*args, **kwargs) - -class Traceback(object): +class Traceback: """ Class that wraps builtin Traceback objects. + + Args: + get_locals (callable): A function that take a frame argument and returns a dict. + + Ideally you will only return exactly what you need, and only with simple types that can be json serializable. + + Example: + + .. code:: python + + def get_locals(frame): + if frame.f_locals.get("__tracebackhide__"): + return {"__tracebackhide__": True} + else: + return {} """ + tb_next = None - def __init__(self, tb): - self.tb_frame = Frame(tb.tb_frame) - # noinspection SpellCheckingInspection + def __init__(self, tb, *, get_locals=None): + self.tb_frame = Frame(tb.tb_frame, get_locals=get_locals) self.tb_lineno = int(tb.tb_lineno) # Build in place to avoid exceeding the recursion limit @@ -123,7 +104,7 @@ def __init__(self, tb): cls = type(self) while tb is not None: traceback = object.__new__(cls) - traceback.tb_frame = Frame(tb.tb_frame) + traceback.tb_frame = Frame(tb.tb_frame, get_locals=get_locals) traceback.tb_lineno = int(tb.tb_lineno) prev_traceback.tb_next = traceback prev_traceback = traceback @@ -133,48 +114,43 @@ def as_traceback(self): """ Convert to a builtin Traceback object that is usable for raising or rendering a stacktrace. """ - if tproxy: - return tproxy(TracebackType, self.__tproxy__) - if not tb_set_next: - raise RuntimeError("Unsupported Python interpreter!") - current = self top_tb = None tb = None while current: f_code = current.tb_frame.f_code code = compile('\n' * (current.tb_lineno - 1) + 'raise __traceback_maker', current.tb_frame.f_code.co_filename, 'exec') - if hasattr(code, "replace"): + if hasattr(code, 'replace'): # Python 3.8 and newer - code = code.replace(co_argcount=0, - co_filename=f_code.co_filename, co_name=f_code.co_name, - co_freevars=(), co_cellvars=()) - elif PY3: - code = CodeType( - 0, code.co_kwonlyargcount, - code.co_nlocals, code.co_stacksize, code.co_flags, - code.co_code, code.co_consts, code.co_names, code.co_varnames, - f_code.co_filename, f_code.co_name, - code.co_firstlineno, code.co_lnotab, (), () - ) + code = code.replace(co_argcount=0, co_filename=f_code.co_filename, co_name=f_code.co_name, co_freevars=(), co_cellvars=()) else: code = CodeType( 0, - code.co_nlocals, code.co_stacksize, code.co_flags, - code.co_code, code.co_consts, code.co_names, code.co_varnames, - f_code.co_filename.encode(), f_code.co_name.encode(), - code.co_firstlineno, code.co_lnotab, (), () + code.co_kwonlyargcount, + code.co_nlocals, + code.co_stacksize, + code.co_flags, + code.co_code, + code.co_consts, + code.co_names, + code.co_varnames, + f_code.co_filename, + f_code.co_name, + code.co_firstlineno, + code.co_lnotab, + (), + (), ) # noinspection PyBroadException try: - exec(code, dict(current.tb_frame.f_globals), {}) + exec(code, dict(current.tb_frame.f_globals), dict(current.tb_frame.f_locals)) # noqa: S102 except Exception: next_tb = sys.exc_info()[2].tb_next if top_tb is None: top_tb = next_tb if tb is not None: - tb_set_next(tb, next_tb) + tb.tb_next = next_tb tb = next_tb del next_tb @@ -184,22 +160,8 @@ def as_traceback(self): finally: del top_tb del tb - to_traceback = as_traceback - # noinspection SpellCheckingInspection - def __tproxy__(self, operation, *args, **kwargs): - """ - Necessary for PyPy's tproxy. - """ - if operation in ('__getattribute__', '__getattr__'): - if args[0] == 'tb_next': - return self.tb_next and self.tb_next.as_traceback() - elif args[0] == 'tb_frame': - return tproxy(FrameType, self.tb_frame.__tproxy__) - else: - return getattr(self, args[0]) - else: - return getattr(self, operation)(*args, **kwargs) + to_traceback = as_traceback def as_dict(self): """ @@ -209,7 +171,7 @@ def as_dict(self): if self.tb_next is None: tb_next = None else: - tb_next = self.tb_next.to_dict() + tb_next = self.tb_next.as_dict() code = { 'co_filename': self.tb_frame.f_code.co_filename, @@ -217,6 +179,7 @@ def as_dict(self): } frame = { 'f_globals': self.tb_frame.f_globals, + 'f_locals': self.tb_frame.f_locals, 'f_code': code, 'f_lineno': self.tb_frame.f_lineno, } @@ -225,6 +188,7 @@ def as_dict(self): 'tb_lineno': self.tb_lineno, 'tb_next': tb_next, } + to_dict = as_dict @classmethod @@ -243,6 +207,7 @@ def from_dict(cls, dct): ) frame = _AttrDict( f_globals=dct['tb_frame']['f_globals'], + f_locals=dct['tb_frame'].get('f_locals', {}), f_code=code, f_lineno=dct['tb_frame']['f_lineno'], ) @@ -251,7 +216,7 @@ def from_dict(cls, dct): tb_lineno=dct['tb_lineno'], tb_next=tb_next, ) - return cls(tb) + return cls(tb, get_locals=get_all_locals) @classmethod def from_string(cls, string, strict=True): @@ -287,6 +252,7 @@ def from_string(cls, string, strict=True): __file__=frame['co_filename'], __name__='?', ), + f_locals={}, f_code=_AttrDict(frame), f_lineno=int(frame['tb_lineno']), ), @@ -294,4 +260,8 @@ def from_string(cls, string, strict=True): ) return cls(previous) else: - raise TracebackParseError("Could not find any frames in %r." % string) + raise TracebackParseError('Could not find any frames in %r.' % string) + + +def get_all_locals(frame): + return dict(frame.f_locals) diff --git a/src/tblib/cpython.py b/src/tblib/cpython.py deleted file mode 100644 index 5c4bf20..0000000 --- a/src/tblib/cpython.py +++ /dev/null @@ -1,80 +0,0 @@ -""" -Taken verbatim from Jinja2. - -https://github.com/mitsuhiko/jinja2/blob/master/jinja2/debug.py#L267 -""" -import platform -import sys - - -def _init_ugly_crap(): - """This function implements a few ugly things so that we can patch the - traceback objects. The function returned allows resetting `tb_next` on - any python traceback object. Do not attempt to use this on non cpython - interpreters - """ - import ctypes - from types import TracebackType - - # figure out side of _Py_ssize_t - if hasattr(ctypes.pythonapi, 'Py_InitModule4_64'): - _Py_ssize_t = ctypes.c_int64 - else: - _Py_ssize_t = ctypes.c_int - - # regular python - class _PyObject(ctypes.Structure): - pass - - _PyObject._fields_ = [ - ('ob_refcnt', _Py_ssize_t), - ('ob_type', ctypes.POINTER(_PyObject)) - ] - - # python with trace - if hasattr(sys, 'getobjects'): - class _PyObject(ctypes.Structure): - pass - - _PyObject._fields_ = [ - ('_ob_next', ctypes.POINTER(_PyObject)), - ('_ob_prev', ctypes.POINTER(_PyObject)), - ('ob_refcnt', _Py_ssize_t), - ('ob_type', ctypes.POINTER(_PyObject)) - ] - - class _Traceback(_PyObject): - pass - - _Traceback._fields_ = [ - ('tb_next', ctypes.POINTER(_Traceback)), - ('tb_frame', ctypes.POINTER(_PyObject)), - ('tb_lasti', ctypes.c_int), - ('tb_lineno', ctypes.c_int) - ] - - def tb_set_next(tb, next): - """Set the tb_next attribute of a traceback object.""" - if not (isinstance(tb, TracebackType) and (next is None or isinstance(next, TracebackType))): - raise TypeError('tb_set_next arguments must be traceback objects') - obj = _Traceback.from_address(id(tb)) - if tb.tb_next is not None: - old = _Traceback.from_address(id(tb.tb_next)) - old.ob_refcnt -= 1 - if next is None: - obj.tb_next = ctypes.POINTER(_Traceback)() - else: - next = _Traceback.from_address(id(next)) - next.ob_refcnt += 1 - obj.tb_next = ctypes.pointer(next) - - return tb_set_next - - -tb_set_next = None -try: - if platform.python_implementation() == 'CPython': - tb_set_next = _init_ugly_crap() -except Exception as exc: - sys.stderr.write("Failed to initialize cpython support: {!r}".format(exc)) -del _init_ugly_crap diff --git a/src/tblib/decorators.py b/src/tblib/decorators.py index 29fdef2..38d0675 100644 --- a/src/tblib/decorators.py +++ b/src/tblib/decorators.py @@ -6,7 +6,7 @@ from . import Traceback -class Error(object): +class Error: def __init__(self, exc_type, exc_value, traceback): self.exc_type = exc_type self.exc_value = exc_value diff --git a/src/tblib/pickling_support.py b/src/tblib/pickling_support.py index cf6e390..0085140 100644 --- a/src/tblib/pickling_support.py +++ b/src/tblib/pickling_support.py @@ -1,14 +1,10 @@ -import sys +import copyreg +from functools import partial from types import TracebackType from . import Frame from . import Traceback -if sys.version_info.major >= 3: - import copyreg -else: - import copy_reg as copyreg - def unpickle_traceback(tb_frame, tb_lineno, tb_next): ret = object.__new__(Traceback) @@ -18,14 +14,24 @@ def unpickle_traceback(tb_frame, tb_lineno, tb_next): return ret.as_traceback() -def pickle_traceback(tb): - return unpickle_traceback, (Frame(tb.tb_frame), tb.tb_lineno, tb.tb_next and Traceback(tb.tb_next)) +def pickle_traceback(tb, *, get_locals=None): + return unpickle_traceback, ( + Frame(tb.tb_frame, get_locals=get_locals), + tb.tb_lineno, + tb.tb_next and Traceback(tb.tb_next, get_locals=get_locals), + ) -def unpickle_exception(func, args, cause, tb): +# Note: Older versions of tblib will generate pickle archives that call unpickle_exception() with +# fewer arguments. We assign default values to some of the arguments to support this. +def unpickle_exception(func, args, cause, tb, context=None, suppress_context=False, notes=None): inst = func(*args) inst.__cause__ = cause inst.__traceback__ = tb + inst.__context__ = context + inst.__suppress_context__ = suppress_context + if notes is not None: + inst.__notes__ = notes return inst @@ -39,10 +45,22 @@ def pickle_exception(obj): # still be pickled with protocol 5 if pickle.dump() is running with it. rv = obj.__reduce_ex__(3) if isinstance(rv, str): - raise TypeError("str __reduce__ output is not supported") - assert isinstance(rv, tuple) and len(rv) >= 2 - - return (unpickle_exception, rv[:2] + (obj.__cause__, obj.__traceback__)) + rv[2:] + raise TypeError('str __reduce__ output is not supported') + assert isinstance(rv, tuple) + assert len(rv) >= 2 + + return ( + unpickle_exception, + rv[:2] + + ( + obj.__cause__, + obj.__traceback__, + obj.__context__, + obj.__suppress_context__, + # __notes__ doesn't exist prior to Python 3.11; and even on Python 3.11 it may be absent + getattr(obj, '__notes__', None), + ), + ) + rv[2:] def _get_subclasses(cls): @@ -54,16 +72,13 @@ def _get_subclasses(cls): to_visit += list(this.__subclasses__()) -def install(*exc_classes_or_instances): - copyreg.pickle(TracebackType, pickle_traceback) +def install(*exc_classes_or_instances, get_locals=None): + """ + Args: - if sys.version_info.major < 3: - # Dummy decorator? - if len(exc_classes_or_instances) == 1: - exc = exc_classes_or_instances[0] - if isinstance(exc, type) and issubclass(exc, BaseException): - return exc - return + get_locals (callable): A function that take a frame argument and returns a dict. See :class:`tblib.Traceback` class for example. + """ + copyreg.pickle(TracebackType, partial(pickle_traceback, get_locals=get_locals)) if not exc_classes_or_instances: for exception_cls in _get_subclasses(BaseException): @@ -72,16 +87,36 @@ def install(*exc_classes_or_instances): for exc in exc_classes_or_instances: if isinstance(exc, BaseException): - while exc is not None: - copyreg.pickle(type(exc), pickle_exception) - exc = exc.__cause__ + _install_for_instance(exc, set()) elif isinstance(exc, type) and issubclass(exc, BaseException): copyreg.pickle(exc, pickle_exception) # Allow using @install as a decorator for Exception classes if len(exc_classes_or_instances) == 1: return exc else: - raise TypeError( - "Expected subclasses or instances of BaseException, got %s" - % (type(exc)) - ) + raise TypeError('Expected subclasses or instances of BaseException, got %s' % (type(exc))) + + +def _install_for_instance(exc, seen): + assert isinstance(exc, BaseException) + + # Prevent infinite recursion if we somehow get a self-referential exception. (Self-referential + # exceptions should never normally happen, but if it did somehow happen, we want to pickle the + # exception faithfully so the developer can troubleshoot why it happened.) + if id(exc) in seen: + return + seen.add(id(exc)) + + copyreg.pickle(type(exc), pickle_exception) + + if exc.__cause__ is not None: + _install_for_instance(exc.__cause__, seen) + if exc.__context__ is not None: + _install_for_instance(exc.__context__, seen) + + # This case is meant to cover BaseExceptionGroup on Python 3.11 as well as backports like the + # exceptiongroup module + if hasattr(exc, 'exceptions') and isinstance(exc.exceptions, (tuple, list)): + for subexc in exc.exceptions: + if isinstance(subexc, BaseException): + _install_for_instance(subexc, seen) diff --git a/tests/badmodule.py b/tests/badmodule.py index 86611f9..4915f00 100644 --- a/tests/badmodule.py +++ b/tests/badmodule.py @@ -1,3 +1,3 @@ a = 1 b = 2 -raise Exception("boom!") +raise Exception('boom!') diff --git a/tests/examples.py b/tests/examples.py index d4e64ab..bb61d8c 100644 --- a/tests/examples.py +++ b/tests/examples.py @@ -11,14 +11,16 @@ def func_c(): def func_d(): - raise Exception("Guessing time !") + raise Exception('Guessing time !') def bad_syntax(): import badsyntax - badsyntax + + badsyntax() def bad_module(): import badmodule - badmodule + + badmodule() diff --git a/tests/test_issue30.py b/tests/test_issue30.py index 270f146..3452597 100644 --- a/tests/test_issue30.py +++ b/tests/test_issue30.py @@ -2,9 +2,8 @@ import sys import pytest -import six -from tblib import pickling_support # noqa: E402 +from tblib import pickling_support pytest.importorskip('twisted') @@ -21,7 +20,8 @@ def test_30(): f = None try: - six.reraise(*pickle.loads(s)) + etype, evalue, etb = pickle.loads(s) + raise evalue.with_traceback(etb) except ValueError: f = Failure() diff --git a/tests/test_pickle_exception.py b/tests/test_pickle_exception.py index 18a018c..5ff4679 100644 --- a/tests/test_pickle_exception.py +++ b/tests/test_pickle_exception.py @@ -1,3 +1,5 @@ +from traceback import format_exception + try: import copyreg except ImportError: @@ -11,14 +13,14 @@ import tblib.pickling_support -has_python3 = sys.version_info.major >= 3 +has_python311 = sys.version_info >= (3, 11) @pytest.fixture def clear_dispatch_table(): bak = copyreg.dispatch_table.copy() copyreg.dispatch_table.clear() - yield + yield None copyreg.dispatch_table.clear() copyreg.dispatch_table.update(bak) @@ -27,50 +29,66 @@ class CustomError(Exception): pass -@pytest.mark.parametrize( - "protocol", [None] + list(range(1, pickle.HIGHEST_PROTOCOL + 1)) -) -@pytest.mark.parametrize("how", ["global", "instance", "class"]) +@pytest.mark.parametrize('protocol', [None, *list(range(1, pickle.HIGHEST_PROTOCOL + 1))]) +@pytest.mark.parametrize('how', ['global', 'instance', 'class']) def test_install(clear_dispatch_table, how, protocol): - if how == "global": + if how == 'global': tblib.pickling_support.install() - elif how == "class": - tblib.pickling_support.install(CustomError, ZeroDivisionError) + elif how == 'class': + tblib.pickling_support.install(CustomError, ValueError, ZeroDivisionError) try: try: - 1 / 0 + try: + 1 / 0 # noqa: B018 + finally: + # The ValueError's __context__ will be the ZeroDivisionError + raise ValueError('blah') except Exception as e: # Python 3 only syntax # raise CustomError("foo") from e - new_e = CustomError("foo") - if has_python3: - new_e.__cause__ = e - raise new_e + new_e = CustomError('foo') + new_e.__cause__ = e + if has_python311: + new_e.add_note('note 1') + new_e.add_note('note 2') + raise new_e from e except Exception as e: exc = e else: - assert False + raise AssertionError + expected_format_exception = ''.join(format_exception(type(exc), exc, exc.__traceback__)) + print(expected_format_exception) # Populate Exception.__dict__, which is used in some cases exc.x = 1 - if has_python3: - exc.__cause__.x = 2 + exc.__cause__.x = 2 + exc.__cause__.__context__.x = 3 - if how == "instance": + if how == 'instance': tblib.pickling_support.install(exc) if protocol: exc = pickle.loads(pickle.dumps(exc, protocol=protocol)) assert isinstance(exc, CustomError) - assert exc.args == ("foo",) + assert exc.args == ('foo',) assert exc.x == 1 - if has_python3: - assert exc.__traceback__ is not None - assert isinstance(exc.__cause__, ZeroDivisionError) - assert exc.__cause__.__traceback__ is not None - assert exc.__cause__.x == 2 - assert exc.__cause__.__cause__ is None + assert exc.__traceback__ is not None + + assert isinstance(exc.__cause__, ValueError) + assert exc.__cause__.__traceback__ is not None + assert exc.__cause__.x == 2 + assert exc.__cause__.__cause__ is None + + assert isinstance(exc.__cause__.__context__, ZeroDivisionError) + assert exc.__cause__.__context__.x == 3 + assert exc.__cause__.__context__.__cause__ is None + assert exc.__cause__.__context__.__context__ is None + + if has_python311: + assert exc.__notes__ == ['note 1', 'note 2'] + + assert expected_format_exception == ''.join(format_exception(type(exc), exc, exc.__traceback__)) @tblib.pickling_support.install @@ -80,19 +98,65 @@ class RegisteredError(Exception): def test_install_decorator(): with pytest.raises(RegisteredError) as ewrap: - raise RegisteredError("foo") + raise RegisteredError('foo') exc = ewrap.value exc.x = 1 exc = pickle.loads(pickle.dumps(exc)) assert isinstance(exc, RegisteredError) - assert exc.args == ("foo",) + assert exc.args == ('foo',) assert exc.x == 1 - if has_python3: - assert exc.__traceback__ is not None + assert exc.__traceback__ is not None + + +@pytest.mark.skipif(not has_python311, reason='no BaseExceptionGroup before Python 3.11') +def test_install_instance_recursively(clear_dispatch_table): + exc = BaseExceptionGroup('test', [ValueError('foo'), CustomError('bar')]) + exc.exceptions[0].__cause__ = ZeroDivisionError('baz') + exc.exceptions[0].__cause__.__context__ = AttributeError('quux') + + tblib.pickling_support.install(exc) + + installed = {c for c in copyreg.dispatch_table if issubclass(c, BaseException)} + assert installed == {ExceptionGroup, ValueError, CustomError, ZeroDivisionError, AttributeError} -@pytest.mark.skipif(sys.version_info[0] < 3, reason="No checks done in Python 2") def test_install_typeerror(): with pytest.raises(TypeError): - tblib.pickling_support.install("foo") + tblib.pickling_support.install('foo') + + +@pytest.mark.parametrize('protocol', [None, *list(range(1, pickle.HIGHEST_PROTOCOL + 1))]) +@pytest.mark.parametrize('how', ['global', 'instance', 'class']) +def test_get_locals(clear_dispatch_table, how, protocol): + def get_locals(frame): + if 'my_variable' in frame.f_locals: + return {'my_variable': int(frame.f_locals['my_variable'])} + else: + return {} + + if how == 'global': + tblib.pickling_support.install(get_locals=get_locals) + elif how == 'class': + tblib.pickling_support.install(CustomError, ValueError, ZeroDivisionError, get_locals=get_locals) + + def func(my_arg='2'): + my_variable = '1' + raise ValueError(my_variable) + + try: + func() + except Exception as e: + exc = e + else: + raise AssertionError + + f_locals = exc.__traceback__.tb_next.tb_frame.f_locals + assert 'my_variable' in f_locals + assert f_locals['my_variable'] == '1' + + if how == 'instance': + tblib.pickling_support.install(exc, get_locals=get_locals) + + exc = pickle.loads(pickle.dumps(exc, protocol=protocol)) + assert exc.__traceback__.tb_next.tb_frame.f_locals == {'my_variable': 1} diff --git a/tests/test_tblib.py b/tests/test_tblib.py index bade6d4..2f04cd9 100644 --- a/tests/test_tblib.py +++ b/tests/test_tblib.py @@ -6,7 +6,55 @@ pickling_support.install() -pytest_plugins = 'pytester', +pytest_plugins = ('pytester',) + + +def test_get_locals(): + def get_locals(frame): + print(frame, frame.f_locals) + if 'my_variable' in frame.f_locals: + return {'my_variable': int(frame.f_locals['my_variable'])} + else: + return {} + + def func(my_arg='2'): + my_variable = '1' + raise ValueError(my_variable) + + try: + func() + except Exception as e: + exc = e + else: + raise AssertionError + + f_locals = exc.__traceback__.tb_next.tb_frame.f_locals + assert 'my_variable' in f_locals + assert f_locals['my_variable'] == '1' + + value = Traceback(exc.__traceback__, get_locals=get_locals).as_dict() + lineno = exc.__traceback__.tb_lineno + assert value == { + 'tb_frame': { + 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, + 'f_locals': {}, + 'f_code': {'co_filename': __file__, 'co_name': 'test_get_locals'}, + 'f_lineno': lineno + 10, + }, + 'tb_lineno': lineno, + 'tb_next': { + 'tb_frame': { + 'f_globals': {'__name__': 'test_tblib', '__file__': __file__}, + 'f_locals': {'my_variable': 1}, + 'f_code': {'co_filename': __file__, 'co_name': 'func'}, + 'f_lineno': lineno - 3, + }, + 'tb_lineno': lineno - 3, + 'tb_next': None, + }, + } + + assert Traceback.from_dict(value).tb_next.tb_frame.f_locals == {'my_variable': 1} def test_parse_traceback(): @@ -31,27 +79,30 @@ def test_parse_traceback(): tb2 = Traceback(pytb) expected_dict = { - "tb_frame": { - "f_code": {"co_filename": "file1", "co_name": ""}, - "f_globals": {"__file__": "file1", "__name__": "?"}, - "f_lineno": 123, + 'tb_frame': { + 'f_code': {'co_filename': 'file1', 'co_name': ''}, + 'f_globals': {'__file__': 'file1', '__name__': '?'}, + 'f_locals': {}, + 'f_lineno': 123, }, - "tb_lineno": 123, - "tb_next": { - "tb_frame": { - "f_code": {"co_filename": "file2", "co_name": "???"}, - "f_globals": {"__file__": "file2", "__name__": "?"}, - "f_lineno": 234, + 'tb_lineno': 123, + 'tb_next': { + 'tb_frame': { + 'f_code': {'co_filename': 'file2', 'co_name': '???'}, + 'f_globals': {'__file__': 'file2', '__name__': '?'}, + 'f_locals': {}, + 'f_lineno': 234, }, - "tb_lineno": 234, - "tb_next": { - "tb_frame": { - "f_code": {"co_filename": "file3", "co_name": "function3"}, - "f_globals": {"__file__": "file3", "__name__": "?"}, - "f_lineno": 345, + 'tb_lineno': 234, + 'tb_next': { + 'tb_frame': { + 'f_code': {'co_filename': 'file3', 'co_name': 'function3'}, + 'f_globals': {'__file__': 'file3', '__name__': '?'}, + 'f_locals': {}, + 'f_lineno': 345, }, - "tb_lineno": 345, - "tb_next": None, + 'tb_lineno': 345, + 'tb_next': None, }, }, } @@ -61,9 +112,8 @@ def test_parse_traceback(): def test_pytest_integration(testdir): - test = testdir.makepyfile(""" -import six - + test = testdir.makepyfile( + """ from tblib import Traceback def test_raise(): @@ -77,68 +127,76 @@ def test_raise(): File "file4", line 456, in "" ''') pytb = tb1.as_traceback() - six.reraise(RuntimeError, RuntimeError(), pytb) -""") + raise RuntimeError().with_traceback(pytb) +""" + ) # mode(auto / long / short / line / native / no). result = testdir.runpytest_subprocess('--tb=long', '-vv', test) - result.stdout.fnmatch_lines([ - "_ _ _ _ _ _ _ _ *", - "", - "> [?][?][?]", - "", - "file1:123:*", - "_ _ _ _ _ _ _ _ *", - "", - "> [?][?][?]", - "", - "file2:234:*", - "_ _ _ _ _ _ _ _ *", - "", - "> [?][?][?]", - "", - "file3:345:*", - "_ _ _ _ _ _ _ _ *", - "", - "> [?][?][?]", - "E RuntimeError", - "", - "file4:456: RuntimeError", - "===*=== 1 failed in * ===*===", - ]) + result.stdout.fnmatch_lines( + [ + '_ _ _ _ _ _ _ _ *', + '', + '> [?][?][?]', + '', + 'file1:123:*', + '_ _ _ _ _ _ _ _ *', + '', + '> [?][?][?]', + '', + 'file2:234:*', + '_ _ _ _ _ _ _ _ *', + '', + '> [?][?][?]', + '', + 'file3:345:*', + '_ _ _ _ _ _ _ _ *', + '', + '> [?][?][?]', + 'E RuntimeError', + '', + 'file4:456: RuntimeError', + '===*=== 1 failed in * ===*===', + ] + ) result = testdir.runpytest_subprocess('--tb=short', '-vv', test) - result.stdout.fnmatch_lines([ - 'test_pytest_integration.py:*: in test_raise', - ' six.reraise(RuntimeError, RuntimeError(), pytb)', - 'file1:123: in ', - ' ???', - 'file2:234: in ???', - ' ???', - 'file3:345: in function3', - ' ???', - 'file4:456: in ""', - ' ???', - 'E RuntimeError', - ]) + result.stdout.fnmatch_lines( + [ + 'test_pytest_integration.py:*: in test_raise', + ' raise RuntimeError().with_traceback(pytb)', + 'file1:123: in ', + ' ???', + 'file2:234: in ???', + ' ???', + 'file3:345: in function3', + ' ???', + 'file4:456: in ""', + ' ???', + 'E RuntimeError', + ] + ) result = testdir.runpytest_subprocess('--tb=line', '-vv', test) - result.stdout.fnmatch_lines([ - "===*=== FAILURES ===*===", - "file4:456: RuntimeError", - "===*=== 1 failed in * ===*===", - ]) + result.stdout.fnmatch_lines( + [ + '===*=== FAILURES ===*===', + 'file4:456: RuntimeError', + '===*=== 1 failed in * ===*===', + ] + ) result = testdir.runpytest_subprocess('--tb=native', '-vv', test) - result.stdout.fnmatch_lines([ - 'Traceback (most recent call last):', - ' File "*test_pytest_integration.py", line *, in test_raise', - ' six.reraise(RuntimeError, RuntimeError(), pytb)', - ' File "file1", line 123, in ', - ' File "file2", line 234, in ???', - ' File "file3", line 345, in function3', - ' File "file4", line 456, in ""', - 'RuntimeError', - - ]) + result.stdout.fnmatch_lines( + [ + 'Traceback (most recent call last):', + ' File "*test_pytest_integration.py", line *, in test_raise', + ' raise RuntimeError().with_traceback(pytb)', + ' File "file1", line 123, in ', + ' File "file2", line 234, in ???', + ' File "file3", line 345, in function3', + ' File "file4", line 456, in ""', + 'RuntimeError', + ] + ) diff --git a/tox.ini b/tox.ini index e052f92..10215f8 100644 --- a/tox.ini +++ b/tox.ini @@ -1,64 +1,60 @@ [testenv:bootstrap] deps = jinja2 - matrix tox skip_install = true commands = python ci/bootstrap.py --no-env passenv = * -; a generative tox configuration, see: https://tox.readthedocs.io/en/latest/config.html#generative-envlist +; a generative tox configuration, see: https://tox.wiki/en/latest/user_guide.html#generative-environments [tox] envlist = clean, check, docs, - {py27,py35,py36,py37,py38,pypy,pypy3}, + {py38,py39,py310,py311,py312,pypy38,pypy39,pypy310}, report ignore_basepython_conflict = true [testenv] basepython = - pypy: {env:TOXPYTHON:pypy} - pypy3: {env:TOXPYTHON:pypy3} - py27: {env:TOXPYTHON:python2.7} - py35: {env:TOXPYTHON:python3.5} - py36: {env:TOXPYTHON:python3.6} - py37: {env:TOXPYTHON:python3.7} + pypy38: {env:TOXPYTHON:pypy3.8} + pypy39: {env:TOXPYTHON:pypy3.9} + pypy310: {env:TOXPYTHON:pypy3.10} py38: {env:TOXPYTHON:python3.8} - {bootstrap,clean,check,report,codecov,docs}: {env:TOXPYTHON:python3} + py39: {env:TOXPYTHON:python3.9} + py310: {env:TOXPYTHON:python3.10} + py311: {env:TOXPYTHON:python3.11} + py312: {env:TOXPYTHON:python3.12} + {bootstrap,clean,check,report,docs,codecov}: {env:TOXPYTHON:python3} setenv = PYTHONPATH={toxinidir}/tests PYTHONUNBUFFERED=yes + PYTHONNODEBUGRANGES=yes passenv = * usedevelop = false deps = pytest - pytest-travis-fold pytest-cov - pytest-clarity - six - py{27,35,36,37,38,py,py3}: twisted commands = - {posargs:py.test --cov=tblib --cov-report=term-missing -vv tests README.rst} + {posargs:pytest --cov --cov-report=term-missing --cov-report=xml -vv tests} [testenv:check] deps = docutils check-manifest - flake8 + pre-commit readme-renderer pygments isort skip_install = true commands = python setup.py check --strict --metadata --restructuredtext - check-manifest {toxinidir} - flake8 src tests setup.py - isort --verbose --check-only --diff --recursive src tests setup.py + check-manifest . + pre-commit run --all-files --show-diff-on-failure [testenv:docs] usedevelop = true @@ -68,15 +64,9 @@ commands = sphinx-build {posargs:-E} -b html docs dist/docs sphinx-build -b linkcheck docs dist/docs -[testenv:codecov] -deps = - codecov -skip_install = true -commands = - codecov [] - [testenv:report] -deps = coverage +deps = + coverage skip_install = true commands = coverage report @@ -85,4 +75,5 @@ commands = [testenv:clean] commands = coverage erase skip_install = true -deps = coverage +deps = + coverage