diff --git a/.flake8 b/.flake8 deleted file mode 100644 index c2e7c7f3b18d33e1ef400e5d53dc69929856b54d..0000000000000000000000000000000000000000 --- a/.flake8 +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -ignore = E203,W503,W504,E741 - -# vim: ft=dosini diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index 41d34bdc59baf417971a1ae46005b86afc3d8864..51bfcdbc7dd400bd14ac8cf5b0ed2f358f4a769a 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -41,26 +41,40 @@ The official tag is `structlog` and helping out in support frees us up to improv You can (and should) run our test suite using [*tox*]. However, you’ll probably want a more traditional environment as well. -We highly recommend to develop using the latest Python release because we try to take advantage of modern features whenever possible. -Clone the *structlog* repository: +First, create a [virtual environment](https://virtualenv.pypa.io/) so you don't break your system-wide Python installation. +We recommend using the Python version from the `.python-version-default` file in project's root directory. + +If you're using [*direnv*](https://direnv.net), you can automate the creation of a virtual environment with the correct Python version by adding the following `.envrc` to the project root: + +```bash +layout python python$(cat .python-version-default) +``` + +[Create a fork](https://github.com/hynek/structlog/fork) of the *structlog* repository and clone it: ```console -$ git clone git@github.com:hynek/structlog.git +$ git clone git@github.com:YOU/structlog.git ``` Or if you prefer to use Git via HTTPS: ```console -$ git clone https://github.com/hynek/structlog.git +$ git clone https://github.com/YOU/structlog.git ``` -Change into the newly created directory and after activating a virtual environment install an editable version of *structlog* along with its tests and docs requirements: +> **Warning** +> - **Before** you start working on a new pull request, use the "*Sync fork*" button in GitHub's web UI to ensure your fork is up to date. +> - **Always create a new branch off `main` for each new pull request.** +> Yes, you can work on `main` in your fork and submit pull requests. +> But this will *inevitably* lead to you not being able to synchronize your fork with upstream and having to start over. + +Change into the newly created directory and after activating a virtual environment, install an editable version of *structlog* along with its tests and docs requirements: ```console $ cd structlog -$ pip install --upgrade pip wheel # PLEASE don't skip this step -$ pip install -e '.[dev]' +$ python -Im pip install --upgrade pip wheel # PLEASE don't skip this step +$ python -Im pip install -e '.[dev]' ``` At this point, @@ -69,13 +83,21 @@ At this point, $ python -m pytest ``` -should work and pass, as should: +When working on the documentation, use: -```console -$ cd docs -$ make html +```bash +$ tox run -e docs-watch ``` +... to watch your files and automatically rebuild when a file changes. +And use: + +```bash +$ tox run -e docs +``` + +... to build it once and run our doctests. + The built documentation can then be found in `docs/_build/html/`. --- @@ -99,20 +121,24 @@ But it's way more comfortable to run it locally and catch avoidable errors befor ## Code - Obey [PEP 8](https://www.python.org/dev/peps/pep-0008/) and [PEP 257](https://www.python.org/dev/peps/pep-0257/). - We use the `"""`-on-separate-lines style for docstrings: + We use the `"""`-on-separate-lines style for docstrings with [Napoleon](https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html)-style API documentation: ```python def func(x: str) -> str: """ Do something. - :param str x: A very important parameter. + Arguments: + + x: A very important parameter. + + Returns: - :rtype: str + A very important return value. """ ``` - If you add or change public APIs, tag the docstring using `.. versionadded:: 16.0.0 WHAT` or `.. versionchanged:: 16.2.0 WHAT`. -- We use [*isort*](https://github.com/PyCQA/isort) to sort our imports, and we use [*Black*](https://github.com/psf/black) with line length of 79 characters to format our code. +- We use [Ruff](https://ruff.rs/) to sort our imports, and we use [Black](https://github.com/psf/black) with line length of 79 characters to format our code. As long as you run our full [*tox*] suite before committing, or install our [*pre-commit*] hooks (ideally you'll do both – see [*Local Development Environment*](#local-development-environment) above), you won't have to spend any time on formatting your code at all. If you don't, [CI] will catch it for you – but that seems like a waste of your time! @@ -130,7 +156,7 @@ But it's way more comfortable to run it locally and catch avoidable errors befor - To run the test suite, all you need is a recent [*tox*]. It will ensure the test suite runs with all dependencies against all Python versions just as it will in our [CI]. - If you lack some Python versions, you can can always limit the environments like `tox -e py38,py39`, or make it a non-failure using `tox --skip-missing-interpreters`. + If you lack some Python versions, you can always limit the environments like `tox -e py38,py39`, or make it a non-failure using `tox --skip-missing-interpreters`. In that case you should look into [*asdf*](https://asdf-vm.com) or [*pyenv*](https://github.com/pyenv/pyenv), which make it very easy to install many different Python versions in parallel. - Write [good test docstrings](https://jml.io/pages/test-docstrings.html). @@ -139,9 +165,9 @@ But it's way more comfortable to run it locally and catch avoidable errors befor ## Documentation -- We use [*Markdown*] everywhere except in `docs/api.rst` and docstrings. +- We use [Markdown] everywhere except in `docs/api.rst` and docstrings. -- Use [semantic newlines] in [*reStructuredText*] and [*Markdown*] files (files ending in `.rst` and `.md`): +- Use [semantic newlines] in [reStructuredText] and [Markdown] files (files ending in `.rst` and `.md`): ```markdown This is a sentence. @@ -207,5 +233,5 @@ or: [*pre-commit*]: https://pre-commit.com/ [*tox*]: https://tox.wiki/ [semantic newlines]: https://rhodesmill.org/brandon/2012/one-sentence-per-line/ -[*reStructuredText*]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html -[*Markdown*]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax +[reStructuredText]: https://www.sphinx-doc.org/en/stable/usage/restructuredtext/basics.html +[Markdown]: https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index ec7e8c0e9fa7f68b0db4cc40b607092c089254e6..381af11b28e46088ddc8cca3fb44d556c85e433d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -15,11 +15,12 @@ If an item doesn't apply to your pull request, **check it anyway** to make it ap - [ ] Added **tests** for changed code. - The CI fails with less than 100% coverage. -- [ ] **New APIs** are added to [`typing_examples.py`](https://github.com/hynek/structlog/blob/main/tests/typing_examples.py). +- [ ] **New APIs** are added to [`api.py`](https://github.com/hynek/structlog/blob/main/tests/typing/api.py). - [ ] Updated **documentation** for changed code. - [ ] New functions/classes have to be added to `docs/api.rst` by hand. - [ ] Changed/added classes/methods/functions have appropriate `versionadded`, `versionchanged`, or `deprecated` [directives](http://www.sphinx-doc.org/en/stable/markup/para.html#directive-versionadded). - Find the appropriate next version in our [`__init__.py`](https://github.com/hynek/structlog/blob/main/src/structlog/__init__.py) file. + + The next version is the second number in the current release + 1. The first number represents the current year. So if the current version on PyPI is 23.1.0, the next version is gonna be 23.2.0. If the next version is the first in the new year, it'll be 24.1.0. - [ ] Documentation in `.rst` and `.md` files is written using [**semantic newlines**](https://rhodesmill.org/brandon/2012/one-sentence-per-line/). - [ ] Changes (and possible deprecations) are documented in the [**changelog**](https://github.com/hynek/structlog/blob/main/CHANGELOG.md). - [ ] Consider granting [push permissions to the PR branch](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork), so maintainers can fix minor issues themselves without pestering you. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index 6a34429dcdc15c7452078177905e38156cb5bdfe..4357414adc94f78284646aea3973d8d712674b3f 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -5,8 +5,8 @@ We are following [*CalVer*](https://calver.org) with generous backwards-compatibility guarantees. Therefore we only support the latest version. -That said, you shouldn't be afraid to upgrade *structlog* if you're using its documented public APIs and pay attention to `DeprecationWarning`s. -Whenever there is a need to break compatibility, it is announced [in the changelog](https://github.com/hynek/structlog/blob/main/CHANGELOG.md) and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. +Put simply, you shouldn't ever be afraid to upgrade as long as you're only using our public APIs. +Whenever there is a need to break compatibility, it is announced in the changelog, and raises a `DeprecationWarning` for a year (if possible) before it's finally really broken. You **can't** rely on the default settings and the `structlog.dev` module, though. They may be adjusted in the future to provide a better experience when starting to use *structlog*. @@ -15,6 +15,5 @@ So please make sure to **always** properly configure your applications. ## Reporting a Vulnerability -To report a security vulnerability, please use the [Tidelift security -contact](https://tidelift.com/security). Tidelift will coordinate the fix and -disclosure. +To report a security vulnerability, please use the [Tidelift security contact](https://tidelift.com/security). +Tidelift will coordinate the fix and disclosure. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 8ac6b8c4984dcd382e54c7923325af6d226ca3df..64284b90748cb10e03e909e7c0530fc1d12a934e 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,3 +1,4 @@ +--- version: 2 updates: - package-ecosystem: "github-actions" diff --git a/.github/workflows/build-docset.yml b/.github/workflows/build-docset.yml index 1791a4dbc400401f6bb2b6ace82323645ff1f5db..0dfce69e656b90ff3a6115dd2a7f413e42380970 100644 --- a/.github/workflows/build-docset.yml +++ b/.github/workflows/build-docset.yml @@ -10,14 +10,14 @@ env: PIP_DISABLE_PIP_VERSION_CHECK: 1 PIP_NO_PYTHON_VERSION_WARNING: 1 -permissions: # added using https://github.com/step-security/secure-workflows +permissions: contents: read jobs: docset: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 # get correct version - uses: actions/setup-python@v4 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8bafdd64dc2d399bfd4cb757ae8c42acf582d705..1f01595a9bbcafbb25bf6a8cda309278e36a549b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,70 +10,52 @@ on: env: FORCE_COLOR: "1" # Make tools pretty. - TOX_TESTENV_PASSENV: FORCE_COLOR PIP_DISABLE_PIP_VERSION_CHECK: "1" PIP_NO_PYTHON_VERSION_WARNING: "1" - PYTHON_LATEST: "3.11" - # For re-actors/checkout-python-sdist - SETUPTOOLS_SCM_PRETEND_VERSION: "1.0" # hard-code version for predictable sdist names - sdist-artifact: python-package-distributions - sdist-name: structlog-1.0.tar.gz - -permissions: - contents: read +permissions: {} jobs: - build-sdist: - name: 📦 Build the source distribution + build-package: + name: Build & verify package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v4 + - uses: actions/checkout@v4 with: - python-version: ${{ env.PYTHON_LATEST }} - - run: python -Im pip install build + fetch-depth: 0 - - run: python -Im build --sdist - - - uses: actions/upload-artifact@v3 - with: - name: ${{ env.sdist-artifact }} - # NOTE: Exact expected file names are specified here - # NOTE: as a safety measure — if anything weird ends - # NOTE: up being in this dir or not all dists will be - # NOTE: produced, this will fail the workflow. - path: dist/${{ env.sdist-name }} - retention-days: 15 + - uses: hynek/build-and-inspect-python-package@v1 tests: - name: Tests on ${{ matrix.python-version }} - needs: build-sdist + name: Tests & API Mypy on ${{ matrix.python-version }} + needs: build-package runs-on: ubuntu-latest strategy: fail-fast: false matrix: python-version: - - "3.7" - "3.8" - "3.9" - "3.10" - "3.11" + - "3.12" steps: - - name: Get source code from pre-built sdist - uses: re-actors/checkout-python-sdist@release/v1 + - name: Download pre-built packages + uses: actions/download-artifact@v3 with: - source-tarball-name: ${{ env.sdist-name }} - workflow-artifact-name: ${{ env.sdist-artifact }} - + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - - run: python -Im pip install --upgrade wheel tox + allow-prereleases: true + cache: pip + - run: python -Im pip install tox - - run: python -Im tox run -f py$(echo ${{ matrix.python-version }} | tr -d .) + - run: python -Im tox run --installpkg dist/*.whl -f py$(echo ${{ matrix.python-version }} | tr -d .) - name: Upload coverage data uses: actions/upload-artifact@v3 @@ -88,19 +70,12 @@ jobs: runs-on: ubuntu-latest steps: - - name: Get source code from pre-built sdist - uses: re-actors/checkout-python-sdist@release/v1 - with: - source-tarball-name: ${{ env.sdist-name }} - workflow-artifact-name: ${{ env.sdist-artifact }} - + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: - # Use latest Python, so it understands all syntax. - python-version: ${{env.PYTHON_LATEST}} - + python-version-file: .python-version-default + cache: pip - run: python -Im pip install --upgrade coverage[toml] - - uses: actions/download-artifact@v3 with: name: coverage-data @@ -123,50 +98,67 @@ jobs: path: htmlcov if: ${{ failure() }} - mypy: - name: Mypy on ${{ matrix.python-version }} - needs: build-sdist + mypy-pkg: + name: Type-check package + needs: build-package runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - # mypy on 3.7 fails but there's nothing we can do about it - - "3.8" - - "3.9" - - "3.10" - - "3.11" steps: - - name: Get source code from pre-built sdist - uses: re-actors/checkout-python-sdist@release/v1 + - name: Download pre-built packages + uses: actions/download-artifact@v3 with: - source-tarball-name: ${{ env.sdist-name }} - workflow-artifact-name: ${{ env.sdist-artifact }} + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@v4 with: - python-version: ${{ matrix.python-version }} - - run: python -Im pip install --upgrade wheel tox + python-version-file: .python-version-default + allow-prereleases: true + cache: pip + - run: python -Im pip install tox + + - run: python -Im tox run --installpkg dist/*.whl -e mypy-pkg + + pyright: + name: Pyright + runs-on: ubuntu-latest + needs: build-package - - run: python -Im tox -e mypy + steps: + - name: Download pre-built packages + uses: actions/download-artifact@v3 + with: + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 + - uses: actions/setup-python@v4 + with: + python-version-file: .python-version-default + allow-prereleases: true + cache: pip + - run: python -Im pip install tox + + - run: python -Im tox run --installpkg dist/*.whl -e pyright docs: name: Build docs & run doctests - needs: build-sdist + needs: build-package runs-on: ubuntu-latest steps: - - name: Get source code from pre-built sdist - uses: re-actors/checkout-python-sdist@release/v1 + - name: Download pre-built packages + uses: actions/download-artifact@v3 with: - source-tarball-name: ${{ env.sdist-name }} - workflow-artifact-name: ${{ env.sdist-artifact }} + name: Packages + path: dist + - run: tar xf dist/*.tar.gz --strip-components=1 - uses: actions/setup-python@v4 with: - # Keep in sync with tox.ini/docs & readthedocs.yaml + # Keep in sync with tox.ini/docs & .readthedocs.yaml python-version: "3.11" - - run: python -Im pip install --upgrade wheel tox + cache: pip + - run: python -Im pip install tox - - run: python -Im tox -e docs + - run: python -Im tox run -e docs install-dev: name: Verify dev env @@ -176,10 +168,12 @@ jobs: os: [ubuntu-latest, windows-latest] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: - python-version: ${{env.PYTHON_LATEST}} + python-version-file: .python-version-default + cache: pip + - run: python -Im pip install -e .[dev] - run: python -Ic 'import structlog; print(structlog.__version__)' @@ -191,6 +185,8 @@ jobs: - coverage - docs - install-dev + - mypy-pkg + - pyright runs-on: ubuntu-latest @@ -203,18 +199,15 @@ jobs: colors: name: Visual check for color settings using env variables - needs: build-sdist + needs: build-package runs-on: ubuntu-latest steps: - - name: Get source code from pre-built sdist - uses: re-actors/checkout-python-sdist@release/v1 - with: - source-tarball-name: ${{ env.sdist-name }} - workflow-artifact-name: ${{ env.sdist-artifact }} + - uses: actions/checkout@v4 - uses: actions/setup-python@v4 with: - python-version: ${{env.PYTHON_LATEST}} - - run: python -Im pip install tox wheel + python-version-file: .python-version-default + cache: pip + - run: python -Im pip install tox - - run: tox -f color + - run: python -Im tox run -f color diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index c8e7cab3a24c3881b980019b66f966e4be82dcb0..c0e6a208a7a6a4dbf08043058843284fb8cd2d77 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -1,10 +1,7 @@ -name: "CodeQL" +--- +name: CodeQL on: - push: - branches: ["main"] - pull_request: - branches: ["main"] schedule: - cron: "41 3 * * 6" @@ -23,11 +20,11 @@ jobs: strategy: fail-fast: false matrix: - language: ["python"] + language: [python] steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Initialize CodeQL uses: github/codeql-action/init@v2 diff --git a/.github/workflows/pypi-package.yml b/.github/workflows/pypi-package.yml index 8a613ff061ca6198b819409fce6da8fe829a7d33..63c6b784029bee33bc2609400680865cd432937a 100644 --- a/.github/workflows/pypi-package.yml +++ b/.github/workflows/pypi-package.yml @@ -5,8 +5,6 @@ on: push: branches: [main] tags: ["*"] - pull_request: - branches: [main] release: types: - published @@ -14,15 +12,15 @@ on: permissions: contents: read + id-token: write jobs: - # Always build & lint package. build-package: name: Build & verify package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: fetch-depth: 0 @@ -46,7 +44,6 @@ jobs: - name: Upload package to Test PyPI uses: pypa/gh-action-pypi-publish@release/v1 with: - password: ${{ secrets.TEST_PYPI_API_TOKEN }} repository-url: https://test.pypi.org/legacy/ # Upload to real PyPI on GitHub Releases. @@ -66,5 +63,3 @@ jobs: - name: Upload package to PyPI uses: pypa/gh-action-pypi-publish@release/v1 - with: - password: ${{ secrets.PYPI_API_TOKEN }} diff --git a/.gitignore b/.gitignore index 89b4da126ff26551922512ec03b14a06a90f9ff0..e2980f24531d5838a32e510e20b567eba7c76647 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ htmlcov tmp structlog.docset structlog.tgz +Justfile diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 5001cb0b4fbbdfe326d6c73f0d6ab790e533c1b8..fb051c2a79372b6d0416681406b831befb38fc2b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,48 +3,37 @@ ci: autoupdate_schedule: monthly default_language_version: - python: python3.10 + # Keep in-sync with .python-version + python: python3.11 repos: - repo: https://github.com/psf/black - rev: 23.3.0 + rev: 23.9.1 hooks: - id: black - - repo: https://github.com/asottile/pyupgrade - rev: v3.3.1 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.0.292 hooks: - - id: pyupgrade - args: [--py37-plus] + - id: ruff + args: [--fix, --exit-non-zero-on-fix] - - repo: https://github.com/PyCQA/isort - rev: 5.12.0 + - repo: https://github.com/econchick/interrogate + rev: 1.5.0 hooks: - - id: isort - additional_dependencies: [toml] - - - repo: https://github.com/asottile/yesqa - rev: v1.4.0 - hooks: - - id: yesqa - - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - exclude: docs/code_examples + - id: interrogate + args: [tests] - repo: https://github.com/codespell-project/codespell - rev: v2.2.4 + rev: v2.2.6 hooks: - id: codespell args: [-L, alog] - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.4.0 + rev: v4.5.0 hooks: - id: trailing-whitespace - id: end-of-file-fixer - - id: debug-statements - id: check-toml - id: check-yaml diff --git a/.python-version-default b/.python-version-default new file mode 100644 index 0000000000000000000000000000000000000000..2c0733315e415bfb5e5b353f9996ecd964d395b2 --- /dev/null +++ b/.python-version-default @@ -0,0 +1 @@ +3.11 diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 3f7ab7ada8ceb9a96bbe625661bd0491cda07789..f8c4c2ceb73151f35a530f57aad2a0bc371b51b4 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -1,10 +1,8 @@ --- version: 2 -# PDF builds are broken. -formats: [htmlzip, epub] build: - os: ubuntu-20.04 + os: ubuntu-22.04 tools: # Keep version in sync with tox.ini/docs and ci.yml/docs python: "3.11" diff --git a/CHANGELOG.md b/CHANGELOG.md index d5f187276bfa3bdb2ea9df38c0c2983f1be475a7..baa405044e6f1c6cd6a4514b361fd0b391689fc0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,41 @@ The **first number** of the version is the year. The **second number** is incremented with each release, starting at 1 for each year. The **third number** is for emergencies when we need to start branches for older releases. -You can find out backwards-compatibility policy [here](https://github.com/hynek/structlog/blob/main/.github/SECURITY.md). +You can find our backwards-compatibility policy [here](https://github.com/hynek/structlog/blob/main/.github/SECURITY.md). <!-- changelog follows --> +## [23.2.0](https://github.com/hynek/structlog/compare/23.1.0...23.2.0) - 2023-10-09 + +### Removed + +- Support for Python 3.7. + + +### Added + +- Official support for Python 3.12. + [#515](https://github.com/hynek/structlog/issues/515) + +- `structlog.processors.MaybeTimeStamper` that only adds a timestamp if there isn't one already. + [#81](https://github.com/hynek/structlog/issues/81) + +- `structlog.dev.ConsoleRenderer` now supports renamed timestamp keys using the *timestamp_key* parameter. + [#541](https://github.com/hynek/structlog/issues/541) + +- `structlog.dev.RichTracebackFormatter` that allows to configure the traceback formatting. + [#542](https://github.com/hynek/structlog/issues/542) + + +### Fixed + +- `FilteringBoundLogger.exception()` and `FilteringBoundLogger.aexception()` now support positional argument formatting like the rest of the methods. + [#531](https://github.com/hynek/structlog/issues/531) +- `structlog.processors.format_exc_info()` and `structlog.dev.ConsoleRenderer` do not crash anymore when told to format a non-existent exception. + [#533](https://github.com/hynek/structlog/issues/533) + + ## [23.1.0](https://github.com/hynek/structlog/compare/22.3.0...23.1.0) - 2023-04-06 ### Added @@ -240,8 +270,8 @@ You can find out backwards-compatibility policy [here](https://github.com/hynek/ - `structlog.contextvars.bind_contextvars()` now returns a mapping of keys to `contextvars.Token`s, allowing you to reset values using the new `structlog.contextvars.reset_contextvars()`. [#339](https://github.com/hynek/structlog/pull/339) - Exception rendering in `structlog.dev.ConsoleLogger` is now configurable using the `exception_formatter` setting. - If either the [*Rich*](https://github.com/Textualize/rich) or the [*better-exceptions*](https://github.com/qix-/better-exceptions) package is present, *structlog* will use them for pretty-printing tracebacks. - *Rich* takes precedence over *better-exceptions* if both are present. + If either the [Rich](https://github.com/Textualize/rich) or the [*better-exceptions*](https://github.com/qix-/better-exceptions) package is present, *structlog* will use them for pretty-printing tracebacks. + Rich takes precedence over *better-exceptions* if both are present. This only works if `format_exc_info` is **absent** in the processor chain. [#330](https://github.com/hynek/structlog/pull/330), @@ -257,9 +287,9 @@ You can find out backwards-compatibility policy [here](https://github.com/hynek/ Make sure to remove `format_exc_info` from your processor chain if you configure *structlog* manually. This change is not really breaking, because the old use-case will keep working as before. However if you pass `pretty_exceptions=True` (which is the default if either `rich` or `better-exceptions` is installed), a warning will be raised and the exception will be rendered without prettification. -- All use of [*Colorama*](https://github.com/tartley/colorama) on non-Windows systems has been excised. +- All use of [Colorama](https://github.com/tartley/colorama) on non-Windows systems has been excised. Thus, colors are now enabled by default in `structlog.dev.ConsoleRenderer` on non-Windows systems. - You can keep using *Colorama* to customize colors, of course. + You can keep using Colorama to customize colors, of course. [#345](https://github.com/hynek/structlog/pull/345) @@ -368,7 +398,7 @@ You can find out backwards-compatibility policy [here](https://github.com/hynek/ - The logger created by `structlog.get_logger()` is not detected as an abstract method anymore, when attached to an abstract base class. [#229](https://github.com/hynek/structlog/issues/229) -- *Colorama* isn't initialized lazily on Windows anymore because it breaks rendering. +- Colorama isn't initialized lazily on Windows anymore because it breaks rendering. [#232](https://github.com/hynek/structlog/issues/232), [#242](https://github.com/hynek/structlog/pull/242) @@ -413,9 +443,9 @@ It has been unsupported by the Python core team for a while now and its PyPI dow ### Fixed -- `structlog.dev.ConsoleRenderer` now uses no colors by default, if *Colorama* is not available. +- `structlog.dev.ConsoleRenderer` now uses no colors by default, if Colorama is not available. [#215](https://github.com/hynek/structlog/issues/215) -- `structlog.dev.ConsoleRenderer` now initializes *Colorama* lazily, to prevent accidental side-effects just by importing *structlog*. +- `structlog.dev.ConsoleRenderer` now initializes Colorama lazily, to prevent accidental side-effects just by importing *structlog*. [#210](https://github.com/hynek/structlog/issues/210) - A best effort has been made to make as much of *structlog* pickleable as possible to make it friendlier with `multiprocessing` and similar libraries. Some classes can only be pickled on Python 3 or using the [dill](https://pypi.org/project/dill/) library though and that is very unlikely to change. diff --git a/PKG-INFO b/PKG-INFO index e1455d9b49597a224b07e3f5c7ea69f7e6bf6ad0..db414e31ccf4db8714a89100c0ca45d752dbe836 100644 --- a/PKG-INFO +++ b/PKG-INFO @@ -1,50 +1,33 @@ Metadata-Version: 2.1 Name: structlog -Version: 23.1.0 +Version: 23.2.0 Summary: Structured Logging for Python Project-URL: Documentation, https://www.structlog.org/ -Project-URL: Changelog, https://www.structlog.org/en/stable/changelog.html -Project-URL: Bug Tracker, https://github.com/hynek/structlog/issues -Project-URL: Source Code, https://github.com/hynek/structlog +Project-URL: Changelog, https://github.com/hynek/structlog/blob/main/CHANGELOG.md +Project-URL: GitHub, https://github.com/hynek/structlog Project-URL: Funding, https://github.com/sponsors/hynek -Project-URL: Tidelift, https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=pypi +Project-URL: Tidelift, https://tidelift.com?utm_source=lifter&utm_medium=referral&utm_campaign=hynek +Project-URL: Mastodon, https://mastodon.social/@hynek +Project-URL: Twitter, https://twitter.com/hynek Author-email: Hynek Schlawack <hs@ox.cx> -License: Licensed under either of - - - Apache License, Version 2.0 (LICENSE-APACHE or <https://choosealicense.com/licenses/apache/>) - - or MIT license (LICENSE-MIT or <https://choosealicense.com/licenses/mit/>) - - at your option. - - Any contribution intentionally submitted for inclusion in the work by you, as - defined in the Apache-2.0 license, shall be dual-licensed as above, without any - additional terms or conditions. +License-Expression: MIT OR Apache-2.0 License-File: LICENSE-APACHE License-File: LICENSE-MIT License-File: NOTICE Keywords: log,logging,structure,structured Classifier: Development Status :: 5 - Production/Stable -Classifier: Intended Audience :: Developers Classifier: License :: OSI Approved :: Apache Software License Classifier: License :: OSI Approved :: MIT License -Classifier: Natural Language :: English -Classifier: Operating System :: OS Independent -Classifier: Programming Language :: Python -Classifier: Programming Language :: Python :: 3 -Classifier: Programming Language :: Python :: 3.7 Classifier: Programming Language :: Python :: 3.8 Classifier: Programming Language :: Python :: 3.9 Classifier: Programming Language :: Python :: 3.10 Classifier: Programming Language :: Python :: 3.11 -Classifier: Programming Language :: Python :: Implementation :: CPython -Classifier: Programming Language :: Python :: Implementation :: PyPy -Classifier: Topic :: Software Development :: Libraries :: Python Modules +Classifier: Programming Language :: Python :: 3.12 Classifier: Topic :: System :: Logging -Requires-Python: >=3.7 -Requires-Dist: importlib-metadata; python_version < '3.8' -Requires-Dist: typing-extensions; python_version < '3.8' +Classifier: Typing :: Typed +Requires-Python: >=3.8 Provides-Extra: dev -Requires-Dist: structlog[docs,tests,typing]; extra == 'dev' +Requires-Dist: structlog[tests,typing]; extra == 'dev' Provides-Extra: docs Requires-Dist: furo; extra == 'docs' Requires-Dist: myst-parser; extra == 'docs' @@ -53,14 +36,13 @@ Requires-Dist: sphinx-notfound-page; extra == 'docs' Requires-Dist: sphinxcontrib-mermaid; extra == 'docs' Requires-Dist: twisted; extra == 'docs' Provides-Extra: tests -Requires-Dist: coverage[toml]; extra == 'tests' Requires-Dist: freezegun>=0.2.8; extra == 'tests' Requires-Dist: pretend; extra == 'tests' Requires-Dist: pytest-asyncio>=0.17; extra == 'tests' Requires-Dist: pytest>=6.0; extra == 'tests' Requires-Dist: simplejson; extra == 'tests' Provides-Extra: typing -Requires-Dist: mypy; extra == 'typing' +Requires-Dist: mypy>=1.4; extra == 'typing' Requires-Dist: rich; extra == 'typing' Requires-Dist: twisted; extra == 'typing' Description-Content-Type: text/markdown @@ -74,8 +56,8 @@ Description-Content-Type: text/markdown *structlog* is *the* production-ready logging solution for Python: -- **Simple**: At its core, everything is about **functions** that take and return **dictionaries** – all hidden behind **familiar APIs**. -- **Powerful**: Functions and dictionaries aren’t just simple, they’re also powerful. +- **Simple**: Everything is about **functions** that take and return **dictionaries** – all hidden behind **familiar APIs**. +- **Powerful**: Functions and dictionaries aren’t just simple but also powerful. *structlog* leaves *you* in control. - **Fast**: *structlog* is not hamstrung by designs of yore. Its flexibility comes not at the price of performance. @@ -97,7 +79,7 @@ Especially those generously supporting us at the *The Organization* tier and hig <img src="https://raw.githubusercontent.com/hynek/structlog/main/.github/sponsors/Variomedia.svg" width="200" height="60"></img> </a> - <a href="https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme"> + <a href="https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek"> <img src="https://raw.githubusercontent.com/hynek/structlog/main/.github/sponsors/Tidelift.svg" width="200" height="60"></img> </a> @@ -121,13 +103,11 @@ Especially those generously supporting us at the *The Organization* tier and hig *structlog* has been successfully used in production at every scale since **2013**, while embracing cutting-edge technologies like *asyncio*, context variables, or type hints as they emerged. Its paradigms proved influential enough to [help design](https://twitter.com/sirupsen/status/638330548361019392) structured logging [packages across ecosystems](https://github.com/sirupsen/logrus). -## Project Information +## Project Links -- **License**: *dual* [Apache License, version 2 **and** MIT](https://www.structlog.org/en/latest/license.html) -- **Get Help**: please use the *structlog* tag on [*Stack Overflow*](https://stackoverflow.com/questions/tagged/structlog) -- **Supported Python Versions**: 3.7 and later +- [**Get Help**](https://stackoverflow.com/questions/tagged/structlog) (use the *structlog* tag on Stack Overflow) - [**PyPI**](https://pypi.org/project/structlog/) -- [**Source Code**](https://github.com/hynek/structlog) +- [**GitHub**](https://github.com/hynek/structlog) - [**Documentation**](https://www.structlog.org/) - [**Changelog**](https://www.structlog.org/en/stable/changelog.html) - [**Third-party Extensions**](https://github.com/hynek/structlog/wiki/Third-party-Extensions) @@ -137,38 +117,42 @@ Its paradigms proved influential enough to [help design](https://twitter.com/sir Available as part of the Tidelift Subscription. -The maintainers of *structlog* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme) +The maintainers of *structlog* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek) ## Release Information +### Removed + +- Support for Python 3.7. + + ### Added -- `structlog.stdlib.BoundLogger` now has, analogously to our native logger, a full set of async log methods prefixed with an `a`: `await log.ainfo("event!")` - [#502](https://github.com/hynek/structlog/issues/502) +- Official support for Python 3.12. + [#515](https://github.com/hynek/structlog/issues/515) -- The default configuration now respects the presence of `FORCE_COLOR` (regardless of its value, unless an empty string). - This disables all heuristics whether it makes sense to use colors. - [#503](https://github.com/hynek/structlog/issues/503) +- `structlog.processors.MaybeTimeStamper` that only adds a timestamp if there isn't one already. + [#81](https://github.com/hynek/structlog/issues/81) -- The default configuration now respects the presence of [`NO_COLOR`](https://no-color.org) (regardless of its value, unless an empty string). - This disables all heuristics whether it makes sense to use colors and overrides `FORCE_COLOR`. - [#504](https://github.com/hynek/structlog/issues/504) +- `structlog.dev.ConsoleRenderer` now supports renamed timestamp keys using the *timestamp_key* parameter. + [#541](https://github.com/hynek/structlog/issues/541) +- `structlog.dev.RichTracebackFormatter` that allows to configure the traceback formatting. + [#542](https://github.com/hynek/structlog/issues/542) -### Fixed -- ConsoleRenderer now reuses the `_figure_out_exc_info` to process the `exc_info` argument like `ExceptionRenderer` does. - This prevents crashes if the actual Exception is passed for the *exc_info* argument instead of a tuple or `True`. - [#482](https://github.com/hynek/structlog/issues/482) +### Fixed -- `FilteringBoundLogger.aexception()` now extracts the exception info using `sys.exc_info()` before passing control to the asyncio executor (where original exception info is no longer available). - [#488](https://github.com/hynek/structlog/issues/488) +- `FilteringBoundLogger.exception()` and `FilteringBoundLogger.aexception()` now support positional argument formatting like the rest of the methods. + [#531](https://github.com/hynek/structlog/issues/531) +- `structlog.processors.format_exc_info()` and `structlog.dev.ConsoleRenderer` do not crash anymore when told to format a non-existent exception. + [#533](https://github.com/hynek/structlog/issues/533) --- -[Full changelog](https://www.structlog.org/en/stable/changelog.html) +[Full Changelog →](https://www.structlog.org/en/stable/changelog.html) ## Credits @@ -176,9 +160,7 @@ The maintainers of *structlog* and thousands of other packages are working with *structlog* is written and maintained by [Hynek Schlawack](https://hynek.me/). The idea of bound loggers is inspired by previous work by [Jean-Paul Calderone](https://github.com/exarkun) and [David Reid](https://github.com/dreid). -The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *structlog*’s [Tidelift subscribers](https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme), and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). - -A full list of contributors can be found in GitHub’s [overview](https://github.com/hynek/structlog/graphs/contributors). +The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *structlog*’s [Tidelift subscribers](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek), and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). The logs-loving futuristic beaver logo has been contributed by [Russell Keith-Magee](https://github.com/freakboy3742). diff --git a/README.md b/README.md index dba7dadfbd1ff73601d72ef75d339e0b4a0c3718..372e8048227decee0cf47498e6f4483f7ce0f9b3 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,12 @@ <a href="https://bestpractices.coreinfrastructure.org/projects/6560"> <img src="https://bestpractices.coreinfrastructure.org/projects/6560/badge"> </a> - <a href="https://pypi.org/project/structlog/"> - <img src="https://img.shields.io/pypi/v/structlog" alt="PyPI release" /> - </a> <a href="https://doi.org/10.5281/zenodo.7353739"> <img src="https://zenodo.org/badge/DOI/10.5281/zenodo.7353739.svg" alt="DOI"> </a> + <a href="https://pypi.org/project/structlog/"> + <img src="https://img.shields.io/pypi/pyversions/structlog.svg" alt="Supported Python versions of the current PyPI release." /> + </a> <a href="https://pepy.tech/project/structlog"> <img src="https://static.pepy.tech/personalized-badge/structlog?period=month&units=international_system&left_color=grey&right_color=blue&left_text=Downloads%20/%20Month" alt="Downloads per month" /> </a> @@ -33,8 +33,8 @@ *structlog* is *the* production-ready logging solution for Python: -- **Simple**: At its core, everything is about **functions** that take and return **dictionaries** – all hidden behind **familiar APIs**. -- **Powerful**: Functions and dictionaries aren’t just simple, they’re also powerful. +- **Simple**: Everything is about **functions** that take and return **dictionaries** – all hidden behind **familiar APIs**. +- **Powerful**: Functions and dictionaries aren’t just simple but also powerful. *structlog* leaves *you* in control. - **Fast**: *structlog* is not hamstrung by designs of yore. Its flexibility comes not at the price of performance. @@ -56,7 +56,7 @@ Especially those generously supporting us at the *The Organization* tier and hig <img src="https://raw.githubusercontent.com/hynek/structlog/main/.github/sponsors/Variomedia.svg" width="200" height="60"></img> </a> - <a href="https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme"> + <a href="https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek"> <img src="https://raw.githubusercontent.com/hynek/structlog/main/.github/sponsors/Tidelift.svg" width="200" height="60"></img> </a> @@ -86,30 +86,35 @@ A short explanation on *why* structured logging is good for you, and why *struct Once you feel inspired to try it out, check out our friendly [Getting Started tutorial](https://www.structlog.org/en/stable/getting-started.html). -If you prefer videos over reading, check out [Markus Holtermann](https://twitter.com/m_holtermann)'s DjangoCon Europe 2019 talk: [*Logging Rethought 2: The Actions of Frank Taylor Jr.*](https://www.youtube.com/watch?v=Y5eyEgyHLLo) +<!-- begin tutorials --> +For a fully-fledged zero-to-hero tutorial, check out [*A Comprehensive Guide to Python Logging with structlog*](https://betterstack.com/community/guides/logging/structlog/). +If you prefer videos over reading, check out [Markus Holtermann](https://chaos.social/@markush)'s talk *Logging Rethought 2: The Actions of Frank Taylor Jr.*: + +<p align="center"> + <a href="https://www.youtube.com/watch?v=Y5eyEgyHLLo"> + <img width="50%" src="https://img.youtube.com/vi/Y5eyEgyHLLo/maxresdefault.jpg"> + </a> +</p> +<!-- end tutorials --> ## Credits *structlog* is written and maintained by [Hynek Schlawack](https://hynek.me/). The idea of bound loggers is inspired by previous work by [Jean-Paul Calderone](https://github.com/exarkun) and [David Reid](https://github.com/dreid). -The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *structlog*’s [Tidelift subscribers](https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme), and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). - -A full list of contributors can be found in GitHub’s [overview](https://github.com/hynek/structlog/graphs/contributors). +The development is kindly supported by my employer [Variomedia AG](https://www.variomedia.de/), *structlog*’s [Tidelift subscribers](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek), and all my amazing [GitHub Sponsors](https://github.com/sponsors/hynek). The logs-loving futuristic beaver logo has been contributed by [Russell Keith-Magee](https://github.com/freakboy3742). <!-- begin-meta --> -## Project Information +## Project Links -- **License**: *dual* [Apache License, version 2 **and** MIT](https://www.structlog.org/en/latest/license.html) -- **Get Help**: please use the *structlog* tag on [*Stack Overflow*](https://stackoverflow.com/questions/tagged/structlog) -- **Supported Python Versions**: 3.7 and later +- [**Get Help**](https://stackoverflow.com/questions/tagged/structlog) (use the *structlog* tag on Stack Overflow) - [**PyPI**](https://pypi.org/project/structlog/) -- [**Source Code**](https://github.com/hynek/structlog) +- [**GitHub**](https://github.com/hynek/structlog) - [**Documentation**](https://www.structlog.org/) - [**Changelog**](https://www.structlog.org/en/stable/changelog.html) - [**Third-party Extensions**](https://github.com/hynek/structlog/wiki/Third-party-Extensions) @@ -119,4 +124,4 @@ The logs-loving futuristic beaver logo has been contributed by [Russell Keith-Ma Available as part of the Tidelift Subscription. -The maintainers of *structlog* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=referral&utm_campaign=readme) +The maintainers of *structlog* and thousands of other packages are working with Tidelift to deliver commercial support and maintenance for the open source packages you use to build your applications. Save time, reduce risk, and improve code health, while paying the maintainers of the exact packages you use. [Learn more.](https://tidelift.com/?utm_source=lifter&utm_medium=referral&utm_campaign=hynek) diff --git a/docs/api.rst b/docs/api.rst index 9b6670b0643c95fe562519cc5caaf7ba2fff7b6f..5a911533ecc62453d0893d9e4715c02031c1c875 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -79,6 +79,7 @@ API Reference :members: get_default_level_styles .. autofunction:: plain_traceback +.. autoclass:: RichTracebackFormatter .. autofunction:: rich_traceback .. autofunction:: better_traceback @@ -167,8 +168,9 @@ API Reference Please note that additionally to strings, you can also return any type the standard library JSON module knows about -- like in this example a list. - If you choose to pass a *default* parameter as part of *json_kw*, support for ``__structlog__`` is disabled. - This can be useful when used together with more elegant serialization methods like :func:`functools.singledispatch`: `Better Python Object Serialization <https://hynek.me/articles/serialization/>`_. + If you choose to pass a *default* parameter as part of *dumps_kw*, support for ``__structlog__`` is disabled. + That can be useful with more elegant serialization methods like `functools.singledispatch`: `Better Python Object Serialization <https://hynek.me/articles/serialization/>`_. + It can also be helpful if you are using *orjson* and want to rely on it to serialize `datetime.datetime` and other objects natively. .. tip:: @@ -261,6 +263,16 @@ API Reference >>> TimeStamper(fmt="%Y", key="year")(None, None, {}) # doctest: +SKIP {'year': '2013'} +.. autoclass:: MaybeTimeStamper + + .. doctest:: + + >>> from structlog.processors import MaybeTimeStamper + >>> MaybeTimeStamper()(None, None, {}) # doctest: +SKIP + {'timestamp': 1690036074.494428} + >>> MaybeTimeStamper()(None, None, {"timestamp": 42}) + {'timestamp': 42} + .. autoclass:: CallsiteParameter :members: @@ -280,6 +292,7 @@ API Reference :members: bind, unbind, try_unbind, new, debug, info, warning, warn, error, critical, exception, log, adebug, ainfo, awarning, aerror, acritical, aexception, alog .. autoclass:: AsyncBoundLogger + :members: sync_bl .. autoclass:: LoggerFactory :members: __call__ diff --git a/docs/changelog.md b/docs/changelog.md deleted file mode 100644 index 66efc0fecd1554e6e4681fcb149692591f388bf2..0000000000000000000000000000000000000000 --- a/docs/changelog.md +++ /dev/null @@ -1,2 +0,0 @@ -```{include} ../CHANGELOG.md -``` diff --git a/docs/conf.py b/docs/conf.py index 2f7a6a7257f9f27a1e9526d6632e4f80f92fd263..5948b03d4aff0e14e103fded1d44ff20034004f5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -17,6 +17,7 @@ extensions = [ "notfound.extension", "sphinx.ext.autodoc", "sphinx.ext.autodoc.typehints", + "sphinx.ext.napoleon", "sphinx.ext.doctest", "sphinx.ext.intersphinx", "sphinx.ext.viewcode", @@ -28,6 +29,7 @@ myst_enable_extensions = [ "smartquotes", "deflist", ] +mermaid_init_js = "mermaid.initialize({startOnLoad:true,theme:'neutral'});" # Add any paths that contain templates here, relative to this directory. templates_path = ["_templates"] @@ -76,10 +78,6 @@ nitpick_ignore = [ # If true, '()' will be appended to :func: etc. cross-reference text. add_function_parentheses = True -# If true, the current module name will be prepended to all description -# unit titles (such as .. function::). -# add_module_names = True - # Move type hints into the description block, instead of the func definition. autodoc_typehints = "description" autodoc_typehints_description_target = "documented" @@ -162,4 +160,7 @@ linkcheck_ignore = [ # Twisted's trac tends to be slow linkcheck_timeout = 300 -intersphinx_mapping = {"python": ("https://docs.python.org/3", None)} +intersphinx_mapping = { + "python": ("https://docs.python.org/3", None), + "rich": ("https://rich.readthedocs.io/en/stable/", None), +} diff --git a/docs/configuration.md b/docs/configuration.md index 272ffcbbc24531c30849c52e443f8cd4f73cebc7..16820042f4eca1ff207d76887bb6569345686f80 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -67,7 +67,7 @@ Whenever you bind or unbind data to a *bound logger*, this class is instantiated ### Logger Factories -We've already talked about wrapped loggers responsible for the output, but until now we haven't explained where they come from until now. +We've already talked about wrapped loggers responsible for the output, but we haven't explained where they come from until now. Unlike with *bound loggers*, you often need more flexibility when instantiating them. Therefore you don't configure a class; you configure a *factory* using the `logger_factory` keyword. diff --git a/docs/console-output.md b/docs/console-output.md index fe3905ddb0cc71b22093b6167321cab61e6c339e..aa5fbb6043959a90540d6644740089337577acb4 100644 --- a/docs/console-output.md +++ b/docs/console-output.md @@ -4,12 +4,12 @@ To make development a more pleasurable experience, *structlog* comes with the {m The highlight is {class}`structlog.dev.ConsoleRenderer` that offers nicely aligned and colorful[^win] console output. -[^win]: Requires the [*Colorama* package](https://pypi.org/project/colorama/) on Windows. +[^win]: Requires the [Colorama package](https://pypi.org/project/colorama/) on Windows. -If either of the [*Rich*](https://rich.readthedocs.io/) or [*better-exceptions*](https://github.com/Qix-/better-exceptions) packages is installed, it will also pretty-print exceptions with helpful contextual data. -*Rich* takes precedence over *better-exceptions*, but you can configure it by passing {func}`structlog.dev.plain_traceback` or {func}`structlog.dev.better_traceback` for the `exception_formatter` parameter of {class}`~structlog.dev.ConsoleRenderer`. +If either of the [Rich](https://rich.readthedocs.io/) or [*better-exceptions*](https://github.com/Qix-/better-exceptions) packages is installed, it will also pretty-print exceptions with helpful contextual data. +Rich takes precedence over *better-exceptions*, but you can configure it by passing {func}`structlog.dev.plain_traceback` or {func}`structlog.dev.better_traceback` for the `exception_formatter` parameter of {class}`~structlog.dev.ConsoleRenderer`. -The following output is rendered using *Rich*: +The following output is rendered using Rich: ```{figure} _static/console_renderer.png Colorful console output by ConsoleRenderer. @@ -24,7 +24,7 @@ It will recognize logger names, log levels, time stamps, stack infos, and `exc_i For pretty exceptions to work, {func}`~structlog.processors.format_exc_info` must be **absent** from the processors chain. ::: -*structlog*'s default configuration already uses {class}`~structlog.dev.ConsoleRenderer`, therefore if you want nice colorful output on the console, you don't have to do anything except installing *Rich* or *better-exceptions* (and *Colorama* on Windows). +*structlog*'s default configuration already uses {class}`~structlog.dev.ConsoleRenderer`, therefore if you want nice colorful output on the console, you don't have to do anything except installing Rich or *better-exceptions* (and Colorama on Windows). If you want to use it along with standard library logging, we suggest the following configuration: ```python @@ -46,6 +46,11 @@ structlog.configure( ) ``` +:::{seealso} +{doc}`exceptions` for more information on how to configure exception rendering. +For the console and beyond. +::: + ## Standard Environment Variables @@ -60,4 +65,4 @@ It's possible to override this behavior by setting two standard environment vari ## Disabling Exception Pretty-Printing -If you prefer the default terse Exception rendering, but still want *Rich* installed, you can disable the pretty-printing by instantiating {class}`structlog.dev.ConsoleRenderer()` yourself and passing `exception_formatter=structlog.dev.plain_traceback`. +If you prefer the default terse Exception rendering, but still want Rich installed, you can disable the pretty-printing by instantiating {class}`structlog.dev.ConsoleRenderer()` yourself and passing `exception_formatter=structlog.dev.plain_traceback`. diff --git a/docs/contextvars.md b/docs/contextvars.md index 59fb4ba8b876fe273e199e57c27c1e64740d69f2..07d25de13b6f1b60ef6f3ee24f35fa4fbaefa7af 100644 --- a/docs/contextvars.md +++ b/docs/contextvars.md @@ -12,13 +12,19 @@ structlog.reset_defaults() ``` The {mod}`contextvars` module in the Python standard library allows having a global *structlog* context that is local to the current execution context. -The execution context can be thread-local if using threads, or using primitives based on {mod}`asyncio`, or [*greenlet*](https://greenlet.readthedocs.io/) respectively. +The execution context can be thread-local if using threads, stored in the {mod}`asyncio` event loop, or [*greenlet*](https://greenlet.readthedocs.io/) respectively. For example, you may want to bind certain values like a request ID or the peer's IP address at the beginning of a web request and have them logged out along with the local contexts you build within our views. For that *structlog* provides the {mod}`structlog.contextvars` module with a set of functions to bind variables to a context-local context. This context is safe to be used both in threaded as well as asynchronous code. +:::{warning} +Since the storage mechanics of your context variables is different for each concurrency method, they are _isolated_ from each other. + +This can be a problem in hybrid applications like those based on [*starlette*](https://www.starlette.io) (this [includes FastAPI](https://github.com/tiangolo/fastapi/discussions/5999)) where context variables set in a synchronous context don't appear in logs from an async context and vice versa. +::: + The general flow is: - Use {func}`structlog.configure` with {func}`structlog.contextvars.merge_contextvars` as your first processor (part of default configuration). diff --git a/docs/exceptions.md b/docs/exceptions.md new file mode 100644 index 0000000000000000000000000000000000000000..329fe3b5fbcfc30715e3ef836d1e0400ce57d6ed --- /dev/null +++ b/docs/exceptions.md @@ -0,0 +1,55 @@ +# Exceptions + +While you should use a proper crash reporter like our sponsor [Sentry](https://sentry.io) in production, *structlog* has helpers for formatting exceptions for humans and machines. + +All *structog*'s exception features center around passing an `exc_info` key-value pair in the event dict. +There are three possible behaviors depending on its value: + +1. If the value is a tuple, render it as if it was returned by {func}`sys.exc_info`. +2. If the value is an Exception, render it. +3. If the value is true but no tuple, call {func}`sys.exc_info` and render that. + +If there is no `exc_info` key or false, the event dict is not touched. +This behavior is analog to the one of the stdlib's logging. + + +## Transformations + +*structlog* comes with {class}`structlog.processors.ExceptionRenderer` that deduces and removes the `exc_info` key as outlined above, calls a user-supplied function with the synthesized `exc_info`, and stores its return value in the `exception` key. +The most common use-cases are already covered by the following processors: + +{func}`structlog.processors.format_exc_info` + +: Formats it to a flat string like the standard library would on the console. + +{obj}`structlog.processors.dict_tracebacks` + +: Uses {class}`structlog.tracebacks.ExceptionDictTransformer` to give you a structured and JSON-serializable `exception` key. + + +## Console Rendering + +Our {doc}`console-output`'s {class}`structlog.dev.ConsoleRenderer` takes an *exception_formatter* argument that allows for customizing the output of exceptions. + +{func}`structlog.dev.plain_traceback` + +: Is the default if neither [Rich] nor [*better-exceptions*] are installed. + As the name suggests, it renders a plain traceback. + +{func}`structlog.dev.better_traceback` + +: Uses [*better-exceptions*] to render a colorful traceback. +: It's the default if *better-exceptions* is installed and Rich is not. + +{class}`structlog.dev.RichTracebackFormatter` + +: Uses [Rich] to render a colorful traceback. + It's a class because it allows for customizing the output by passing arguments to Rich. +: It's the default if Rich is installed. + +:::{seealso} +{doc}`console-output` for more information on *structlog*'s console features. +::: + +[*better-exceptions*]: https://github.com/qix-/better-exceptions +[Rich]: https://github.com/Textualize/rich diff --git a/docs/getting-started.md b/docs/getting-started.md index 7f770a99651ac9e8205823c98522b1447e7be5ad..8059b86c83b44d0964b504d48b7648fce2013cba 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -10,10 +10,10 @@ You can install *structlog* from [PyPI](https://pypi.org/project/structlog/) usi $ python -m pip install structlog ``` -If you want pretty exceptions in development (you know you do!), additionally install either [*Rich*] or [*better-exceptions*]. -Try both to find out which one you like better -- the screenshot in the README and docs homepage is rendered by *Rich*. +If you want pretty exceptions in development (you know you do!), additionally install either [Rich] or [*better-exceptions*]. +Try both to find out which one you like better -- the screenshot in the README and docs homepage is rendered by Rich. -On **Windows**, you also have to install [*Colorama*](https://pypi.org/project/colorama/) if you want colorful output beside exceptions. +On **Windows**, you also have to install [Colorama](https://pypi.org/project/colorama/) if you want colorful output beside exceptions. ## Your First Log Entry @@ -30,7 +30,7 @@ As a result, the simplest possible usage looks like this: Here, *structlog* takes advantage of its default settings: -- Output is sent to **[standard out](https://en.wikipedia.org/wiki/Standard_out#Standard_output_.28stdout.29)** instead doing nothing. +- Output is sent to **[standard out](https://en.wikipedia.org/wiki/Standard_out#Standard_output_.28stdout.29)** instead of doing nothing. - It **imitates** standard library {mod}`logging`'s **log level names** for familiarity. By default, no level-based filtering is done, but it comes with a **very fast [filtering machinery](filtering)**. - Like in `logging`, positional arguments are [**interpolated into the message string using %**](https://docs.python.org/3/library/stdtypes.html#old-string-formatting). @@ -39,7 +39,7 @@ Here, *structlog* takes advantage of its default settings: - All keywords are formatted using {class}`structlog.dev.ConsoleRenderer`. That in turn uses {func}`repr` to serialize **any value to a string**. - It's rendered in nice **{doc}`colors <console-output>`**. -- If you have [*Rich*] or [*better-exceptions*] installed, **exceptions** will be rendered in **colors** and with additional **helpful information**. +- If you have [Rich] or [*better-exceptions*] installed, **exceptions** will be rendered in **colors** and with additional **helpful information**. Please note that even in most complex logging setups the example would still look just like that thanks to {doc}`configuration`. Using the defaults, as above, is equivalent to: @@ -54,7 +54,7 @@ structlog.configure( structlog.processors.add_log_level, structlog.processors.StackInfoRenderer(), structlog.dev.set_exc_info, - structlog.processors.TimeStamper(), + structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S", utc=False), structlog.dev.ConsoleRenderer() ], wrapper_class=structlog.make_filtering_bound_logger(logging.NOTSET), @@ -246,7 +246,11 @@ You can use the sync and async logging methods interchangeably within the same a Now you're all set for the rest of the user's guide and can start reading about [bound loggers](bound-loggers.md) -- the heart of *structlog*. +```{include} ../README.md +:start-after: <!-- begin tutorials --> +:end-before: <!-- end tutorials --> +``` [*better-exceptions*]: https://github.com/qix-/better-exceptions [recipe]: https://docs.python.org/3/howto/logging-cookbook.html#implementing-structured-logging -[*Rich*]: https://github.com/Textualize/rich +[Rich]: https://github.com/Textualize/rich diff --git a/docs/index.md b/docs/index.md index e3cdec7aad1e81970ccdd05e538990552920d27c..a5baa8f174cfa96baafe5a38920498d35ba1ec27 100644 --- a/docs/index.md +++ b/docs/index.md @@ -2,7 +2,7 @@ *Simple. Powerful. Fast. Pick three.* -Release **{sub-ref}`release`** ([What's new?](changelog)) +Release **{sub-ref}`release`** ([What's new?](https://github.com/hynek/structlog/blob/main/CHANGELOG.md)) --- @@ -37,6 +37,7 @@ bound-loggers configuration processors contextvars +exceptions ``` @@ -100,10 +101,10 @@ api ``` -## Project Information +## Project Links ```{include} ../README.md -:start-after: "## Project Information" +:start-after: "## Project Links" ``` % stop Sphinx from complaining about orphaned docs, we link them elsewhere @@ -112,7 +113,6 @@ api :hidden: true license -changelog ``` diff --git a/docs/recipes.md b/docs/recipes.md index 05ebbfdccc604654d31d5c44a479fb6e35d6c193..b990ecc47b75c53cac2f6057fa1bcff1b9a1979b 100644 --- a/docs/recipes.md +++ b/docs/recipes.md @@ -1,7 +1,7 @@ # Recipes -Thanks to the fact that *structlog* is entirely based on dictionaries and callables, the sky is the limit with what you an achieve. -In the beginning that can be daunting, so here are a few examples of tasks that have come up repeatedly. +Because *structlog* is entirely based on dictionaries and callables, the sky is the limit with what you can achieve. +That can be daunting in the beginning, so here are a few examples of tasks that have come up repeatedly. Please note that recipes related to integration with frameworks have an [own chapter](frameworks.md). @@ -12,7 +12,7 @@ Please note that recipes related to integration with frameworks have an [own cha The name of the event is hard-coded in *structlog* to `event`. But that doesn't mean it has to be called that in your logs. -With the {class}`structlog.processors.EventRenamer` processor you can for instance rename the log message to `msg` and use `event` for something custom, that you bind to `_event` in your code: +With the {class}`structlog.processors.EventRenamer` processor, you can, for instance, rename the log message to `msg` and use `event` for something custom, that you bind to `_event` in your code: ```pycon >>> from structlog.processors import EventRenamer @@ -100,9 +100,9 @@ That class still ships with *structlog* and can wrap *any* logger class by inter Nowadays, the default is a {class}`structlog.typing.FilteringBoundLogger` that imitates standard library’s log levels with the possibility of efficiently filtering at a certain level (inactive log methods are a plain `return None` each). -If you’re integrating with {mod}`logging` or Twisted, you may was to use one of their specific *bound loggers* ({class}`structlog.stdlib.BoundLogger` and {class}`structlog.twisted.BoundLogger`, respectively). +If you’re integrating with {mod}`logging` or Twisted, you may want to use one of their specific *bound loggers* ({class}`structlog.stdlib.BoundLogger` and {class}`structlog.twisted.BoundLogger`, respectively). -— +--- On top of that all, you can also write your own wrapper classes. To make it easy for you, *structlog* comes with the class {class}`structlog.BoundLoggerBase` which takes care of all data binding duties so you just add your log methods if you choose to sub-class it. @@ -186,3 +186,12 @@ def manager(request_id: str): ``` See the [issue 425](https://github.com/hynek/structlog/issues/425) for a more complete example. + + +## Switching Console Output to Standard Error + +When using structlog without standard library integration and want the log output to go to standard error (*stderr*) instead of standard out (*stdout*), you can switch with a single line of configuration: + +```python +structlog.configure(logger_factory=structlog.PrintLoggerFactory(sys.stderr)) +``` diff --git a/docs/standard-library.md b/docs/standard-library.md index 6339db8dc2ec114ee2a7263b02246e43cd5c9157..37f83295d842207d322f4d44b8be72984b5d1dc4 100644 --- a/docs/standard-library.md +++ b/docs/standard-library.md @@ -154,7 +154,6 @@ Chances are, this is all you need. :align: center flowchart TD - %%{ init: {'theme': 'neutral'} }%% User structlog stdlib[Standard Library\ne.g. logging.StreamHandler] @@ -190,7 +189,7 @@ structlog.configure( # sys.exc_info() tuple, remove "exc_info" and render the exception # with traceback into the "exception" key. structlog.processors.format_exc_info, - # If some value is in bytes, decode it to a unicode str. + # If some value is in bytes, decode it to a Unicode str. structlog.processors.UnicodeDecoder(), # Add callsite parameters. structlog.processors.CallsiteParameterAdder( @@ -249,7 +248,6 @@ You can choose to use *structlog* only for building the event dictionary and lea :align: center flowchart TD - %%{ init: {'theme': 'neutral'} }%% User structlog stdlib[Standard Library\ne.g. logging.StreamHandler] @@ -331,7 +329,6 @@ Consequently, the output is the duty of the standard library too. :align: center flowchart TD - %%{ init: {'theme': 'neutral'} }%% User structlog structlog2[structlog] @@ -424,10 +421,10 @@ formatter = structlog.stdlib.ProcessorFormatter( foreign_pre_chain=shared_processors, # These run on ALL entries after the pre_chain is done. processors=[ - # Remove _record & _from_structlog. - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - structlog.dev.ConsoleRenderer(), - ], + # Remove _record & _from_structlog. + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer(), + ], ) ``` @@ -459,6 +456,7 @@ pre_chain = [ timestamper, ] + def extract_from_record(_, __, event_dict): """ Extract thread and process names and add them to the event dict. @@ -466,27 +464,28 @@ def extract_from_record(_, __, event_dict): record = event_dict["_record"] event_dict["thread_name"] = record.threadName event_dict["process_name"] = record.processName - return event_dict -logging.config.dictConfig({ + +logging.config.dictConfig( + { "version": 1, "disable_existing_loggers": False, "formatters": { "plain": { "()": structlog.stdlib.ProcessorFormatter, "processors": [ - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - structlog.dev.ConsoleRenderer(colors=False), + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer(colors=False), ], "foreign_pre_chain": pre_chain, }, "colored": { "()": structlog.stdlib.ProcessorFormatter, "processors": [ - extract_from_record, - structlog.stdlib.ProcessorFormatter.remove_processors_meta, - structlog.dev.ConsoleRenderer(colors=True), + extract_from_record, + structlog.stdlib.ProcessorFormatter.remove_processors_meta, + structlog.dev.ConsoleRenderer(colors=True), ], "foreign_pre_chain": pre_chain, }, @@ -510,8 +509,9 @@ logging.config.dictConfig({ "level": "DEBUG", "propagate": True, }, - } -}) + }, + } +) structlog.configure( processors=[ structlog.stdlib.add_log_level, diff --git a/pyproject.toml b/pyproject.toml index 18ebb192db3035c49c4c4a35f1faeed31c99bc9e..ec7cf12db2a54a05cde1f77527722f384b4c3e7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,45 +10,34 @@ dynamic = ["readme", "version"] name = "structlog" description = "Structured Logging for Python" authors = [{ name = "Hynek Schlawack", email = "hs@ox.cx" }] -requires-python = ">=3.7" -license = { file = "COPYRIGHT" } +requires-python = ">=3.8" +license = "MIT OR Apache-2.0" keywords = ["logging", "structured", "structure", "log"] classifiers = [ "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", "License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: MIT License", - "Natural Language :: English", - "Operating System :: OS Independent", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: Implementation :: CPython", - "Programming Language :: Python :: Implementation :: PyPy", - "Programming Language :: Python", - "Topic :: Software Development :: Libraries :: Python Modules", + "Programming Language :: Python :: 3.12", "Topic :: System :: Logging", + "Typing :: Typed", ] -dependencies = [ - "typing-extensions; python_version<'3.8'", - "importlib_metadata; python_version<'3.8'", -] +dependencies = [] [project.urls] Documentation = "https://www.structlog.org/" -Changelog = "https://www.structlog.org/en/stable/changelog.html" -"Bug Tracker" = "https://github.com/hynek/structlog/issues" -"Source Code" = "https://github.com/hynek/structlog" +Changelog = "https://github.com/hynek/structlog/blob/main/CHANGELOG.md" +GitHub = "https://github.com/hynek/structlog" Funding = "https://github.com/sponsors/hynek" -Tidelift = "https://tidelift.com/subscription/pkg/pypi-structlog?utm_source=pypi-structlog&utm_medium=pypi" - +Tidelift = "https://tidelift.com?utm_source=lifter&utm_medium=referral&utm_campaign=hynek" +Mastodon = "https://mastodon.social/@hynek" +Twitter = "https://twitter.com/hynek" [project.optional-dependencies] tests = [ - "coverage[toml]", "freezegun>=0.2.8", "pretend", "pytest-asyncio>=0.17", @@ -57,7 +46,7 @@ tests = [ ] # Need Twisted & Rich for stubs. # Otherwise mypy fails in tox. -typing = ["mypy", "rich", "twisted"] +typing = ["mypy>=1.4", "rich", "twisted"] docs = [ "furo", "myst-parser", @@ -66,7 +55,7 @@ docs = [ "sphinxcontrib-mermaid", "twisted", ] -dev = ["structlog[tests,typing,docs]"] +dev = ["structlog[tests,typing]"] [tool.hatch.version] @@ -112,12 +101,66 @@ exclude_lines = [ ] +[tool.interrogate] +omit-covered-files = true +verbose = 2 +fail-under = 100 +whitelist-regex = ["test_.*"] + + [tool.black] line-length = 79 -[tool.isort] -profile = "attrs" +[tool.ruff] +src = ["src", "tests"] +select = ["ALL"] +ignore = [ + "A", # shadowing is fine + "ANN", # Mypy is better at this + "ARG", # unused arguments are common w/ interfaces + "C901", # sometimes you trade complexity for performance + "COM", # Black takes care of our commas + "D", # We prefer our own docstring style. + "E501", # leave line-length enforcement to Black + "EM101", # simple strings are fine + "FBT", # bools are our friends + "FIX", # Yes, we want XXX as a marker. + "INP001", # sometimes we want Python files outside of packages + "N802", # some names are non-pep8 due to stdlib logging / Twisted + "N803", # ditto + "N806", # ditto + "PLR0913", # leave complexity to me + "PLR2004", # numbers are sometimes fine + "PLW2901", # overwriting a loop var can be useful + "RUF001", # leave my smart characters alone + "RUF001", # leave my smart characters alone + "SLF001", # private members are accessed by friendly functions + "T201", # prints are fine + "TCH", # TYPE_CHECKING blocks break autodocs + "TD", # we don't follow other people's todo style + "TRY003", # simple strings are fine + "TRY004", # too many false negatives + "TRY300", # else blocks are nice, but code-locality is nicer + "PTH", # pathlib can be slow, so no point to rewrite +] + +[tool.ruff.per-file-ignores] +"tests/*" = [ + "B018", # "useless" expressions can be useful in tests + "BLE", # tests have different rules around exceptions + "EM", # tests have different rules around exceptions + "PLC1901", # empty strings are falsey, but are less specific in tests + "PT005", # we always add underscores and explicit name + "RUF012", # no type hints in tests + "S", # it's test; chill out security + "SIM300", # Yoda rocks in tests + "TRY", # tests have different rules around exceptions +] + +[tool.ruff.isort] +lines-between-types = 1 +lines-after-imports = 2 [tool.mypy] @@ -133,6 +176,10 @@ warn_return_any = false module = "tests.*" ignore_errors = true +[[tool.mypy.overrides]] +module = "tests.typing.*" +ignore_errors = false + [tool.hatch.metadata.hooks.fancy-pypi-readme] content-type = "text/markdown" @@ -172,7 +219,7 @@ pattern = "\n(###.+?\n)## " text = """ --- -[Full changelog](https://www.structlog.org/en/stable/changelog.html) +[Full Changelog →](https://www.structlog.org/en/stable/changelog.html) """ diff --git a/src/structlog/__init__.py b/src/structlog/__init__.py index 52c55d328704753b46c0f14bca0154cd3f0a9c85..2ffe2c8657bf9bde9d875bc36fb70650f2e4f3ab 100644 --- a/src/structlog/__init__.py +++ b/src/structlog/__init__.py @@ -92,30 +92,30 @@ __all__ = [ def __getattr__(name: str) -> str: + import warnings + + from importlib.metadata import metadata, version + dunder_to_metadata = { - "__version__": "version", "__description__": "summary", "__uri__": "", "__email__": "", + "__version__": "", } - if name not in dunder_to_metadata.keys(): - raise AttributeError(f"module {__name__} has no attribute {name}") - - import sys - import warnings - - if sys.version_info < (3, 8): - from importlib_metadata import metadata + if name not in dunder_to_metadata: + msg = f"module {__name__} has no attribute {name}" + raise AttributeError(msg) + + if name != "__version__": + warnings.warn( + f"Accessing structlog.{name} is deprecated and will be " + "removed in a future release. Use importlib.metadata directly " + "to query for structlog's packaging metadata.", + DeprecationWarning, + stacklevel=2, + ) else: - from importlib.metadata import metadata - - warnings.warn( - f"Accessing structlog.{name} is deprecated and will be " - "removed in a future release. Use importlib.metadata directly " - "to query for structlog's packaging metadata.", - DeprecationWarning, - stacklevel=2, - ) + return version("structlog") meta = metadata("structlog") diff --git a/src/structlog/_base.py b/src/structlog/_base.py index 3b865fda77bccb58fffc5e287df417f17368ec5c..9fc6e7cec1b762ecfa703e58eb9420780786c8d8 100644 --- a/src/structlog/_base.py +++ b/src/structlog/_base.py @@ -55,13 +55,13 @@ class BoundLoggerBase: self.__class__.__name__, self._context, self._processors ) - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: try: - return self._context == other._context + return self._context == other._context # type: ignore[attr-defined] except AttributeError: return False - def __ne__(self, other: Any) -> bool: + def __ne__(self, other: object) -> bool: return not self.__eq__(other) def bind(self, **new_values: Any) -> BoundLoggerBase: @@ -78,7 +78,9 @@ class BoundLoggerBase: """ Return a new logger with *keys* removed from the context. - :raises KeyError: If the key is not part of the context. + Raises: + + KeyError: If the key is not part of the context. """ bl = self.bind() for key in keys: @@ -100,7 +102,7 @@ class BoundLoggerBase: def new(self, **new_values: Any) -> BoundLoggerBase: """ - Clear context and binds *initial_values* using `bind`. + Clear context and binds *new_values* using `bind`. Only necessary with dict implementations that keep global state like those wrapped by `structlog.threadlocal.wrap_dict` when threads @@ -121,19 +123,29 @@ class BoundLoggerBase: Call it to combine your *event* and *context* into an event_dict and process using the processor chain. - :param method_name: The name of the logger method. Is passed into - the processors. - :param event: The event -- usually the first positional argument to a - logger. - :param event_kw: Additional event keywords. For example if someone - calls ``log.info("foo", bar=42)``, *event* would to be ``"foo"`` - and *event_kw* ``{"bar": 42}``. + Arguments: + + method_name: + The name of the logger method. Is passed into the processors. + + event: + The event -- usually the first positional argument to a logger. + + event_kw: + Additional event keywords. For example if someone calls + ``log.info("foo", bar=42)``, *event* would to be ``"foo"`` and + *event_kw* ``{"bar": 42}``. + + Raises: + + structlog.DropEvent: if log entry should be dropped. - :raises: `structlog.DropEvent` if log entry should be dropped. - :raises: `ValueError` if the final processor doesn't return a - str, bytes, bytearray, tuple, or a dict. + ValueError: + if the final processor doesn't return a str, bytes, bytearray, + tuple, or a dict. - :returns: `tuple` of ``(*args, **kw)`` + Returns: + `tuple` of ``(*args, **kw)`` .. note:: @@ -169,11 +181,11 @@ class BoundLoggerBase: if isinstance(event_dict, dict): return (), event_dict - raise ValueError( - "Last processor didn't return an appropriate value. Valid " - "return values are a dict, a tuple of (args, kwargs), bytes, " - "or a str." + msg = ( + "Last processor didn't return an appropriate value. " + "Valid return values are a dict, a tuple of (args, kwargs), bytes, or a str." ) + raise ValueError(msg) def _proxy_to_logger( self, method_name: str, event: str | None = None, **event_kw: Any @@ -185,14 +197,20 @@ class BoundLoggerBase: handling :exc:`structlog.DropEvent`, and finally calls *method_name* on :attr:`_logger` with the result. - :param method_name: The name of the method that's going to get - called. Technically it should be identical to the method the - user called because it also get passed into processors. - :param event: The event -- usually the first positional argument to a - logger. - :param event_kw: Additional event keywords. For example if someone - calls ``log.info("foo", bar=42)``, *event* would to be ``"foo"`` - and *event_kw* ``{"bar": 42}``. + Arguments: + + method_name: + The name of the method that's going to get called. Technically + it should be identical to the method the user called because it + also get passed into processors. + + event: + The event -- usually the first positional argument to a logger. + + event_kw: + Additional event keywords. For example if someone calls + ``log.info("foo", bar=42)``, *event* would to be ``"foo"`` and + *event_kw* ``{"bar": 42}``. .. note:: @@ -214,12 +232,15 @@ def get_context(bound_logger: BindableLogger) -> Context: The type of *bound_logger* and the type returned depend on your configuration. - :param bound_logger: The bound logger whose context you want. + Arguments: + + bound_logger: The bound logger whose context you want. + + Returns: - :returns: The *actual* context from *bound_logger*. It is *not* copied - first. + The *actual* context from *bound_logger*. It is *not* copied first. - .. versionadded:: 20.2 + .. versionadded:: 20.2.0 """ # This probably will get more complicated in the future. return bound_logger._context diff --git a/src/structlog/_config.py b/src/structlog/_config.py index 2eb5c4e0f7db1caf954f46eedd1886adf48f3548..718bcfb62792df32cb8f71985a40bc4e7a612afc 100644 --- a/src/structlog/_config.py +++ b/src/structlog/_config.py @@ -24,10 +24,10 @@ from .typing import BindableLogger, Context, Processor, WrappedLogger """ - Any changes to these defaults must be reflected in: +Any changes to these defaults must be reflected in: - - `getting-started`. - - structlog.stdlib.recreate_defaults()'s docstring. +- `getting-started`. +- structlog.stdlib.recreate_defaults()'s docstring. """ _BUILTIN_DEFAULT_PROCESSORS: Sequence[Processor] = [ merge_contextvars, @@ -81,7 +81,7 @@ def is_configured() -> bool: If `False`, *structlog* is running with builtin defaults. - .. versionadded: 18.1 + .. versionadded: 18.1.0 """ return _CONFIG.is_configured @@ -94,7 +94,7 @@ def get_config() -> dict[str, Any]: Changes to the returned dictionary do *not* affect *structlog*. - .. versionadded: 18.1 + .. versionadded: 18.1.0 """ return { "processors": _CONFIG.default_processors, @@ -114,12 +114,18 @@ def get_logger(*args: Any, **initial_values: Any) -> Any: >>> log.info("hello", x=42) y=23 x=42 event='hello' - :param args: *Optional* positional arguments that are passed unmodified to - the logger factory. Therefore it depends on the factory what they - mean. - :param initial_values: Values that are used to pre-populate your contexts. + Arguments: - :returns: A proxy that creates a correctly configured bound logger when + args: + *Optional* positional arguments that are passed unmodified to the + logger factory. Therefore it depends on the factory what they + mean. + + initial_values: Values that are used to pre-populate your contexts. + + Returns: + + A proxy that creates a correctly configured bound logger when necessary. The type of that bound logger depends on your configuration and is `structlog.BoundLogger` by default. @@ -128,13 +134,12 @@ def get_logger(*args: Any, **initial_values: Any) -> Any: If you prefer CamelCase, there's an alias for your reading pleasure: `structlog.getLogger`. - .. versionadded:: 0.4.0 - *args* + .. versionadded:: 0.4.0 *args* """ return wrap_logger(None, logger_factory_args=args, **initial_values) -getLogger = get_logger +getLogger = get_logger # noqa: N816 """ CamelCase alias for `structlog.get_logger`. @@ -144,7 +149,7 @@ stick out like a sore thumb in frameworks like Twisted or Zope. def wrap_logger( - logger: WrappedLogger, + logger: WrappedLogger | None, processors: Iterable[Processor] | None = None, wrapper_class: type[BindableLogger] | None = None, context_class: type[Context] | None = None, @@ -158,23 +163,28 @@ def wrap_logger( Default values for *processors*, *wrapper_class*, and *context_class* can be set using `configure`. - If you set an attribute here, `configure` calls have *no* effect for - the *respective* attribute. + If you set an attribute here, `configure` calls have *no* effect for the + *respective* attribute. In other words: selective overwriting of the defaults while keeping some *is* possible. - :param initial_values: Values that are used to pre-populate your contexts. - :param logger_factory_args: Values that are passed unmodified as - ``*logger_factory_args`` to the logger factory if not `None`. + Arguments: + + initial_values: Values that are used to pre-populate your contexts. - :returns: A proxy that creates a correctly configured bound logger when + logger_factory_args: + Values that are passed unmodified as ``*logger_factory_args`` to + the logger factory if not `None`. + + Returns: + + A proxy that creates a correctly configured bound logger when necessary. See `configure` for the meaning of the rest of the arguments. - .. versionadded:: 0.4.0 - *logger_factory_args* + .. versionadded:: 0.4.0 *logger_factory_args* """ return BoundLoggerLazyProxy( logger, @@ -207,22 +217,29 @@ def configure( Use `reset_defaults` to undo your changes. - :param processors: The processor chain. See :doc:`processors` for details. - :param wrapper_class: Class to use for wrapping loggers instead of - `structlog.BoundLogger`. See `standard-library`, :doc:`twisted`, and - `custom-wrappers`. - :param context_class: Class to be used for internal context keeping. The - default is a `dict` and since dictionaries are ordered as of Python - 3.6, there's few reasons to change this option. - :param logger_factory: Factory to be called to create a new logger that - shall be wrapped. - :param cache_logger_on_first_use: `wrap_logger` doesn't return an actual - wrapped logger but a proxy that assembles one when it's first used. If - this option is set to `True`, this assembled logger is cached. See - `performance`. - - .. versionadded:: 0.3.0 - *cache_logger_on_first_use* + Arguments: + + processors: The processor chain. See :doc:`processors` for details. + + wrapper_class: + Class to use for wrapping loggers instead of + `structlog.BoundLogger`. See `standard-library`, :doc:`twisted`, + and `custom-wrappers`. + + context_class: + Class to be used for internal context keeping. The default is a + `dict` and since dictionaries are ordered as of Python 3.6, there's + few reasons to change this option. + + logger_factory: + Factory to be called to create a new logger that shall be wrapped. + + cache_logger_on_first_use: + `wrap_logger` doesn't return an actual wrapped logger but a proxy + that assembles one when it's first used. If this option is set to + `True`, this assembled logger is cached. See `performance`. + + .. versionadded:: 0.3.0 *cache_logger_on_first_use* """ _CONFIG.is_configured = True @@ -251,7 +268,9 @@ def configure_once( It does *not* matter whether it was configured using `configure` or `configure_once` before. - Raises a `RuntimeWarning` if repeated configuration is attempted. + Raises: + + RuntimeWarning: if repeated configuration is attempted. """ if not _CONFIG.is_configured: configure( @@ -283,23 +302,22 @@ def reset_defaults() -> None: class BoundLoggerLazyProxy: """ - Instantiates a ``BoundLogger`` on first usage. + Instantiates a bound logger on first usage. Takes both configuration and instantiation parameters into account. - The only points where a BoundLogger changes state are ``bind()``, + The only points where a bound logger changes state are ``bind()``, ``unbind()``, and ``new()`` and that return the actual ``BoundLogger``. - If and only if configuration says so, that actual ``BoundLogger`` is - cached on first usage. + If and only if configuration says so, that actual bound logger is cached on + first usage. - .. versionchanged:: 0.4.0 - Added support for *logger_factory_args*. + .. versionchanged:: 0.4.0 Added support for *logger_factory_args*. """ def __init__( self, - logger: WrappedLogger, + logger: WrappedLogger | None, wrapper_class: type[BindableLogger] | None = None, processors: Iterable[Processor] | None = None, context_class: type[Context] | None = None, @@ -317,11 +335,11 @@ class BoundLoggerLazyProxy: def __repr__(self) -> str: return ( - "<BoundLoggerLazyProxy(logger={0._logger!r}, wrapper_class=" - "{0._wrapper_class!r}, processors={0._processors!r}, " - "context_class={0._context_class!r}, " - "initial_values={0._initial_values!r}, " - "logger_factory_args={0._logger_factory_args!r})>".format(self) + f"<BoundLoggerLazyProxy(logger={self._logger!r}, wrapper_class=" + f"{self._wrapper_class!r}, processors={self._processors!r}, " + f"context_class={self._context_class!r}, " + f"initial_values={self._initial_values!r}, " + f"logger_factory_args={self._logger_factory_args!r})>" ) def bind(self, **new_values: Any) -> BindableLogger: diff --git a/src/structlog/_frames.py b/src/structlog/_frames.py index 6982463dd81b0146e70997cd4cacf0bc5b9f89e7..7a5fb6c3fdd2d72440171a2309bacf84e3bdae0e 100644 --- a/src/structlog/_frames.py +++ b/src/structlog/_frames.py @@ -20,6 +20,9 @@ def _format_exception(exc_info: ExcInfo) -> str: Shamelessly stolen from stdlib's logging module. """ + if exc_info == (None, None, None): # type: ignore[comparison-overlap] + return "MISSING" + sio = StringIO() traceback.print_exception(exc_info[0], exc_info[1], exc_info[2], None, sio) @@ -37,10 +40,14 @@ def _find_first_app_frame_and_name( """ Remove all intra-structlog calls and return the relevant app frame. - :param additional_ignores: Additional names with which the first frame must - not start. + Arguments: + + additional_ignores: + Additional names with which the first frame must not start. + + Returns: - :returns: tuple of (frame, name) + tuple of (frame, name) """ ignores = ["structlog"] + (additional_ignores or []) f = sys._getframe() diff --git a/src/structlog/_greenlets.py b/src/structlog/_greenlets.py index 43db1c1056d6c54063bd6136f62e899c805aab4d..0583048b5e22f07a52ebbbe8d5d27cbe6f1c5715 100644 --- a/src/structlog/_greenlets.py +++ b/src/structlog/_greenlets.py @@ -30,7 +30,7 @@ class GreenThreadLocal: try: return self._weakdict[key][name] except KeyError: - raise AttributeError(name) + raise AttributeError(name) from None def __setattr__(self, name: str, val: Any) -> None: key = getcurrent() @@ -41,4 +41,4 @@ class GreenThreadLocal: try: del self._weakdict[key][name] except KeyError: - raise AttributeError(name) + raise AttributeError(name) from None diff --git a/src/structlog/_log_levels.py b/src/structlog/_log_levels.py index d3863a025d56e359e210e66b20a1af17f3c6bb59..b2e721b658e7bf9b6a9bba6b743eb6675a285b6b 100644 --- a/src/structlog/_log_levels.py +++ b/src/structlog/_log_levels.py @@ -80,13 +80,17 @@ async def _anop(self: Any, event: str, *args: Any, **kw: Any) -> Any: return None -def exception(self: FilteringBoundLogger, event: str, **kw: Any) -> Any: +def exception( + self: FilteringBoundLogger, event: str, *args: Any, **kw: Any +) -> Any: kw.setdefault("exc_info", True) - return self.error(event, **kw) + return self.error(event, *args, **kw) -async def aexception(self: FilteringBoundLogger, event: str, **kw: Any) -> Any: +async def aexception( + self: FilteringBoundLogger, event: str, *args: Any, **kw: Any +) -> Any: # Exception info has to be extracted this early, because it is no longer # available once control is passed to the executor. if kw.get("exc_info", True) is True: @@ -95,7 +99,7 @@ async def aexception(self: FilteringBoundLogger, event: str, **kw: Any) -> Any: ctx = contextvars.copy_context() return await asyncio.get_running_loop().run_in_executor( None, - lambda: ctx.run(lambda: self.error(event, **kw)), + lambda: ctx.run(lambda: self.error(event, *args, **kw)), ) @@ -124,11 +128,14 @@ def make_filtering_bound_logger(min_level: int) -> type[FilteringBoundLogger]: - You *can* have (much) more fine-grained filtering by :ref:`writing a simple processor <finer-filtering>`. - :param min_level: The log level as an integer. You can use the constants - from `logging` like ``logging.INFO`` or pass the values directly. See - `this table from the logging docs - <https://docs.python.org/3/library/logging.html#levels>`_ for possible - values. + Arguments: + + min_level: + The log level as an integer. You can use the constants from + `logging` like ``logging.INFO`` or pass the values directly. See + `this table from the logging docs + <https://docs.python.org/3/library/logging.html#levels>`_ for + possible values. .. versionadded:: 20.2.0 .. versionchanged:: 21.1.0 The returned loggers are now pickleable. diff --git a/src/structlog/_output.py b/src/structlog/_output.py index 2f21494859b8c117e2d90f9ee7d674c81f7d7306..4c5efbf42ff44577e0a3553fac21606ad1a60f90 100644 --- a/src/structlog/_output.py +++ b/src/structlog/_output.py @@ -24,8 +24,6 @@ WRITE_LOCKS: dict[IO[Any], threading.Lock] = {} def _get_lock_for_file(file: IO[Any]) -> threading.Lock: - global WRITE_LOCKS - lock = WRITE_LOCKS.get(file) if lock is None: lock = threading.Lock() @@ -38,19 +36,21 @@ class PrintLogger: """ Print events into a file. - :param file: File to print to. (default: `sys.stdout`) + Arguments: + + file: File to print to. (default: `sys.stdout`) >>> from structlog import PrintLogger >>> PrintLogger().info("hello") hello - Useful if you follow - `current logging best practices <logging-best-practices>`. + Useful if you follow `current logging best practices + <logging-best-practices>`. Also very useful for testing and examples since `logging` is finicky in doctests. - .. versionchanged:: 22.1 + .. versionchanged:: 22.1.0 The implementation has been switched to use `print` for better monkeypatchability. """ @@ -85,7 +85,7 @@ class PrintLogger: self._lock = _get_lock_for_file(self._file) - def __deepcopy__(self, memodict: dict[Any, Any] = {}) -> PrintLogger: + def __deepcopy__(self, memodict: dict[str, object]) -> PrintLogger: """ Create a new PrintLogger with the same attributes. Similar to pickling. """ @@ -122,7 +122,9 @@ class PrintLoggerFactory: To be used with `structlog.configure`\ 's ``logger_factory``. - :param file: File to print to. (default: `sys.stdout`) + Arguments: + + file: File to print to. (default: `sys.stdout`) Positional arguments are silently ignored. @@ -140,7 +142,9 @@ class WriteLogger: """ Write events into a file. - :param file: File to print to. (default: `sys.stdout`) + Arguments: + + file: File to print to. (default: `sys.stdout`) >>> from structlog import WriteLogger >>> WriteLogger().info("hello") @@ -154,7 +158,7 @@ class WriteLogger: A little faster and a little less versatile than `structlog.PrintLogger`. - .. versionadded:: 22.1 + .. versionadded:: 22.1.0 """ def __init__(self, file: TextIO | None = None): @@ -189,7 +193,7 @@ class WriteLogger: self._lock = _get_lock_for_file(self._file) - def __deepcopy__(self, memodict: dict[Any, Any] = {}) -> WriteLogger: + def __deepcopy__(self, memodict: dict[str, object]) -> WriteLogger: """ Create a new WriteLogger with the same attributes. Similar to pickling. """ @@ -228,11 +232,13 @@ class WriteLoggerFactory: To be used with `structlog.configure`\ 's ``logger_factory``. - :param file: File to print to. (default: `sys.stdout`) + Arguments: + + file: File to print to. (default: `sys.stdout`) Positional arguments are silently ignored. - .. versionadded:: 22.1 + .. versionadded:: 22.1.0 """ def __init__(self, file: TextIO | None = None): @@ -246,12 +252,12 @@ class BytesLogger: r""" Writes bytes into a file. - :param file: File to print to. (default: `sys.stdout`\ ``.buffer``) + Arguments: + file: File to print to. (default: `sys.stdout`\ ``.buffer``) - Useful if you follow - `current logging best practices <logging-best-practices>` together with - a formatter that returns bytes (e.g. `orjson - <https://github.com/ijl/orjson>`_). + Useful if you follow `current logging best practices + <logging-best-practices>` together with a formatter that returns bytes + (e.g. `orjson <https://github.com/ijl/orjson>`_). .. versionadded:: 20.2.0 """ @@ -291,7 +297,7 @@ class BytesLogger: self._flush = self._file.flush self._lock = _get_lock_for_file(self._file) - def __deepcopy__(self, memodict: dict[Any, Any] = {}) -> BytesLogger: + def __deepcopy__(self, memodict: dict[str, object]) -> BytesLogger: """ Create a new BytesLogger with the same attributes. Similar to pickling. """ @@ -330,7 +336,9 @@ class BytesLoggerFactory: To be used with `structlog.configure`\ 's ``logger_factory``. - :param file: File to print to. (default: `sys.stdout`\ ``.buffer``) + Arguments: + + file: File to print to. (default: `sys.stdout`\ ``.buffer``) Positional arguments are silently ignored. diff --git a/src/structlog/_utils.py b/src/structlog/_utils.py index b57e55abc3c018c8a25d966ae23808ac70ad0675..f03aeaeaae1b18532dcc79f9e90b696e2510034a 100644 --- a/src/structlog/_utils.py +++ b/src/structlog/_utils.py @@ -20,14 +20,18 @@ def until_not_interrupted(f: Callable[..., Any], *args: Any, **kw: Any) -> Any: """ Retry until *f* succeeds or an exception that isn't caused by EINTR occurs. - :param f: A callable like a function. - :param *args: Positional arguments for *f*. - :param **kw: Keyword arguments for *f*. + Arguments: + + f: A callable like a function. + + *args: Positional arguments for *f*. + + **kw: Keyword arguments for *f*. """ while True: try: return f(*args, **kw) - except OSError as e: + except OSError as e: # noqa: PERF203 if e.args[0] == errno.EINTR: continue raise diff --git a/src/structlog/dev.py b/src/structlog/dev.py index 6a2517a03b3741b23d22a26899176c8a413b263d..816f9cef0c5410fd2b4577c73340a4c11df41c05 100644 --- a/src/structlog/dev.py +++ b/src/structlog/dev.py @@ -11,23 +11,29 @@ See also the narrative documentation in `development`. from __future__ import annotations +import shutil import sys import warnings +from dataclasses import dataclass from io import StringIO -from typing import Any, Iterable, TextIO, Type, Union +from types import ModuleType +from typing import ( + Any, + Iterable, + Literal, + Protocol, + Sequence, + TextIO, + Type, + Union, +) from ._frames import _format_exception from .processors import _figure_out_exc_info from .typing import EventDict, ExceptionRenderer, ExcInfo, WrappedLogger -if sys.version_info >= (3, 8): - from typing import Protocol -else: - from typing_extensions import Protocol - - try: import colorama except ImportError: @@ -95,13 +101,8 @@ else: GREEN = "\033[32m" RED_BACK = "\033[41m" - -if _IS_WINDOWS: # pragma: no cover - # On Windows, use colors by default only if Colorama is installed. - _has_colors = colorama is not None -else: - # On other OSes, use colors by default. - _has_colors = True +# On Windows, colors are only available if Colorama is installed. +_has_colors = not _IS_WINDOWS or colorama is not None # Prevent breakage of packages that used the old name of the variable. _use_colors = _has_colors @@ -169,27 +170,80 @@ def plain_traceback(sio: TextIO, exc_info: ExcInfo) -> None: To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument. - Used by default if neither *Rich* not *better-exceptions* are present. + Used by default if neither Rich nor *better-exceptions* are present. - .. versionadded:: 21.2 + .. versionadded:: 21.2.0 """ sio.write("\n" + _format_exception(exc_info)) -def rich_traceback(sio: TextIO, exc_info: ExcInfo) -> None: +@dataclass +class RichTracebackFormatter: """ - Pretty-print *exc_info* to *sio* using the *Rich* package. + A Rich traceback renderer with the given options. - To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument. + Pass an instance as `ConsoleRenderer`'s ``exception_formatter`` argument. + + See :class:`rich.traceback.Traceback` for details on the arguments. - Used by default if *Rich* is installed. + If a *width* of -1 is passed, the terminal width is used. If the width + can't be determined, fall back to 80. - .. versionadded:: 21.2 + .. versionadded:: 23.2.0 """ - sio.write("\n") - Console(file=sio, color_system="truecolor").print( - Traceback.from_exception(*exc_info, show_locals=True) - ) + + color_system: Literal[ + "auto", "standard", "256", "truecolor", "windows" + ] = "truecolor" + show_locals: bool = True + max_frames: int = 100 + theme: str | None = None + word_wrap: bool = False + extra_lines: int = 3 + width: int = 100 + indent_guides: bool = True + locals_max_length: int = 10 + locals_max_string: int = 80 + locals_hide_dunder: bool = True + locals_hide_sunder: bool = False + suppress: Sequence[str | ModuleType] = () + + def __call__(self, sio: TextIO, exc_info: ExcInfo) -> None: + if self.width == -1: + self.width, _ = shutil.get_terminal_size((80, 0)) + + sio.write("\n") + + Console(file=sio, color_system=self.color_system).print( + Traceback.from_exception( + *exc_info, + show_locals=self.show_locals, + max_frames=self.max_frames, + theme=self.theme, + word_wrap=self.word_wrap, + extra_lines=self.extra_lines, + width=self.width, + indent_guides=self.indent_guides, + locals_max_length=self.locals_max_length, + locals_max_string=self.locals_max_string, + locals_hide_dunder=self.locals_hide_dunder, + locals_hide_sunder=self.locals_hide_sunder, + suppress=self.suppress, + ) + ) + + +rich_traceback = RichTracebackFormatter() +""" +Pretty-print *exc_info* to *sio* using the Rich package. + +To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument. + +This is a `RichTracebackFormatter` with default arguments and used by default +if Rich is installed. + +.. versionadded:: 21.2.0 +""" def better_traceback(sio: TextIO, exc_info: ExcInfo) -> None: @@ -198,9 +252,9 @@ def better_traceback(sio: TextIO, exc_info: ExcInfo) -> None: To be passed into `ConsoleRenderer`'s ``exception_formatter`` argument. - Used by default if *better-exceptions* is installed and *Rich* is absent. + Used by default if *better-exceptions* is installed and Rich is absent. - .. versionadded:: 21.2 + .. versionadded:: 21.2.0 """ sio.write("\n" + "".join(better_exceptions.format_exception(*exc_info))) @@ -217,33 +271,51 @@ class ConsoleRenderer: """ Render ``event_dict`` nicely aligned, possibly in colors, and ordered. - If ``event_dict`` contains a true-ish ``exc_info`` key, it will be - rendered *after* the log line. If Rich_ or better-exceptions_ are present, - in colors and with extra context. - - :param pad_event: Pad the event to this many characters. - :param colors: Use colors for a nicer output. `True` by default. On - Windows only if Colorama_ is installed. - :param force_colors: Force colors even for non-tty destinations. - Use this option if your logs are stored in a file that is meant - to be streamed to the console. Only meaningful on Windows. - :param repr_native_str: When `True`, `repr` is also applied - to native strings (i.e. unicode on Python 3 and bytes on Python 2). - Setting this to `False` is useful if you want to have human-readable - non-ASCII output on Python 2. The ``event`` key is *never* - `repr` -ed. - :param level_styles: When present, use these styles for colors. This - must be a dict from level names (strings) to Colorama styles. The - default can be obtained by calling - `ConsoleRenderer.get_default_level_styles` - :param exception_formatter: A callable to render ``exc_infos``. If rich_ - or better-exceptions_ are installed, they are used for pretty-printing - by default (rich_ taking precedence). You can also manually set it to - `plain_traceback`, `better_traceback`, `rich_traceback`, or implement - your own. - :param sort_keys: Whether to sort keys when formatting. `True` by default. - :param event_key: The key to look for the main log message. Needed when - you rename it e.g. using `structlog.processors.EventRenamer`. + If ``event_dict`` contains a true-ish ``exc_info`` key, it will be rendered + *after* the log line. If Rich_ or better-exceptions_ are present, in colors + and with extra context. + + Arguments: + + pad_event: Pad the event to this many characters. + + colors: + Use colors for a nicer output. `True` by default. On Windows only + if Colorama_ is installed. + + force_colors: + Force colors even for non-tty destinations. Use this option if your + logs are stored in a file that is meant to be streamed to the + console. Only meaningful on Windows. + + repr_native_str: + When `True`, `repr` is also applied to native strings (i.e. unicode + on Python 3 and bytes on Python 2). Setting this to `False` is + useful if you want to have human-readable non-ASCII output on + Python 2. The ``event`` key is *never* `repr` -ed. + + level_styles: + When present, use these styles for colors. This must be a dict from + level names (strings) to Colorama styles. The default can be + obtained by calling `ConsoleRenderer.get_default_level_styles` + + exception_formatter: + A callable to render ``exc_infos``. If Rich_ or better-exceptions_ + are installed, they are used for pretty-printing by default (rich_ + taking precedence). You can also manually set it to + `plain_traceback`, `better_traceback`, an instance of + `RichTracebackFormatter` like `rich_traceback`, or implement your + own. + + sort_keys: Whether to sort keys when formatting. `True` by default. + + event_key: + The key to look for the main log message. Needed when you rename it + e.g. using `structlog.processors.EventRenamer`. + + timestamp_key: + The key to look for timestamp of the log message. Needed when you + rename it e.g. using `structlog.processors.EventRenamer`. Requires the Colorama_ package if *colors* is `True` **on Windows**. @@ -251,31 +323,35 @@ class ConsoleRenderer: .. _better-exceptions: https://pypi.org/project/better-exceptions/ .. _Rich: https://pypi.org/project/rich/ - .. versionadded:: 16.0 - .. versionadded:: 16.1 *colors* - .. versionadded:: 17.1 *repr_native_str* - .. versionadded:: 18.1 *force_colors* - .. versionadded:: 18.1 *level_styles* - .. versionchanged:: 19.2 - *Colorama* now initializes lazily to avoid unwanted initializations as + .. versionadded:: 16.0.0 + .. versionadded:: 16.1.0 *colors* + .. versionadded:: 17.1.0 *repr_native_str* + .. versionadded:: 18.1.0 *force_colors* + .. versionadded:: 18.1.0 *level_styles* + .. versionchanged:: 19.2.0 + Colorama now initializes lazily to avoid unwanted initializations as ``ConsoleRenderer`` is used by default. - .. versionchanged:: 19.2 Can be pickled now. - .. versionchanged:: 20.1 *Colorama* does not initialize lazily on Windows - anymore because it breaks rendering. - .. versionchanged:: 21.1 It is additionally possible to set the logger name - using the ``logger_name`` key in the ``event_dict``. - .. versionadded:: 21.2 *exception_formatter* - .. versionchanged:: 21.2 `ConsoleRenderer` now handles the ``exc_info`` - event dict key itself. Do **not** use the - `structlog.processors.format_exc_info` processor together with - `ConsoleRenderer` anymore! It will keep working, but you can't have - customize exception formatting and a warning will be raised if you ask - for it. - .. versionchanged:: 21.2 The colors keyword now defaults to True on - non-Windows systems, and either True or False in Windows depending on - whether *Colorama* is installed. + .. versionchanged:: 19.2.0 Can be pickled now. + .. versionchanged:: 20.1.0 + Colorama does not initialize lazily on Windows anymore because it breaks + rendering. + .. versionchanged:: 21.1.0 + It is additionally possible to set the logger name using the + ``logger_name`` key in the ``event_dict``. + .. versionadded:: 21.2.0 *exception_formatter* + .. versionchanged:: 21.2.0 + `ConsoleRenderer` now handles the ``exc_info`` event dict key itself. Do + **not** use the `structlog.processors.format_exc_info` processor + together with `ConsoleRenderer` anymore! It will keep working, but you + can't have customize exception formatting and a warning will be raised + if you ask for it. + .. versionchanged:: 21.2.0 + The colors keyword now defaults to True on non-Windows systems, and + either True or False in Windows depending on whether Colorama is + installed. .. versionadded:: 21.3.0 *sort_keys* - .. versionadded:: 22.1 *event_key* + .. versionadded:: 22.1.0 *event_key* + .. versionadded:: 23.2.0 *timestamp_key* """ def __init__( @@ -288,6 +364,7 @@ class ConsoleRenderer: exception_formatter: ExceptionRenderer = default_exception_formatter, sort_keys: bool = True, event_key: str = "event", + timestamp_key: str = "timestamp", ): styles: Styles if colors: @@ -331,6 +408,7 @@ class ConsoleRenderer: self._exception_formatter = exception_formatter self._sort_keys = sort_keys self._event_key = event_key + self._timestamp_key = timestamp_key def _repr(self, val: Any) -> str: """ @@ -345,12 +423,12 @@ class ConsoleRenderer: return repr(val) - def __call__( + def __call__( # noqa: PLR0912 self, logger: WrappedLogger, name: str, event_dict: EventDict ) -> str: sio = StringIO() - ts = event_dict.pop("timestamp", None) + ts = event_dict.pop(self._timestamp_key, None) if ts is not None: sio.write( # can be a number if timestamp is UNIXy @@ -423,7 +501,8 @@ class ConsoleRenderer: if exc_info: exc_info = _figure_out_exc_info(exc_info) - self._exception_formatter(sio, exc_info) + if exc_info != (None, None, None): + self._exception_formatter(sio, exc_info) elif exc is not None: if self._exception_formatter is not plain_traceback: warnings.warn( @@ -445,11 +524,14 @@ class ConsoleRenderer: home-grown :func:`~structlog.stdlib.add_log_level` you could do:: my_styles = ConsoleRenderer.get_default_level_styles() - my_styles["EVERYTHING_IS_ON_FIRE"] = my_styles["critical"] - renderer = ConsoleRenderer(level_styles=my_styles) + my_styles["EVERYTHING_IS_ON_FIRE"] = my_styles["critical"] renderer + = ConsoleRenderer(level_styles=my_styles) + + Arguments: - :param colors: Whether to use colorful styles. This must match the - *colors* parameter to `ConsoleRenderer`. Default: `True`. + colors: + Whether to use colorful styles. This must match the *colors* + parameter to `ConsoleRenderer`. Default: `True`. """ styles: Styles styles = _ColorfulStyles if colors else _PlainStyles diff --git a/src/structlog/processors.py b/src/structlog/processors.py index 8c5e0c7c74d5fea38164bfd6e34ba8a13e6b2d2b..7b2377a7699114c0edd5e0db504f3537821f54a4 100644 --- a/src/structlog/processors.py +++ b/src/structlog/processors.py @@ -63,16 +63,21 @@ class KeyValueRenderer: """ Render ``event_dict`` as a list of ``Key=repr(Value)`` pairs. - :param sort_keys: Whether to sort keys when formatting. - :param key_order: List of keys that should be rendered in this exact - order. Missing keys will be rendered as ``None``, extra keys depending - on *sort_keys* and the dict class. - :param drop_missing: When ``True``, extra keys in *key_order* will be - dropped rather than rendered as ``None``. - :param repr_native_str: When ``True``, :func:`repr()` is also applied - to native strings. - Setting this to ``False`` is useful if you want to have human-readable - non-ASCII output on Python 2. + Arguments: + + sort_keys: Whether to sort keys when formatting. + + key_order: + List of keys that should be rendered in this exact order. Missing + keys will be rendered as ``None``, extra keys depending on + *sort_keys* and the dict class. + + drop_missing: + When ``True``, extra keys in *key_order* will be dropped rather + than rendered as ``None``. + + repr_native_str: + When ``True``, :func:`repr()` is also applied to native strings. .. versionadded:: 0.2.0 *key_order* .. versionadded:: 16.1.0 *drop_missing* @@ -114,17 +119,27 @@ class LogfmtRenderer: .. _logfmt: https://brandur.org/logfmt - :param sort_keys: Whether to sort keys when formatting. - :param key_order: List of keys that should be rendered in this exact - order. Missing keys are rendered with empty values, extra keys - depending on *sort_keys* and the dict class. - :param drop_missing: When ``True``, extra keys in *key_order* will be - dropped rather than rendered with empty values. - :param bool_as_flag: When ``True``, render ``{"flag": True}`` as - ``flag``, instead of ``flag=true``. ``{"flag": False}`` is - always rendered as ``flag=false``. + Arguments: + + sort_keys: Whether to sort keys when formatting. + + key_order: + List of keys that should be rendered in this exact order. Missing + keys are rendered with empty values, extra keys depending on + *sort_keys* and the dict class. + + drop_missing: + When ``True``, extra keys in *key_order* will be dropped rather + than rendered with empty values. - :raises ValueError: If a key contains non printable or space characters. + bool_as_flag: + When ``True``, render ``{"flag": True}`` as ``flag``, instead of + ``flag=true``. ``{"flag": False}`` is always rendered as + ``flag=false``. + + Raises: + + ValueError: If a key contains non printable or space characters. .. versionadded:: 21.5.0 """ @@ -145,7 +160,8 @@ class LogfmtRenderer: elements: list[str] = [] for key, value in self._ordered_items(event_dict): if any(c <= " " for c in key): - raise ValueError(f'Invalid key: "{key}"') + msg = f'Invalid key: "{key}"' + raise ValueError(msg) if value is None: elements.append(f"{key}=") @@ -171,7 +187,7 @@ def _items_sorter( sort_keys: bool, key_order: Sequence[str] | None, drop_missing: bool, -) -> Callable[[EventDict], list[tuple[str, Any]]]: +) -> Callable[[EventDict], list[tuple[str, object]]]: """ Return a function to sort items from an ``event_dict``. @@ -182,7 +198,7 @@ def _items_sorter( def ordered_items(event_dict: EventDict) -> list[tuple[str, Any]]: items = [] - for key in key_order: # type: ignore[union-attr] + for key in key_order: value = event_dict.pop(key, None) if value is not None or not drop_missing: items.append((key, value)) @@ -195,7 +211,7 @@ def _items_sorter( def ordered_items(event_dict: EventDict) -> list[tuple[str, Any]]: items = [] - for key in key_order: # type: ignore[union-attr] + for key in key_order: value = event_dict.pop(key, None) if value is not None or not drop_missing: items.append((key, value)) @@ -221,14 +237,16 @@ class UnicodeEncoder: """ Encode unicode values in ``event_dict``. - :param encoding: Encoding to encode to (default: ``"utf-8"``). - :param errors: How to cope with encoding errors (default - ``"backslashreplace"``). + Arguments: + + encoding: Encoding to encode to (default: ``"utf-8"``). - Useful if you're running Python 2 as otherwise ``u"abc"`` will be rendered - as ``'u"abc"'``. + errors: + How to cope with encoding errors (default ``"backslashreplace"``). Just put it in the processor chain before the renderer. + + .. note:: Not very useful in a Python 3-only world. """ _encoding: str @@ -254,12 +272,13 @@ class UnicodeDecoder: """ Decode byte string values in ``event_dict``. - :param encoding: Encoding to decode from (default: ``"utf-8"``). - :param errors: How to cope with encoding errors (default: - ``"replace"``). + Arguments: + + encoding: Encoding to decode from (default: ``"utf-8"``). + + errors: How to cope with encoding errors (default: ``"replace"``). - Useful if you're running Python 3 as otherwise ``b"abc"`` will be rendered - as ``'b"abc"'``. + Useful to prevent ``b"abc"`` being rendered as as ``'b"abc"'``. Just put it in the processor chain before the renderer. @@ -289,24 +308,23 @@ class JSONRenderer: """ Render the ``event_dict`` using ``serializer(event_dict, **dumps_kw)``. - :param dumps_kw: Are passed unmodified to *serializer*. If *default* - is passed, it will disable support for ``__structlog__``-based - serialization. - :param serializer: A :func:`json.dumps`-compatible callable that - will be used to format the string. This can be used to use alternative - JSON encoders like `orjson <https://pypi.org/project/orjson/>`__ or - `RapidJSON <https://pypi.org/project/python-rapidjson/>`_ (default: - :func:`json.dumps`). + Arguments: - .. versionadded:: 0.2.0 - Support for ``__structlog__`` serialization method. + dumps_kw: + Are passed unmodified to *serializer*. If *default* is passed, it + will disable support for ``__structlog__``-based serialization. - .. versionadded:: 15.4.0 - *serializer* parameter. + serializer: + A :func:`json.dumps`-compatible callable that will be used to + format the string. This can be used to use alternative JSON + encoders like `orjson <https://pypi.org/project/orjson/>`__ or + `RapidJSON <https://pypi.org/project/python-rapidjson/>`_ + (default: :func:`json.dumps`). + .. versionadded:: 0.2.0 Support for ``__structlog__`` serialization method. + .. versionadded:: 15.4.0 *serializer* parameter. .. versionadded:: 18.2.0 Serializer's *default* parameter can be overwritten now. - """ def __init__( @@ -349,12 +367,12 @@ class ExceptionRenderer: by *exception_formatter*. The contents of the ``exception`` field depends on the return value of the - :class:`.ExceptionTransformer` that is used: + *exception_formatter* that is passed: - The default produces a formatted string via Python's built-in traceback - formatting. - - The :class:`~structlog.tracebacks.ExceptionDictTransformer` a list of - stack dicts that can be serialized to JSON. + formatting (this is :obj:`.format_exc_info`). + - If you pass a :class:`~structlog.tracebacks.ExceptionDictTransformer`, it + becomes a list of stack dicts that can be serialized to JSON. If *event_dict* contains the key ``exc_info``, there are three possible behaviors: @@ -364,13 +382,20 @@ class ExceptionRenderer: 3. If the value true but no tuple, obtain exc_info ourselves and render that. - If there is no ``exc_info`` key, the *event_dict* is not touched. - This behavior is analogue to the one of the stdlib's logging. + If there is no ``exc_info`` key, the *event_dict* is not touched. This + behavior is analog to the one of the stdlib's logging. + + Arguments: - :param exception_formatter: A callable that is used to format the exception - from the ``exc_info`` field. + exception_formatter: + A callable that is used to format the exception from the + ``exc_info`` field into the ``exception`` field. - .. versionadded:: 22.1 + .. seealso:: + :doc:`exceptions` for a broader explanation of *structlog*'s exception + features. + + .. versionadded:: 22.1.0 """ def __init__( @@ -393,8 +418,8 @@ class ExceptionRenderer: format_exc_info = ExceptionRenderer() """ -Replace an ``exc_info`` field with an ``exception`` string field using -Python's built-in traceback formatting. +Replace an ``exc_info`` field with an ``exception`` string field using Python's +built-in traceback formatting. If *event_dict* contains the key ``exc_info``, there are three possible behaviors: @@ -404,8 +429,12 @@ behaviors: 3. If the value is true but no tuple, obtain exc_info ourselves and render that. -If there is no ``exc_info`` key, the *event_dict* is not touched. -This behavior is analogue to the one of the stdlib's logging. +If there is no ``exc_info`` key, the *event_dict* is not touched. This behavior +is analog to the one of the stdlib's logging. + +.. seealso:: + :doc:`exceptions` for a broader explanation of *structlog*'s exception + features. """ dict_tracebacks = ExceptionRenderer(ExceptionDictTransformer()) @@ -418,7 +447,11 @@ It is a shortcut for :class:`ExceptionRenderer` with a The treatment of the ``exc_info`` key is identical to `format_exc_info`. -.. versionadded:: 22.1 +.. versionadded:: 22.1.0 + +.. seealso:: + :doc:`exceptions` for a broader explanation of *structlog*'s exception + features. """ @@ -426,13 +459,18 @@ class TimeStamper: """ Add a timestamp to ``event_dict``. - :param fmt: strftime format string, or ``"iso"`` for `ISO 8601 - <https://en.wikipedia.org/wiki/ISO_8601>`_, or `None` for a `UNIX - timestamp <https://en.wikipedia.org/wiki/Unix_time>`_. - :param utc: Whether timestamp should be in UTC or local time. - :param key: Target key in *event_dict* for added timestamps. + Arguments: - .. versionchanged:: 19.2 Can be pickled now. + fmt: + strftime format string, or ``"iso"`` for `ISO 8601 + <https://en.wikipedia.org/wiki/ISO_8601>`_, or `None` for a `UNIX + timestamp <https://en.wikipedia.org/wiki/Unix_time>`_. + + utc: Whether timestamp should be in UTC or local time. + + key: Target key in *event_dict* for added timestamps. + + .. versionchanged:: 19.2.0 Can be pickled now. """ __slots__ = ("_stamper", "fmt", "utc", "key") @@ -470,19 +508,21 @@ def _make_stamper( Create a stamper function. """ if fmt is None and not utc: - raise ValueError("UNIX timestamps are always UTC.") + msg = "UNIX timestamps are always UTC." + raise ValueError(msg) now: Callable[[], datetime.datetime] if utc: def now() -> datetime.datetime: - return datetime.datetime.utcnow() + return datetime.datetime.now(tz=datetime.timezone.utc) else: def now() -> datetime.datetime: - return datetime.datetime.now() + # A naive local datetime is fine here, because we only format it. + return datetime.datetime.now() # noqa: DTZ005 if fmt is None: @@ -500,7 +540,7 @@ def _make_stamper( return event_dict def stamper_iso_utc(event_dict: EventDict) -> EventDict: - event_dict[key] = now().isoformat() + "Z" + event_dict[key] = now().isoformat().replace("+00:00", "Z") return event_dict if utc: @@ -509,13 +549,44 @@ def _make_stamper( return stamper_iso_local def stamper_fmt(event_dict: EventDict) -> EventDict: - event_dict[key] = now().strftime(fmt) # type: ignore[arg-type] + event_dict[key] = now().strftime(fmt) return event_dict return stamper_fmt +class MaybeTimeStamper: + """ + A timestamper that only adds a timestamp if there is none. + + This allows you to overwrite the ``timestamp`` key in the event dict for + example when the event is coming from another system. + + It takes the same arguments as `TimeStamper`. + + .. versionadded:: 23.2.0 + """ + + __slots__ = ("stamper",) + + def __init__( + self, + fmt: str | None = None, + utc: bool = True, + key: str = "timestamp", + ): + self.stamper = TimeStamper(fmt=fmt, utc=utc, key=key) + + def __call__( + self, logger: WrappedLogger, name: str, event_dict: EventDict + ) -> EventDict: + if "timestamp" not in event_dict: + return self.stamper(logger, name, event_dict) + + return event_dict + + def _figure_out_exc_info(v: Any) -> ExcInfo: """ Depending on the Python version will try to do the smartest thing possible @@ -537,13 +608,15 @@ class ExceptionPrettyPrinter: """ Pretty print exceptions and remove them from the ``event_dict``. - :param file: Target file for output (default: ``sys.stdout``). + Arguments: + + file: Target file for output (default: ``sys.stdout``). This processor is mostly for development and testing so you can read exceptions properly formatted. - It behaves like `format_exc_info` except it removes the exception - data from the event dictionary after printing it. + It behaves like `format_exc_info` except it removes the exception data from + the event dictionary after printing it. It's tolerant to having `format_exc_info` in front of itself in the processor chain but doesn't require it. In other words, it handles both @@ -588,19 +661,23 @@ class StackInfoRenderer: involving an exception and works analogously to the *stack_info* argument of the Python standard library logging. - :param additional_ignores: By default, stack frames coming from - *structlog* are ignored. With this argument you can add additional - names that are ignored, before the stack starts being rendered. They - are matched using ``startswith()``, so they don't have to match - exactly. The names are used to find the first relevant name, therefore - once a frame is found that doesn't start with *structlog* or one of - *additional_ignores*, **no filtering** is applied to subsequent frames. + Arguments: + + additional_ignores: + By default, stack frames coming from *structlog* are ignored. With + this argument you can add additional names that are ignored, before + the stack starts being rendered. They are matched using + ``startswith()``, so they don't have to match exactly. The names + are used to find the first relevant name, therefore once a frame is + found that doesn't start with *structlog* or one of + *additional_ignores*, **no filtering** is applied to subsequent + frames. .. versionadded:: 0.4.0 .. versionadded:: 22.1.0 *additional_ignores* """ - __slots__ = ["_additional_ignores"] + __slots__ = ("_additional_ignores",) def __init__(self, additional_ignores: list[str] | None = None) -> None: self._additional_ignores = additional_ignores @@ -672,15 +749,18 @@ class CallsiteParameterAdder: The keys used for callsite parameters in the event dictionary are the string values of `CallsiteParameter` enum members. - :param parameters: - A collection of `CallsiteParameter` values that should be added to the - event dictionary. + Arguments: + + parameters: + A collection of `CallsiteParameter` values that should be added to + the event dictionary. - :param additional_ignores: - Additional names with which a stack frame's module name must not - start for it to be considered when determening the callsite. + additional_ignores: + Additional names with which a stack frame's module name must not + start for it to be considered when determening the callsite. .. note:: + When used with `structlog.stdlib.ProcessorFormatter` the most efficient configuration is to either use this processor in ``foreign_pre_chain`` of `structlog.stdlib.ProcessorFormatter` and in ``processors`` of @@ -741,11 +821,7 @@ class CallsiteParameterAdder: event_dict_key: str record_attribute: str - __slots__ = [ - "_active_handlers", - "_additional_ignores", - "_record_mappings", - ] + __slots__ = ("_active_handlers", "_additional_ignores", "_record_mappings") def __init__( self, @@ -808,12 +884,15 @@ class EventRenamer: some processors may rely on the presence and meaning of the ``event`` key. - :param to: Rename ``event_dict["event"]`` to ``event_dict[to]`` - :param replace_by: Rename ``event_dict[replace_by]`` to - ``event_dict["event"]``. *replace_by* missing from ``event_dict`` is - handled gracefully. + Arguments: + + to: Rename ``event_dict["event"]`` to ``event_dict[to]`` + + replace_by: + Rename ``event_dict[replace_by]`` to ``event_dict["event"]``. + *replace_by* missing from ``event_dict`` is handled gracefully. - .. versionadded:: 22.1 + .. versionadded:: 22.1.0 See also the :ref:`rename-event` recipe. """ diff --git a/src/structlog/stdlib.py b/src/structlog/stdlib.py index b73fc8998a2faa6283e379c0ec8c97e815a8c648..9de32fd9384b7dfae1e01bd84aaa3ba53a869542 100644 --- a/src/structlog/stdlib.py +++ b/src/structlog/stdlib.py @@ -55,23 +55,21 @@ def recreate_defaults(*, log_level: int | None = logging.NOTSET) -> None: As with vanilla defaults, the backwards-compatibility guarantees don't apply to the settings applied here. - :param log_level: If `None`, don't configure standard library logging **at - all**. + Arguments: - Otherwise configure it to log to `sys.stdout` at *log_level* - (``logging.NOTSET`` being the default). + log_level: + If `None`, don't configure standard library logging **at all**. - If you need more control over `logging`, pass `None` here and configure - it yourself. + Otherwise configure it to log to `sys.stdout` at *log_level* + (``logging.NOTSET`` being the default). - .. versionadded:: 22.1 + If you need more control over `logging`, pass `None` here and + configure it yourself. + + .. versionadded:: 22.1.0 """ if log_level is not None: - kw = {} - # 3.7 doesn't have the force keyword and we don't care enough to - # re-implement it. - if sys.version_info >= (3, 8): - kw = {"force": True} + kw = {"force": True} logging.basicConfig( format="%(message)s", @@ -151,7 +149,9 @@ class BoundLogger(BoundLoggerBase): """ Return a new logger with *keys* removed from the context. - :raises KeyError: If the key is not part of the context. + Raises: + + KeyError: If the key is not part of the context. """ return super().unbind(*keys) # type: ignore[return-value] @@ -488,15 +488,16 @@ class AsyncBoundLogger: Only available for Python 3.7 and later. - :ivar structlog.stdlib.BoundLogger sync_bl: The wrapped synchronous logger. - It is useful to be able to log synchronously occasionally. - .. versionadded:: 20.2.0 .. versionchanged:: 20.2.0 fix _dispatch_to_sync contextvars usage + .. deprecated:: 23.1.0 + Use the regular `BoundLogger` with its a-prefixed methods instead. """ - __slots__ = ["sync_bl", "_loop"] + __slots__ = ("sync_bl", "_loop") + #: The wrapped synchronous logger. It is useful to be able to log + #: synchronously occasionally. sync_bl: BoundLogger # Blatant lie, we use a property for _context. Need this for Protocol @@ -641,11 +642,14 @@ class LoggerFactory: >>> from structlog.stdlib import LoggerFactory >>> configure(logger_factory=LoggerFactory()) - :param ignore_frame_names: When guessing the name of a logger, skip frames - whose names *start* with one of these. For example, in pyramid - applications you'll want to set it to - ``["venusian", "pyramid.config"]``. This argument is - called *additional_ignores* in other APIs throughout *structlog*. + Arguments: + + ignore_frame_names: + When guessing the name of a logger, skip frames whose names *start* + with one of these. For example, in pyramid applications you'll + want to set it to ``["venusian", "pyramid.config"]``. This argument + is called *additional_ignores* in other APIs throughout + *structlog*. """ def __init__(self, ignore_frame_names: list[str] | None = None): @@ -797,17 +801,20 @@ class ExtraAdder: This processor can be used for adding data passed in the ``extra`` parameter of the `logging` module's log methods to the event dictionary. - :param allow: An optional collection of attributes that, if present in - `logging.LogRecord` objects, will be copied to event dictionaries. + Arguments: + + allow: + An optional collection of attributes that, if present in + `logging.LogRecord` objects, will be copied to event dictionaries. - If ``allow`` is None all attributes of `logging.LogRecord` objects that - do not exist on a standard `logging.LogRecord` object will be copied to - event dictionaries. + If ``allow`` is None all attributes of `logging.LogRecord` objects + that do not exist on a standard `logging.LogRecord` object will be + copied to event dictionaries. .. versionadded:: 21.5.0 """ - __slots__ = ["_copier"] + __slots__ = ("_copier",) def __init__(self, allow: Collection[str] | None = None) -> None: self._copier: Callable[[EventDict, logging.LogRecord], None] @@ -859,8 +866,9 @@ def render_to_log_kwargs( This allows you to defer formatting to `logging`. .. versionadded:: 17.1.0 - .. versionchanged:: 22.1.0 ``exc_info``, ``stack_info``, and ``stackLevel`` - are passed as proper kwargs and not put into ``extra``. + .. versionchanged:: 22.1.0 + ``exc_info``, ``stack_info``, and ``stackLevel`` are passed as proper + kwargs and not put into ``extra``. """ return { "msg": event_dict.pop("event"), @@ -885,56 +893,67 @@ class ProcessorFormatter(logging.Formatter): Please refer to :ref:`processor-formatter` for examples. - :param foreign_pre_chain: - If not `None`, it is used as a processor chain that is applied to - **non**-*structlog* log entries before the event dictionary is passed - to *processors*. (default: `None`) - :param processors: - A chain of *structlog* processors that is used to process **all** log - entries. The last one must render to a `str` which then gets passed on - to `logging` for output. - - Compared to *structlog*'s regular processor chains, there's a few - differences: - - - The event dictionary contains two additional keys: - - #. ``_record``: a `logging.LogRecord` that either was created using - `logging` APIs, **or** is a wrapped *structlog* log entry - created by `wrap_for_formatter`. - #. ``_from_structlog``: a `bool` that indicates whether or not - ``_record`` was created by a *structlog* logger. - - Since you most likely don't want ``_record`` and - ``_from_structlog`` in your log files, we've added - the static method `remove_processors_meta` to ``ProcessorFormatter`` - that you can add just before your renderer. - - - Since this is a `logging` *formatter*, raising `structlog.DropEvent` - will crash your application. - - :param keep_exc_info: ``exc_info`` on `logging.LogRecord`\ s is - added to the ``event_dict`` and removed afterwards. Set this to - ``True`` to keep it on the `logging.LogRecord`. (default: False) - :param keep_stack_info: Same as *keep_exc_info* except for ``stack_info``. - (default: False) - :param logger: Logger which we want to push through the *structlog* - processor chain. This parameter is necessary for some of the - processors like `filter_by_level`. (default: None) - :param pass_foreign_args: If True, pass a foreign log record's - ``args`` attribute to the ``event_dict`` under ``positional_args`` key. - (default: False) - :param processor: - A single *structlog* processor used for rendering the event - dictionary before passing it off to `logging`. Must return a `str`. - The event dictionary does **not** contain ``_record`` and - ``_from_structlog``. - - This parameter exists for historic reasons. Please consider using - *processors* instead. - - :raises TypeError: If both or neither *processor* and *processors* are - passed. + Arguments: + + foreign_pre_chain: + If not `None`, it is used as a processor chain that is applied to + **non**-*structlog* log entries before the event dictionary is + passed to *processors*. (default: `None`) + + processors: + A chain of *structlog* processors that is used to process **all** + log entries. The last one must render to a `str` which then gets + passed on to `logging` for output. + + Compared to *structlog*'s regular processor chains, there's a few + differences: + + - The event dictionary contains two additional keys: + + #. ``_record``: a `logging.LogRecord` that either was created + using `logging` APIs, **or** is a wrapped *structlog* log + entry created by `wrap_for_formatter`. + + #. ``_from_structlog``: a `bool` that indicates whether or not + ``_record`` was created by a *structlog* logger. + + Since you most likely don't want ``_record`` and + ``_from_structlog`` in your log files, we've added the static + method `remove_processors_meta` to ``ProcessorFormatter`` that + you can add just before your renderer. + + - Since this is a `logging` *formatter*, raising + `structlog.DropEvent` will crash your application. + + keep_exc_info: + ``exc_info`` on `logging.LogRecord`\ s is added to the + ``event_dict`` and removed afterwards. Set this to ``True`` to keep + it on the `logging.LogRecord`. (default: False) + + keep_stack_info: + Same as *keep_exc_info* except for ``stack_info``. (default: False) + + logger: + Logger which we want to push through the *structlog* processor + chain. This parameter is necessary for some of the processors like + `filter_by_level`. (default: None) + + pass_foreign_args: + If True, pass a foreign log record's ``args`` attribute to the + ``event_dict`` under ``positional_args`` key. (default: False) + + processor: + A single *structlog* processor used for rendering the event + dictionary before passing it off to `logging`. Must return a `str`. + The event dictionary does **not** contain ``_record`` and + ``_from_structlog``. + + This parameter exists for historic reasons. Please use *processors* + instead. + + Raises: + + TypeError: If both or neither *processor* and *processors* are passed. .. versionadded:: 17.1.0 .. versionadded:: 17.2.0 *keep_exc_info* and *keep_stack_info* @@ -962,10 +981,8 @@ class ProcessorFormatter(logging.Formatter): super().__init__(*args, fmt=fmt, **kwargs) # type: ignore[misc] if processor and processors: - raise TypeError( - "The `processor` and `processors` arguments are mutually " - "exclusive." - ) + msg = "The `processor` and `processors` arguments are mutually exclusive." + raise TypeError(msg) self.processors: Sequence[Processor] if processor is not None: @@ -973,9 +990,8 @@ class ProcessorFormatter(logging.Formatter): elif processors: self.processors = processors else: - raise TypeError( - "Either `processor` or `processors` must be passed." - ) + msg = "Either `processor` or `processors` must be passed." + raise TypeError(msg) self.foreign_pre_chain = foreign_pre_chain self.keep_exc_info = keep_exc_info @@ -1006,7 +1022,7 @@ class ProcessorFormatter(logging.Formatter): # We need to copy because it's possible that the same record gets # processed by multiple logging formatters. LogRecord.getMessage # would transform our dict into a str. - ed = record.msg.copy() # type: ignore[attr-defined] + ed = record.msg.copy() # type: ignore[union-attr] ed["_record"] = record ed["_from_structlog"] = True else: diff --git a/src/structlog/testing.py b/src/structlog/testing.py index 34c62f94de39bfe9fd2733878b75fd39bc0100a3..8f5c093a172ec81b0259f05c12d2b5473c568103 100644 --- a/src/structlog/testing.py +++ b/src/structlog/testing.py @@ -139,9 +139,13 @@ class CapturedCall(NamedTuple): Can also be unpacked like a tuple. - :param method_name: The method name that got called. - :param args: A tuple of the positional arguments. - :param kwargs: A dict of the keyword arguments. + Arguments: + + method_name: The method name that got called. + + args: A tuple of the positional arguments. + + kwargs: A dict of the keyword arguments. .. versionadded:: 20.2.0 """ diff --git a/src/structlog/threadlocal.py b/src/structlog/threadlocal.py index 4fa043f78158304ff50ece8981645e872711f1cf..f57af92b49bb8001990e427fb888592d432688ae 100644 --- a/src/structlog/threadlocal.py +++ b/src/structlog/threadlocal.py @@ -51,13 +51,12 @@ def _deprecated() -> None: Raise a warning with best-effort stacklevel adjustment. """ callsite = "" - try: + + with contextlib.suppress(Exception): f = sys._getframe() callsite = f.f_back.f_back.f_globals[ # type: ignore[union-attr] "__name__" ] - except Exception: # pragma: no cover - pass # Avoid double warnings if TL functions call themselves. if callsite == "structlog.threadlocal": @@ -84,7 +83,9 @@ def wrap_dict(dict_class: type[Context]) -> type[Context]: The wrapped class and used to keep global in the current thread. - :param dict_class: Class used for keeping context. + Arguments: + + dict_class: Class used for keeping context. .. deprecated:: 22.1.0 """ @@ -105,10 +106,14 @@ def as_immutable(logger: TLLogger) -> TLLogger: """ Extract the context from a thread local logger into an immutable logger. - :param structlog.typing.BindableLogger logger: A logger with *possibly* - thread local state. + Arguments: + + logger (structlog.typing.BindableLogger): + A logger with *possibly* thread local state. + + Returns: - :returns: :class:`~structlog.BoundLogger` with an immutable context. + :class:`~structlog.BoundLogger` with an immutable context. .. deprecated:: 22.1.0 """ @@ -194,11 +199,11 @@ class _ThreadLocalDictWrapper: def __repr__(self) -> str: return f"<{self.__class__.__name__}({self._dict!r})>" - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: # Same class == same dictionary return self.__class__ == other.__class__ - def __ne__(self, other: Any) -> bool: + def __ne__(self, other: object) -> bool: return not self.__eq__(other) # Proxy methods necessary for structlog. diff --git a/src/structlog/tracebacks.py b/src/structlog/tracebacks.py index 50643516eb28c2335cd0a5b914f1cdbceda20c13..5fbd5447f5d97e96281540a4a05849ab87f3f02b 100644 --- a/src/structlog/tracebacks.py +++ b/src/structlog/tracebacks.py @@ -6,9 +6,10 @@ """ Extract a structured traceback from an exception. -Contributed by Will McGugan (see -https://github.com/hynek/structlog/pull/407#issuecomment-1150926246) from Rich: -https://github.com/Textualize/rich/blob/972dedff/rich/traceback.py +`Contributed by Will McGugan +<https://github.com/hynek/structlog/pull/407#issuecomment-1150926246>`_ from +`rich.traceback +<https://github.com/Textualize/rich/blob/972dedff/rich/traceback.py>`_. """ from __future__ import annotations @@ -56,7 +57,7 @@ class Frame: @dataclass -class SyntaxError_: +class SyntaxError_: # noqa: N801 """ Contains detailed information about :exc:`SyntaxError` exceptions. """ @@ -94,7 +95,7 @@ def safe_str(_object: Any) -> str: """Don't allow exceptions from __str__ to propegate.""" try: return str(_object) - except Exception as error: + except Exception as error: # noqa: BLE001 return f"<str-error {str(error)!r}>" @@ -105,7 +106,7 @@ def to_repr(obj: Any, max_string: int | None = None) -> str: else: try: obj_repr = repr(obj) - except Exception as error: + except Exception as error: # noqa: BLE001 obj_repr = f"<repr-error {str(error)!r}>" if max_string is not None and len(obj_repr) > max_string: @@ -126,18 +127,25 @@ def extract( """ Extract traceback information. - :param exc_type: Exception type. - :param exc_value: Exception value. - :param traceback: Python Traceback object. - :param show_locals: Enable display of local variables. Defaults to False. - :param locals_max_string: Maximum length of string before truncating, or - ``None`` to disable. - :param max_frames: Maximum number of frames in each stack + Arguments: - :returns: A Trace instance with structured information about all - exceptions. + exc_type: Exception type. - .. versionadded:: 22.1 + exc_value: Exception value. + + traceback: Python Traceback object. + + show_locals: Enable display of local variables. Defaults to False. + + locals_max_string: + Maximum length of string before truncating, or ``None`` to disable. + + max_frames: Maximum number of frames in each stack + + Returns: + A Trace instance with structured information about all exceptions. + + .. versionadded:: 22.1.0 """ stacks: list[Stack] = [] @@ -212,16 +220,26 @@ class ExceptionDictTransformer: These dictionaries are based on :class:`Stack` instances generated by :func:`extract()` and can be dumped to JSON. - :param show_locals: Whether or not to include the values of a stack frame's - local variables. - :param locals_max_string: The maximum length after which long string - representations are truncated. - :param max_frames: Maximum number of frames in each stack. Frames are - removed from the inside out. The idea is, that the first frames - represent your code responsible for the exception and last frames the - code where the exception actually happened. With larger web - frameworks, this does not always work, so you should stick with the - default. + Arguments: + + show_locals: + Whether or not to include the values of a stack frame's local + variables. + + locals_max_string: + The maximum length after which long string representations are + truncated. + + max_frames: + Maximum number of frames in each stack. Frames are removed from + the inside out. The idea is, that the first frames represent your + code responsible for the exception and last frames the code where + the exception actually happened. With larger web frameworks, this + does not always work, so you should stick with the default. + + .. seealso:: + :doc:`exceptions` for a broader explanation of *structlog*'s exception + features. """ def __init__( @@ -231,11 +249,11 @@ class ExceptionDictTransformer: max_frames: int = MAX_FRAMES, ) -> None: if locals_max_string < 0: - raise ValueError( - f'"locals_max_string" must be >= 0: {locals_max_string}' - ) + msg = f'"locals_max_string" must be >= 0: {locals_max_string}' + raise ValueError(msg) if max_frames < 2: - raise ValueError(f'"max_frames" must be >= 2: {max_frames}') + msg = f'"max_frames" must be >= 2: {max_frames}' + raise ValueError(msg) self.show_locals = show_locals self.locals_max_string = locals_max_string self.max_frames = max_frames diff --git a/src/structlog/twisted.py b/src/structlog/twisted.py index 22b62301f60740e1932c2a2da18bb7eea5009275..64f7b386b7511fb1e5271eed8002fadad24e4ced 100644 --- a/src/structlog/twisted.py +++ b/src/structlog/twisted.py @@ -136,7 +136,7 @@ class ReprWrapper: def __init__(self, string: str) -> None: self.string = string - def __eq__(self, other: Any) -> bool: + def __eq__(self, other: object) -> bool: """ Check for equality, just for tests. """ @@ -199,12 +199,14 @@ class JSONRenderer(GenericJSONRenderer): @implementer(ILogObserver) class PlainFileLogObserver: """ - Write only the the plain message without timestamps or anything else. + Write only the plain message without timestamps or anything else. Great to just print JSON to stdout where you catch it with something like runit. - :param file: File to print to. + Arguments: + + file: File to print to. .. versionadded:: 0.2.0 """ @@ -227,10 +229,13 @@ class JSONLogObserverWrapper: """ Wrap a log *observer* and render non-`JSONRenderer` entries to JSON. - :param ILogObserver observer: Twisted log observer to wrap. For example - :class:`PlainFileObserver` or Twisted's stock `FileLogObserver - <https://docs.twisted.org/en/stable/api/ - twisted.python.log.FileLogObserver.html>`_ + Arguments: + + observer (ILogObserver): + Twisted log observer to wrap. For example + :class:`PlainFileObserver` or Twisted's stock `FileLogObserver + <https://docs.twisted.org/en/stable/api/ + twisted.python.log.FileLogObserver.html>`_ .. versionadded:: 0.2.0 """ @@ -288,8 +293,11 @@ class EventAdapter: <https://docs.twisted.org/en/stable/api/twisted.python.log.html#err>`_ behave as expected. - :param dictRenderer: Renderer that is used for the actual log message. - Please note that structlog comes with a dedicated `JSONRenderer`. + Arguments: + + dictRenderer: + Renderer that is used for the actual log message. Please note that + structlog comes with a dedicated `JSONRenderer`. **Must** be the last processor in the chain and requires a *dictRenderer* for the actual formatting as an constructor argument in order to be able to @@ -301,9 +309,6 @@ class EventAdapter: dictRenderer: Callable[[WrappedLogger, str, EventDict], str] | None = None, ) -> None: - """ - :param dictRenderer: A processor used to format the log message. - """ self._dictRenderer = dictRenderer or _BUILTIN_DEFAULT_PROCESSORS[-1] def __call__( @@ -311,9 +316,9 @@ class EventAdapter: ) -> Any: if name == "err": # This aspires to handle the following cases correctly: - # - log.err(failure, _why='event', **kw) - # - log.err('event', **kw) - # - log.err(_stuff=failure, _why='event', **kw) + # 1. log.err(failure, _why='event', **kw) + # 2. log.err('event', **kw) + # 3. log.err(_stuff=failure, _why='event', **kw) _stuff, _why, eventDict = _extractStuffAndWhy(eventDict) eventDict["event"] = _why diff --git a/src/structlog/types.py b/src/structlog/types.py index e7cbe11526928934d1ff3d740137e8737c870195..5d79fd5d2a5163588225bd3e1b585480f5af3d7c 100644 --- a/src/structlog/types.py +++ b/src/structlog/types.py @@ -6,8 +6,8 @@ """ Deprecated name for :mod:`structlog.typing`. -.. versionadded:: 20.2 -.. deprecated:: 22.2 +.. versionadded:: 20.2.0 +.. deprecated:: 22.2.0 """ from __future__ import annotations diff --git a/src/structlog/typing.py b/src/structlog/typing.py index d27625a94930fe185817a05296be54f30ab8a7f1..66f6a7e02c252bf3b0b4c0b080749404255ff52b 100644 --- a/src/structlog/typing.py +++ b/src/structlog/typing.py @@ -9,13 +9,11 @@ Type information used throughout *structlog*. For now, they are considered provisional. Especially `BindableLogger` will probably change to something more elegant. -.. versionadded:: 22.2 +.. versionadded:: 22.2.0 """ from __future__ import annotations -import sys - from types import TracebackType from typing import ( Any, @@ -24,19 +22,15 @@ from typing import ( Mapping, MutableMapping, Optional, + Protocol, TextIO, Tuple, Type, Union, + runtime_checkable, ) -if sys.version_info >= (3, 8): - from typing import Protocol, runtime_checkable -else: - from typing_extensions import Protocol, runtime_checkable - - WrappedLogger = Any """ A logger that is wrapped by a bound logger and is ultimately responsible for @@ -44,7 +38,7 @@ the output of the log entries. *structlog* makes *no* assumptions about it. -.. versionadded:: 20.2 +.. versionadded:: 20.2.0 """ @@ -52,7 +46,7 @@ Context = Union[Dict[str, Any], Dict[Any, Any]] """ A dict-like context carrier. -.. versionadded:: 20.2 +.. versionadded:: 20.2.0 """ @@ -63,7 +57,7 @@ An event dictionary as it is passed into processors. It's created by copying the configured `Context` but doesn't need to support copy itself. -.. versionadded:: 20.2 +.. versionadded:: 20.2.0 """ Processor = Callable[ @@ -75,14 +69,14 @@ A callable that is part of the processor chain. See :doc:`processors`. -.. versionadded:: 20.2 +.. versionadded:: 20.2.0 """ ExcInfo = Tuple[Type[BaseException], BaseException, Optional[TracebackType]] """ An exception info tuple as returned by `sys.exc_info`. -.. versionadded:: 20.2 +.. versionadded:: 20.2.0 """ @@ -92,7 +86,7 @@ A callable that pretty-prints an `ExcInfo` into a file-like object. Used by `structlog.dev.ConsoleRenderer`. -.. versionadded:: 21.2 +.. versionadded:: 21.2.0 """ @@ -108,12 +102,16 @@ class ExceptionTransformer(Protocol): Used by `structlog.processors.format_exc_info()` and `structlog.processors.ExceptionPrettyPrinter`. - :param exc_info: Is the exception tuple to format + Arguments: + + exc_info: Is the exception tuple to format - :returns: Anything that can be rendered by the last processor in your - chain, e.g., a string or a JSON-serializable structure. + Returns: - .. versionadded:: 22.1 + Anything that can be rendered by the last processor in your chain, + for example, a string or a JSON-serializable structure. + + .. versionadded:: 22.1.0 """ def __call__(self, exc_info: ExcInfo) -> Any: @@ -126,7 +124,7 @@ class BindableLogger(Protocol): **Protocol**: Methods shared among all bound loggers and that are relied on by *structlog*. - .. versionadded:: 20.2 + .. versionadded:: 20.2.0 """ _context: Context @@ -151,8 +149,7 @@ class FilteringBoundLogger(BindableLogger, Protocol): The only way to instantiate one is using `make_filtering_bound_logger`. .. versionadded:: 20.2.0 - .. versionadded:: 22.2.0 - String interpolation using positional arguments. + .. versionadded:: 22.2.0 String interpolation using positional arguments. .. versionadded:: 22.2.0 Async variants ``alog()``, ``adebug()``, ``ainfo()``, and so forth. .. versionchanged:: 22.3.0 diff --git a/tests/conftest.py b/tests/conftest.py index ae66a011e73a6ce28ab0be6cfbd2364154404803..d68a7272591d87e8e1805a4959d4f85495dd3cb2 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -9,6 +9,8 @@ from io import StringIO import pytest +import structlog + from structlog._log_levels import _NAME_TO_LEVEL from structlog.testing import CapturingLogger @@ -65,3 +67,8 @@ def _stdlib_log_methods(request): @pytest.fixture(name="cl") def _cl(): return CapturingLogger() + + +@pytest.fixture(autouse=True) +def _reset_config(): + structlog.reset_defaults() diff --git a/tests/test_base.py b/tests/test_base.py index ed2eef8ab06f307dca0666e1b66d40a0db35274e..de810c728a1ae1fc9d6505f4ad143c7adec7ecfe 100644 --- a/tests/test_base.py +++ b/tests/test_base.py @@ -13,6 +13,7 @@ from structlog._config import _CONFIG from structlog.exceptions import DropEvent from structlog.processors import KeyValueRenderer from structlog.testing import ReturnLogger +from tests.utils import CustomError def build_bl(logger=None, processors=None, context=None): @@ -28,11 +29,14 @@ def build_bl(logger=None, processors=None, context=None): class TestBinding: def test_repr(self): - bl = build_bl(processors=[1, 2, 3], context={}) + """ + repr() of a BoundLoggerBase shows its context and processors. + """ + bl = build_bl(processors=[1, 2, 3], context={"A": "B"}) - assert ("<BoundLoggerBase(context={}, processors=[1, 2, 3])>") == repr( - bl - ) + assert ( + "<BoundLoggerBase(context={'A': 'B'}, processors=[1, 2, 3])>" + ) == repr(bl) def test_binds_independently(self): """ @@ -161,9 +165,9 @@ class TestProcessing: If the chain raises anything else than DropEvent, the error is not swallowed. """ - b = build_bl(processors=[raiser(ValueError)]) + b = build_bl(processors=[raiser(CustomError)]) - with pytest.raises(ValueError): + with pytest.raises(CustomError): b._process_event("", "boom", {}) def test_last_processor_returns_string(self): @@ -223,10 +227,9 @@ class TestProcessing: """ logger = stub(msg=lambda *args, **kw: (args, kw)) b = build_bl(logger, processors=[lambda *_: object()]) - with pytest.raises(ValueError) as exc: - b._process_event("", "foo", {}) - assert exc.value.args[0].startswith("Last processor didn't return") + with pytest.raises(ValueError, match="Last processor didn't return"): + b._process_event("", "foo", {}) class TestProxying: diff --git a/tests/test_config.py b/tests/test_config.py index 4f597c6112125c3b5a7f438928a78bebf15e0189..5e741d5da11734970e8611077e634c4fd3534dd2 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -64,9 +64,6 @@ def test_default_context_class(): class TestConfigure: - def teardown_method(self, method): - structlog.reset_defaults() - def test_get_config_is_configured(self): """ Return value of structlog.get_config() works as input for @@ -84,6 +81,9 @@ class TestConfigure: assert False is structlog.is_configured() def test_configure_all(self, proxy): + """ + All configurations are applied and land on the bound logger. + """ x = stub() configure(processors=[x], context_class=dict) b = proxy.bind() @@ -92,8 +92,12 @@ class TestConfigure: assert dict is b._context.__class__ def test_reset(self, proxy): + """ + Reset resets all settings to their default values. + """ x = stub() configure(processors=[x], context_class=dict, wrapper_class=Wrapper) + structlog.reset_defaults() b = proxy.bind() @@ -104,6 +108,9 @@ class TestConfigure: assert _BUILTIN_DEFAULT_LOGGER_FACTORY is _CONFIG.logger_factory def test_just_processors(self, proxy): + """ + It's possible to only configure processors. + """ x = stub() configure(processors=[x]) b = proxy.bind() @@ -113,6 +120,9 @@ class TestConfigure: assert _BUILTIN_DEFAULT_CONTEXT_CLASS == b._context.__class__ def test_just_context_class(self, proxy): + """ + It's possible to only configure the context class. + """ configure(context_class=dict) b = proxy.bind() @@ -120,6 +130,9 @@ class TestConfigure: assert _BUILTIN_DEFAULT_PROCESSORS == b._processors def test_configure_sets_is_configured(self): + """ + After configure() is_configured() returns True. + """ assert False is _CONFIG.is_configured configure() @@ -127,6 +140,10 @@ class TestConfigure: assert True is _CONFIG.is_configured def test_configures_logger_factory(self): + """ + It's possible to configure the logger factory. + """ + def f(): pass @@ -136,9 +153,6 @@ class TestConfigure: class TestBoundLoggerLazyProxy: - def teardown_method(self, method): - structlog.reset_defaults() - def test_repr(self): """ repr reflects all attributes. @@ -362,9 +376,6 @@ class TestBoundLoggerLazyProxy: class TestFunctions: - def teardown_method(self, method): - structlog.reset_defaults() - def test_wrap_passes_args(self): """ wrap_logger propagates all arguments to the wrapped bound logger. diff --git a/tests/test_dev.py b/tests/test_dev.py index ee3e3053686002d1641cff6c8faa6ee73658e934..531e76df6493ef8418fc2d2e12bc964df957a87d 100644 --- a/tests/test_dev.py +++ b/tests/test_dev.py @@ -7,6 +7,7 @@ import pickle import sys from io import StringIO +from unittest import mock import pytest @@ -97,7 +98,7 @@ class TestConsoleRenderer: def test_event_renamed(self): """ - Uses respects if the event key has been renamed. + The main event key can be renamed. """ cr = dev.ConsoleRenderer(colors=False, event_key="msg") @@ -105,6 +106,18 @@ class TestConsoleRenderer: None, None, {"msg": "new event name", "event": "something custom"} ) + def test_timestamp_renamed(self): + """ + The timestamp key can be renamed. + """ + cr = dev.ConsoleRenderer(colors=False, timestamp_key="ts") + + assert "2023-09-07 le event" == cr( + None, + None, + {"ts": "2023-09-07", "event": "le event"}, + ) + def test_level(self, cr, styles, padded): """ Levels are rendered aligned, in square brackets, and color coded. @@ -366,7 +379,7 @@ class TestConsoleRenderer: elif explicit_ei == "exception": ei = e else: - raise ValueError() + raise ValueError from None else: ei = True @@ -510,6 +523,19 @@ class TestConsoleRenderer: pickle.dumps(r, proto) )(None, None, {"event": "foo"}) + def test_no_exception(self): + """ + If there is no exception, don't blow up. + """ + r = dev.ConsoleRenderer(colors=False) + + assert ( + "hi" + == r( + None, None, {"event": "hi", "exc_info": (None, None, None)} + ).rstrip() + ) + class TestSetExcInfo: def test_wrong_name(self): @@ -535,20 +561,19 @@ class TestSetExcInfo: assert {"exc_info": True} == dev.set_exc_info(None, "exception", {}) -@pytest.mark.skipif(dev.rich is None, reason="Needs rich.") -class TestRichTraceback: +@pytest.mark.skipif(dev.rich is None, reason="Needs Rich.") +class TestRichTracebackFormatter: def test_default(self): """ If Rich is present, it's the default. """ assert dev.default_exception_formatter is dev.rich_traceback - def test_does_not_blow_up(self): + def test_does_not_blow_up(self, sio): """ We trust Rich to do the right thing, so we just exercise the function and check the first new line that we add manually is present. """ - sio = StringIO() try: 0 / 0 except ZeroDivisionError: @@ -556,6 +581,20 @@ class TestRichTraceback: assert sio.getvalue().startswith("\n") + def test_width_minus_one(self, sio): + """ + If width is -1, it's replaced by the terminal width on first use. + """ + rtf = dev.RichTracebackFormatter(width=-1) + + with mock.patch("shutil.get_terminal_size", return_value=(42, 0)): + try: + 0 / 0 + except ZeroDivisionError: + rtf(sio, sys.exc_info()) + + assert 42 == rtf.width + @pytest.mark.skipif( dev.better_exceptions is None, reason="Needs better-exceptions." diff --git a/tests/test_frames.py b/tests/test_frames.py index 3aa878fd804baa841f01accba9cdefadafed5d3a..00263d69be55d1175411dc362d13fce02a78a0eb 100644 --- a/tests/test_frames.py +++ b/tests/test_frames.py @@ -85,7 +85,7 @@ class TestFindFirstAppFrameAndName: assert (f1, "?") == (f, n) -@pytest.fixture +@pytest.fixture() def exc_info(): """ Fake a valid exc_info. diff --git a/tests/test_generic.py b/tests/test_generic.py index 7f30ac777a12abd7bfd6fe831b6215e7da564a97..4bc7b36ef0831d7ccd30b85717ca559bbf520522 100644 --- a/tests/test_generic.py +++ b/tests/test_generic.py @@ -7,6 +7,8 @@ import pickle import pytest +from freezegun import freeze_time + from structlog._config import _CONFIG from structlog._generic import BoundLogger from structlog.testing import ReturnLogger @@ -37,21 +39,8 @@ class TestGenericBoundLogger: assert "msg" in b.__dict__ - def test_proxies_anything(self): - """ - Anything that isn't part of BoundLoggerBase gets proxied to the correct - wrapped logger methods. - """ - b = BoundLogger( - ReturnLogger(), - _CONFIG.default_processors, - _CONFIG.default_context_class(), - ) - - assert "log", "foo" == b.log("foo") - assert "gol", "bar" == b.gol("bar") - @pytest.mark.parametrize("proto", range(3, pickle.HIGHEST_PROTOCOL + 1)) + @freeze_time("2023-05-22 17:00") def test_pickle(self, proto): """ Can be pickled and unpickled. diff --git a/tests/test_log_levels.py b/tests/test_log_levels.py index 8faa576d0462e757a26afe1877e8fbcc6f8c5582..ade958e8c6a9f86cdfe0a6d14d1ceb58145ebee0 100644 --- a/tests/test_log_levels.py +++ b/tests/test_log_levels.py @@ -195,6 +195,24 @@ class TestFilteringLogger: assert isinstance(cl.calls[0][2]["exc_info"], tuple) assert exc == cl.calls[0][2]["exc_info"][1] + def test_exception_positional_args(self, bl, cl): + """ + exception allows for positional args + """ + bl.exception("%s %s", "boom", "bastic") + + assert [ + ("error", (), {"event": "boom bastic", "exc_info": True}) + ] == cl.calls + + async def test_aexception_positional_args(self, bl, cl): + """ + aexception allows for positional args + """ + await bl.aexception("%s %s", "boom", "bastic") + assert 1 == len(cl.calls) + assert "boom bastic" == cl.calls[0][2]["event"] + async def test_async_exception_true(self, bl, cl): """ aexception replaces exc_info with current exception info, if exc_info @@ -267,7 +285,7 @@ class TestFilteringLogger: assert [("info", (), {"event": "hello world -- 42!"})] == cl.calls @pytest.mark.parametrize( - "meth,args", + ("meth", "args"), [ ("aexception", ("ev",)), ("ainfo", ("ev",)), @@ -275,12 +293,17 @@ class TestFilteringLogger: ], ) async def test_async_contextvars_merged(self, meth, args, cl): + """ + Contextvars are merged into the event dict. + """ clear_contextvars() bl = make_filtering_bound_logger(logging.INFO)( cl, [merge_contextvars], {} ) bind_contextvars(context_included="yep") + await getattr(bl, meth)(*args) + assert len(cl.calls) == 1 assert "context_included" in cl.calls[0].kwargs diff --git a/tests/test_output.py b/tests/test_output.py index 7b8e986a2486e22919f88a4f454408ad425d4f11..1854c572ea86d30a1e92a2ac70115602c07fc074 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -155,7 +155,7 @@ class TestLoggers: class TestPrintLoggerFactory: def test_does_not_cache(self): """ - Due to doctest weirdness, we must not re-use PrintLoggers. + Due to doctest weirdness, we must not reuse PrintLoggers. """ f = PrintLoggerFactory() @@ -180,7 +180,7 @@ class TestPrintLoggerFactory: class TestWriteLoggerFactory: def test_does_not_cache(self): """ - Due to doctest weirdness, we must not re-use WriteLoggers. + Due to doctest weirdness, we must not reuse WriteLoggers. """ f = WriteLoggerFactory() @@ -314,7 +314,7 @@ class TestBytesLogger: class TestBytesLoggerFactory: def test_does_not_cache(self): """ - Due to doctest weirdness, we must not re-use BytesLoggers. + Due to doctest weirdness, we must not reuse BytesLoggers. """ f = BytesLoggerFactory() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 6628c00231a4a67954c1eaceb014601b1202c28e..d8c470a0088cf9c6563ce7990c9af049f24a6c60 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -3,26 +3,20 @@ # 2.0, and the MIT License. See the LICENSE file in the root of this # repository for complete details. -import sys +from importlib import metadata import pytest import structlog -if sys.version_info < (3, 8): - import importlib_metadata as metadata -else: - from importlib import metadata - - class TestLegacyMetadataHack: - def test_version(self): + def test_version(self, recwarn): """ - structlog.__version__ returns the correct version. + structlog.__version__ returns the correct version and doesn't warn. """ - with pytest.deprecated_call(): - assert metadata.version("structlog") == structlog.__version__ + assert metadata.version("structlog") == structlog.__version__ + assert [] == recwarn.list def test_description(self): """ diff --git a/tests/test_processors.py b/tests/test_processors.py index b6e74e9fcc145d27b211e189f8326d387b4d2e31..c86283023c2e98d20762b3c389797b73c0c1f86f 100644 --- a/tests/test_processors.py +++ b/tests/test_processors.py @@ -3,6 +3,8 @@ # 2.0, and the MIT License. See the LICENSE file in the root of this # repository for complete details. +from __future__ import annotations + import datetime import functools import inspect @@ -15,7 +17,6 @@ import sys import threading from io import StringIO -from typing import Any, Dict, List, Optional, Set import pytest @@ -34,6 +35,7 @@ from structlog.processors import ( JSONRenderer, KeyValueRenderer, LogfmtRenderer, + MaybeTimeStamper, StackInfoRenderer, TimeStamper, UnicodeDecoder, @@ -46,6 +48,7 @@ from structlog.stdlib import ProcessorFormatter from structlog.threadlocal import wrap_dict from structlog.typing import EventDict from tests.additional_frame import additional_frame +from tests.utils import CustomError try: @@ -284,11 +287,9 @@ class TestLogfmtRenderer: "invalid key": "somevalue", } - with pytest.raises(ValueError) as e: + with pytest.raises(ValueError, match='Invalid key: "invalid key"'): LogfmtRenderer()(None, None, event_dict) - assert 'Invalid key: "invalid key"' == e.value.args[0] - class TestJSONRenderer: def test_renders_json(self, event_dict): @@ -365,11 +366,11 @@ class TestTimeStamper: A asking for a UNIX timestamp with a timezone that's not UTC raises a ValueError. """ - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, match="UNIX timestamps are always UTC." + ): TimeStamper(utc=False) - assert "UNIX timestamps are always UTC." == e.value.args[0] - def test_inserts_utc_unix_timestamp_by_default(self): """ Per default a float UNIX timestamp is used. @@ -440,21 +441,34 @@ class TestTimeStamper: None, None, {} ) - @pytest.mark.parametrize( - ("utc", "expect"), - [ - (True, "1980-03-25T16:00:00Z"), - (False, "1980-03-25T17:00:00"), - ], - ) - def test_apply_freezegun_after_instantiation(self, utc, expect): + def test_apply_freezegun_after_instantiation(self): """ - Instantiate TimeStamper after mocking datetime + Freezing time after instantiation of TimeStamper works. """ - ts = TimeStamper(fmt="iso", utc=utc) + ts = TimeStamper(fmt="iso", utc=False) + with freeze_time("1980-03-25 16:00:00", tz_offset=1): d = ts(None, None, {}) - assert expect == d["timestamp"] + + assert "1980-03-25T17:00:00" == d["timestamp"] + + +class TestMaybeTimeStamper: + def test_overwrite(self): + """ + If there is a timestamp, leave it. + """ + mts = MaybeTimeStamper() + + assert {"timestamp": 42} == mts(None, None, {"timestamp": 42}) + + def test_none(self): + """ + If there is no timestamp, add one. + """ + mts = MaybeTimeStamper() + + assert "timestamp" in mts(None, None, {}) class TestFormatExcInfo: @@ -462,13 +476,16 @@ class TestFormatExcInfo: """ The exception formatter can be changed. """ + formatter = ExceptionRenderer(lambda _: "There is no exception!") + try: - raise ValueError("test") - except ValueError as e: - formatter = ExceptionRenderer(lambda _: "There is no exception!") - assert formatter(None, None, {"exc_info": e}) == { - "exception": "There is no exception!" - } + raise CustomError("test") + except CustomError as e: + exc = e + + assert formatter(None, None, {"exc_info": exc}) == { + "exception": "There is no exception!" + } @pytest.mark.parametrize("ei", [False, None, ""]) def test_nop(self, ei): @@ -509,18 +526,21 @@ class TestFormatExcInfo: def test_exception(self): """ - Passing exceptions as exc_info is valid on Python 3. + Passing exceptions as exc_info is valid. """ + formatter = ExceptionRenderer(lambda exc_info: exc_info) + try: raise ValueError("test") except ValueError as e: - formatter = ExceptionRenderer(lambda exc_info: exc_info) - d = formatter(None, None, {"exc_info": e}) - - assert {"exception": (ValueError, e, e.__traceback__)} == d + exc = e else: pytest.fail("Exception not raised.") + assert { + "exception": (ValueError, exc, exc.__traceback__) + } == formatter(None, None, {"exc_info": exc}) + def test_exception_without_traceback(self): """ If an Exception is missing a traceback, render it anyway. @@ -540,7 +560,17 @@ class TestFormatExcInfo: except ValueError as e: a = format_exc_info(None, None, {"exc_info": e}) b = ExceptionRenderer()(None, None, {"exc_info": e}) - assert a == b + + assert a == b + + @pytest.mark.parametrize("ei", [True, (None, None, None)]) + def test_no_exception(self, ei): + """ + A missing exception does not blow up. + """ + assert {"exception": "MISSING"} == format_exc_info( + None, None, {"exc_info": ei} + ) class TestUnicodeEncoder: @@ -700,7 +730,7 @@ class TestExceptionPrettyPrinter: assert "XXX" in sio.getvalue() -@pytest.fixture +@pytest.fixture() def sir(): return StackInfoRenderer() @@ -723,6 +753,9 @@ class TestStackInfoRenderer: assert "stack" in ed def test_renders_correct_stack(self, sir): + """ + The rendered stack is correct. + """ ed = sir(None, None, {"stack_info": True}) assert 'ed = sir(None, None, {"stack_info": True})' in ed["stack"] @@ -796,37 +829,33 @@ class TestCallsiteParameterAdder: "determine the callsite for async calls." ) ) - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_async(self) -> None: """ Callsite information for async invocations are correct. """ - try: - string_io = StringIO() - - class StingIOLogger(structlog.PrintLogger): - def __init__(self): - super().__init__(file=string_io) - - processor = self.make_processor(None, ["concurrent", "threading"]) - structlog.configure( - processors=[processor, JSONRenderer()], - logger_factory=StingIOLogger, - wrapper_class=structlog.stdlib.AsyncBoundLogger, - cache_logger_on_first_use=True, - ) + string_io = StringIO() - logger = structlog.stdlib.get_logger() + class StingIOLogger(structlog.PrintLogger): + def __init__(self): + super().__init__(file=string_io) - callsite_params = self.get_callsite_parameters() - await logger.info("baz") + processor = self.make_processor(None, ["concurrent", "threading"]) + structlog.configure( + processors=[processor, JSONRenderer()], + logger_factory=StingIOLogger, + wrapper_class=structlog.stdlib.AsyncBoundLogger, + cache_logger_on_first_use=True, + ) - assert {"event": "baz", **callsite_params} == json.loads( - string_io.getvalue() - ) + logger = structlog.stdlib.get_logger() - finally: - structlog.reset_defaults() + callsite_params = self.get_callsite_parameters() + await logger.info("baz") + + assert {"event": "baz", **callsite_params} == json.loads( + string_io.getvalue() + ) def test_additional_ignores(self, monkeypatch: pytest.MonkeyPatch) -> None: """ @@ -859,7 +888,7 @@ class TestCallsiteParameterAdder: assert expected == actual @pytest.mark.parametrize( - "origin, parameter_strings", + ("origin", "parameter_strings"), itertools.product( ["logging", "structlog"], [ @@ -875,7 +904,7 @@ class TestCallsiteParameterAdder: def test_processor( self, origin: str, - parameter_strings: Optional[Set[str]], + parameter_strings: set[str] | None, ): """ The correct callsite parameters are added to event dictionaries. @@ -922,7 +951,7 @@ class TestCallsiteParameterAdder: assert expected == actual @pytest.mark.parametrize( - "setup, origin, parameter_strings", + ("setup", "origin", "parameter_strings"), itertools.product( ["common-without-pre", "common-with-pre", "shared", "everywhere"], ["logging", "structlog"], @@ -940,7 +969,7 @@ class TestCallsiteParameterAdder: self, setup: str, origin: str, - parameter_strings: Optional[Set[str]], + parameter_strings: set[str] | None, ) -> None: """ Logging output contains the correct callsite parameters. @@ -983,7 +1012,7 @@ class TestCallsiteParameterAdder: callsite_params = self.get_callsite_parameters() logger.info(test_message) elif origin == "structlog": - ctx: Dict[str, Any] = {} + ctx = {} bound_logger = BoundLogger( logger, [*common_processors, ProcessorFormatter.wrap_for_formatter], @@ -1012,45 +1041,50 @@ class TestCallsiteParameterAdder: @classmethod def make_processor( cls, - parameter_strings: Optional[Set[str]], - additional_ignores: Optional[List[str]] = None, + parameter_strings: set[str] | None, + additional_ignores: list[str] | None = None, ) -> CallsiteParameterAdder: """ Creates a ``CallsiteParameterAdder`` with parameters matching the supplied ``parameter_strings`` values and with the supplied ``additional_ignores`` values. - :param parameter_strings: - Strings for which corresponding ``CallsiteParameters`` should be - included in the resulting ``CallsiteParameterAdded``. - :param additional_ignores: - Used as ``additional_ignores`` for the resulting - ``CallsiteParameterAdded``. + Arguments: + + parameter_strings: + Strings for which corresponding ``CallsiteParameters`` should + be included in the resulting ``CallsiteParameterAdded``. + + additional_ignores: + + Used as ``additional_ignores`` for the resulting + ``CallsiteParameterAdded``. """ if parameter_strings is None: return CallsiteParameterAdder( additional_ignores=additional_ignores ) - else: - parameters = cls.filter_parameters(parameter_strings) - return CallsiteParameterAdder( - parameters=parameters, - additional_ignores=additional_ignores, - ) + + parameters = cls.filter_parameters(parameter_strings) + return CallsiteParameterAdder( + parameters=parameters, + additional_ignores=additional_ignores, + ) @classmethod def filter_parameters( - cls, parameter_strings: Optional[Set[str]] - ) -> Set[CallsiteParameter]: + cls, parameter_strings: set[str] | None + ) -> set[CallsiteParameter]: """ Returns a set containing all ``CallsiteParameter`` members with values that are in ``parameter_strings``. - :param parameter_strings: - The parameters strings for which corresponding - ``CallsiteParameter`` members should be - returned. If this value is `None` then all - ``CallsiteParameter`` will be returned. + Arguments: + + parameter_strings: + The parameters strings for which corresponding + ``CallsiteParameter`` members should be returned. If this value + is `None` then all ``CallsiteParameter`` will be returned. """ if parameter_strings is None: return cls._all_parameters @@ -1062,15 +1096,17 @@ class TestCallsiteParameterAdder: @classmethod def filter_parameter_dict( - cls, input: Dict[str, Any], parameter_strings: Optional[Set[str]] - ) -> Dict[str, Any]: + cls, input: dict[str, object], parameter_strings: set[str] | None + ) -> dict[str, object]: """ Returns a dictionary that is equivalent to ``input`` but with all keys not in ``parameter_strings`` removed. - :param parameter_strings: - The keys to keep in the dictionary, if this value is ``None`` then - all keys matching ``cls.parameter_strings`` are kept. + Arguments: + + parameter_strings: + The keys to keep in the dictionary, if this value is ``None`` + then all keys matching ``cls.parameter_strings`` are kept. """ if parameter_strings is None: parameter_strings = cls.parameter_strings @@ -1081,14 +1117,16 @@ class TestCallsiteParameterAdder: } @classmethod - def get_callsite_parameters(cls, offset: int = 1) -> Dict[str, Any]: + def get_callsite_parameters(cls, offset: int = 1) -> dict[str, object]: """ This function creates dictionary of callsite parameters for the line that is ``offset`` lines after the invocation of this function. - :param offset: - The amount of lines after the invocation of this function that - callsite parameters should be generated for. + Arguments: + + offset: + The amount of lines after the invocation of this function that + callsite parameters should be generated for. """ frame_info = inspect.stack()[1] frame_traceback = inspect.getframeinfo(frame_info[0]) diff --git a/tests/test_stdlib.py b/tests/test_stdlib.py index d479438e30d2c9d7e2d82c49bc73161991540be1..4e3c9f56d2198fc1a518c80983d96e9ebad23cff 100644 --- a/tests/test_stdlib.py +++ b/tests/test_stdlib.py @@ -3,6 +3,8 @@ # 2.0, and the MIT License. See the LICENSE file in the root of this # repository for complete details. +from __future__ import annotations + import json import logging import logging.config @@ -10,7 +12,7 @@ import os import sys from io import StringIO -from typing import Any, Callable, Collection, Dict, Optional, Set +from typing import Any, Callable, Collection import pytest import pytest_asyncio @@ -22,7 +24,6 @@ from structlog import ( ReturnLogger, configure, get_context, - reset_defaults, ) from structlog._config import _CONFIG from structlog._log_levels import _NAME_TO_LEVEL, CRITICAL, WARN @@ -108,7 +109,11 @@ class TestLoggerFactory: ) def test_deduces_correct_caller(self): + """ + It will find the correct caller. + """ logger = _FixedFindCallerLogger("test") + file_name, line_number, func_name = logger.findCaller()[:3] assert file_name == os.path.realpath(__file__) @@ -124,21 +129,25 @@ class TestLoggerFactory: assert "testing, is_, fun" in stack_info def test_no_stack_info_by_default(self): + """ + If we don't ask for stack_info, it won't be returned. + """ logger = _FixedFindCallerLogger("test") testing, is_, fun, stack_info = logger.findCaller() assert None is stack_info - def test_find_caller(self, monkeypatch): + def test_find_caller(self, caplog): + """ + The caller is found. + """ logger = LoggerFactory()() - log_handle = call_recorder(lambda x: None) - monkeypatch.setattr(logger, "handle", log_handle) + logger.error("Test") - log_record = log_handle.calls[0].args[0] - assert log_record.funcName == "test_find_caller" - assert log_record.name == __name__ - assert log_record.filename == os.path.basename(__file__) + assert caplog.text.startswith( + "ERROR tests.test_stdlib:test_stdlib.py" + ) def test_sets_correct_logger(self): """ @@ -237,7 +246,7 @@ class TestBoundLogger: assert bound_logger_attribute == stdlib_logger_attribute @pytest.mark.parametrize( - "method_name,method_args", + ("method_name", "method_args"), [ ("addHandler", [None]), ("removeHandler", [None]), @@ -467,7 +476,7 @@ class TestPositionalArgumentsFormatter: class TestAddLogLevelNumber: - @pytest.mark.parametrize("level, number", _NAME_TO_LEVEL.items()) + @pytest.mark.parametrize(("level", "number"), _NAME_TO_LEVEL.items()) def test_log_level_number_added(self, level, number): """ The log level number is added to the event dict. @@ -539,7 +548,7 @@ class TestAddLoggerName: assert name == event_dict["logger"] -def extra_dict() -> Dict[str, Any]: +def extra_dict() -> dict[str, Any]: """ A dict to be passed in the `extra` parameter of the `logging` module's log methods. @@ -559,11 +568,11 @@ def extra_dict_fixture(): class TestExtraAdder: @pytest.mark.parametrize( - "allow, misses", + ("allow", "misses"), [ (None, None), ({}, None), - *[({key}, None) for key in extra_dict().keys()], + *[({key}, None) for key in extra_dict()], ({"missing"}, {"missing"}), ({"missing", "keys"}, {"missing"}), ({"this", "x_int"}, None), @@ -572,9 +581,9 @@ class TestExtraAdder: def test_add_extra( self, make_log_record: Callable[[], logging.LogRecord], - extra_dict: Dict[str, Any], - allow: Optional[Collection[str]], - misses: Optional[Set[str]], + extra_dict: dict[str, Any], + allow: Collection[str] | None, + misses: set[str] | None, ): """ Extra attributes of a LogRecord object are added to the event dict. @@ -601,11 +610,11 @@ class TestExtraAdder: assert {} == actual @pytest.mark.parametrize( - "allow, misses", + ("allow", "misses"), [ (None, None), ({}, None), - *[({key}, None) for key in extra_dict().keys()], + *[({key}, None) for key in extra_dict()], ({"missing"}, {"missing"}), ({"missing", "keys"}, {"missing"}), ({"this", "x_int"}, None), @@ -613,9 +622,9 @@ class TestExtraAdder: ) def test_add_extra_e2e( self, - extra_dict: Dict[str, Any], - allow: Optional[Collection[str]], - misses: Optional[Set[str]], + extra_dict: dict[str, Any], + allow: Collection[str] | None, + misses: set[str] | None, ): """ Values passed in the `extra` parameter of the `logging` module's log @@ -652,20 +661,18 @@ class TestExtraAdder: def _copy_allowed( cls, event_dict: EventDict, - extra_dict: Dict[str, Any], - allow: Optional[Collection[str]], + extra_dict: dict[str, Any], + allow: Collection[str] | None, ) -> EventDict: if allow is None: return {**event_dict, **extra_dict} - else: - return { - **event_dict, - **{ - key: value - for key, value in extra_dict.items() - if key in allow - }, - } + + return { + **event_dict, + **{ + key: value for key, value in extra_dict.items() if key in allow + }, + } class TestRenderToLogKW: @@ -720,7 +727,8 @@ def _configure_for_processor_formatter(): """ Configure structlog to use ProcessorFormatter. - Reset both structlog and logging setting after the test. + Reset logging setting after the test (structlog is reset automatically + before all tests). """ configure( processors=[add_log_level, ProcessorFormatter.wrap_for_formatter], @@ -731,14 +739,13 @@ def _configure_for_processor_formatter(): yield logging.basicConfig() - reset_defaults() def configure_logging( pre_chain, logger=None, pass_foreign_args=False, - renderer=ConsoleRenderer(colors=False), + renderer=ConsoleRenderer(colors=False), # noqa: B008 ): """ Configure logging to use ProcessorFormatter. @@ -831,7 +838,7 @@ class TestProcessorFormatter: If `pass_foreign_args` is `True` we set the `positional_args` key in the `event_dict` before clearing args. """ - test_processor = call_recorder(lambda l, m, event_dict: event_dict) + test_processor = call_recorder(lambda _, __, event_dict: event_dict) configure_logging((test_processor,), pass_foreign_args=True) positional_args = {"foo": "bar"} @@ -910,7 +917,7 @@ class TestProcessorFormatter: If non-structlog record contains exc_info, foreign_pre_chain functions have access to it. """ - test_processor = call_recorder(lambda l, m, event_dict: event_dict) + test_processor = call_recorder(lambda _, __, event_dict: event_dict) configure_logging((test_processor,), renderer=KeyValueRenderer()) try: @@ -929,26 +936,26 @@ class TestProcessorFormatter: ProcessorFormatter should not have changed it. """ - class MyException(Exception): + class MyError(Exception): pass def add_excinfo(logger, log_method, event_dict): event_dict["exc_info"] = sys.exc_info() return event_dict - test_processor = call_recorder(lambda l, m, event_dict: event_dict) + test_processor = call_recorder(lambda _, __, event_dict: event_dict) configure_logging( (add_excinfo, test_processor), renderer=KeyValueRenderer() ) try: - raise MyException("oh no") + raise MyError("oh no") except Exception: logging.getLogger().error("okay") event_dict = test_processor.calls[0].args[2] - assert MyException is event_dict["exc_info"][0] + assert MyError is event_dict["exc_info"][0] def test_other_handlers_get_original_record(self): """ @@ -1122,10 +1129,6 @@ async def _abl(cl): return AsyncBoundLogger(cl, context={}, processors=[]) -@pytest.mark.skipif( - sys.version_info[:2] < (3, 7), - reason="AsyncBoundLogger is only for Python 3.7 and later.", -) class TestAsyncBoundLogger: def test_sync_bl(self, abl, cl): """ @@ -1137,14 +1140,14 @@ class TestAsyncBoundLogger: CapturedCall(method_name="info", args=(), kwargs={"event": "test"}) ] == cl.calls - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_protocol(self, abl): """ AsyncBoundLogger is a proper BindableLogger. """ assert isinstance(abl, BindableLogger) - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_correct_levels(self, abl, cl, stdlib_log_method): """ The proxy methods call the correct upstream methods. @@ -1154,14 +1157,11 @@ class TestAsyncBoundLogger: aliases = {"exception": "error", "warn": "warning"} alias = aliases.get(stdlib_log_method) - if alias: - expect = alias - else: - expect = stdlib_log_method + expect = alias if alias else stdlib_log_method assert expect == cl.calls[0].method_name - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_log_method(self, abl, cl): """ The `log` method is proxied too. @@ -1170,7 +1170,7 @@ class TestAsyncBoundLogger: assert "error" == cl.calls[0].method_name - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_exception(self, abl, cl): """ `exception` makes sure 'exc_info" is set, if it's not set already. @@ -1185,7 +1185,7 @@ class TestAsyncBoundLogger: assert ValueError is ei[0] assert ("omg",) == ei[1].args - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_exception_do_not_overwrite(self, abl, cl): """ `exception` leaves exc_info be, if it's set. @@ -1202,7 +1202,7 @@ class TestAsyncBoundLogger: ei = cl.calls[0].kwargs["exc_info"] assert (o1, o2, o3) == ei - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_bind_unbind(self, cl): """ new/bind/unbind/try_unbind are correctly propagated. @@ -1233,7 +1233,7 @@ class TestAsyncBoundLogger: assert {} == l5._context assert l4 is not l5 - @pytest.mark.asyncio + @pytest.mark.asyncio() async def test_integration(self, capsys): """ Configure and log an actual entry. @@ -1257,16 +1257,12 @@ class TestAsyncBoundLogger: "level": "info", } == json.loads(capsys.readouterr().out) - reset_defaults() - @pytest.mark.parametrize("log_level", [None, 45]) def test_recreate_defaults(log_level): """ Recreate defaults configures structlog and -- if asked -- logging. """ - reset_defaults() - logging.basicConfig( stream=sys.stderr, level=1, @@ -1279,11 +1275,6 @@ def test_recreate_defaults(log_level): assert dict is _CONFIG.default_context_class assert isinstance(_CONFIG.logger_factory, LoggerFactory) - # 3.7 doesn't have the force keyword and we don't care enough to - # re-implement it. - if sys.version_info < (3, 8): - return - log = get_logger().bind() if log_level is not None: assert log_level == log.getEffectiveLevel() diff --git a/tests/test_testing.py b/tests/test_testing.py index 99b4a75ccbeaadcdcf995afe3165782a0850a2fa..1283ddee3d0249408ec0d5632c7bfcd8eb381d9d 100644 --- a/tests/test_testing.py +++ b/tests/test_testing.py @@ -5,7 +5,7 @@ import pytest -from structlog import get_config, get_logger, reset_defaults, testing +from structlog import get_config, get_logger, testing from structlog.testing import ( CapturedCall, CapturingLogger, @@ -17,10 +17,6 @@ from structlog.testing import ( class TestCaptureLogs: - @classmethod - def teardown_class(cls): - reset_defaults() - def test_captures_logs(self): """ Log entries are captured and retain their structure. @@ -64,9 +60,8 @@ class TestCaptureLogs: """ orig_procs = self.get_active_procs() - with pytest.raises(NotImplementedError): - with testing.capture_logs(): - raise NotImplementedError("from test") + with pytest.raises(NotImplementedError), testing.capture_logs(): + raise NotImplementedError("from test") assert orig_procs is self.get_active_procs() diff --git a/tests/test_threadlocal.py b/tests/test_threadlocal.py index eac054c49137a4822dc9e2e2e6e90e6d01ab757f..3e58c7bdf58bcde743fba7157b3504acb949511a 100644 --- a/tests/test_threadlocal.py +++ b/tests/test_threadlocal.py @@ -26,6 +26,7 @@ from structlog.threadlocal import ( unbind_threadlocal, wrap_dict, ) +from tests.utils import CustomError try: @@ -89,14 +90,16 @@ class TestTmpBind: tmp_bind cleans up properly on exceptions. """ log = log.bind(y=23) - with pytest.raises(ValueError), pytest.deprecated_call(): - with tmp_bind(log, x=42, y="foo") as tmp_log: - assert ( - {"y": "foo", "x": 42} - == tmp_log._context._dict - == log._context._dict - ) - raise ValueError + + with pytest.raises( # noqa: PT012 + CustomError + ), pytest.deprecated_call(), tmp_bind(log, x=42, y="foo") as tmp_log: + assert ( + {"y": "foo", "x": 42} + == tmp_log._context._dict + == log._context._dict + ) + raise CustomError assert {"y": 23} == log._context._dict @@ -408,9 +411,8 @@ class TestBoundThreadlocal: """ Bindings are cleaned up """ - with pytest.deprecated_call(): - with bound_threadlocal(x=42, y="foo"): - assert {"x": 42, "y": "foo"} == get_threadlocal() + with pytest.deprecated_call(), bound_threadlocal(x=42, y="foo"): + assert {"x": 42, "y": "foo"} == get_threadlocal() with pytest.deprecated_call(): assert {} == get_threadlocal() @@ -435,10 +437,9 @@ class TestBoundThreadlocal: """ New bindings inside bound_threadlocal are preserved after the clean up """ - with pytest.deprecated_call(): - with bound_threadlocal(x=42): - bind_threadlocal(y="foo") - assert {"x": 42, "y": "foo"} == get_threadlocal() + with pytest.deprecated_call(), bound_threadlocal(x=42): + bind_threadlocal(y="foo") + assert {"x": 42, "y": "foo"} == get_threadlocal() with pytest.deprecated_call(): assert {"y": "foo"} == get_threadlocal() diff --git a/tests/test_tracebacks.py b/tests/test_tracebacks.py index fd55ec6a235f918763522b7c5ad709c3e9ee4a00..b3f762eaf7d66f99829344cf02c9f25a57bce996 100644 --- a/tests/test_tracebacks.py +++ b/tests/test_tracebacks.py @@ -1,19 +1,31 @@ +# SPDX-License-Identifier: MIT OR Apache-2.0 +# This file is dual licensed under the terms of the Apache License, Version +# 2.0, and the MIT License. See the LICENSE file in the root of this +# repository for complete details. + +from __future__ import annotations + +import inspect import sys from pathlib import Path -from typing import Any, Dict, Optional +from typing import Any import pytest from structlog import tracebacks -@pytest.mark.parametrize("data, expected", [(3, "3"), ("spam", "spam")]) +def get_next_lineno() -> int: + return inspect.currentframe().f_back.f_lineno + 1 + + +@pytest.mark.parametrize(("data", "expected"), [(3, "3"), ("spam", "spam")]) def test_save_str(data: Any, expected: str): """ "safe_str()" returns the str repr of an object. """ - assert tracebacks.safe_str(data) == expected + assert expected == tracebacks.safe_str(data) def test_safe_str_error(): @@ -25,12 +37,14 @@ def test_safe_str_error(): def __str__(self) -> str: raise ValueError("BAAM!") - pytest.raises(ValueError, str, Baam()) - assert tracebacks.safe_str(Baam()) == "<str-error 'BAAM!'>" + with pytest.raises(ValueError, match="BAAM!"): + str(Baam()) + + assert "<str-error 'BAAM!'>" == tracebacks.safe_str(Baam()) @pytest.mark.parametrize( - "data, max_len, expected", + ("data", "max_len", "expected"), [ (3, None, "3"), ("spam", None, "spam"), @@ -40,8 +54,11 @@ def test_safe_str_error(): ("bacon", 5, "bacon"), ], ) -def test_to_repr(data: Any, max_len: Optional[int], expected: str): - assert tracebacks.to_repr(data, max_string=max_len) == expected +def test_to_repr(data: Any, max_len: int | None, expected: str): + """ + "to_repr()" returns the repr of an object, trimmed to max_len. + """ + assert expected == tracebacks.to_repr(data, max_string=max_len) def test_to_repr_error(): @@ -53,8 +70,10 @@ def test_to_repr_error(): def __repr__(self) -> str: raise ValueError("BAAM!") - pytest.raises(ValueError, repr, Baam()) - assert tracebacks.to_repr(Baam()) == "<repr-error 'BAAM!'>" + with pytest.raises(ValueError, match="BAAM!"): + repr(Baam()) + + assert "<repr-error 'BAAM!'>" == tracebacks.to_repr(Baam()) def test_simple_exception(): @@ -62,11 +81,12 @@ def test_simple_exception(): Tracebacks are parsed for simple, single exceptions. """ try: + lineno = get_next_lineno() 1 / 0 except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ZeroDivisionError", exc_value="division by zero", @@ -75,14 +95,14 @@ def test_simple_exception(): frames=[ tracebacks.Frame( filename=__file__, - lineno=65, + lineno=lineno, name="test_simple_exception", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_raise_hide_cause(): @@ -94,11 +114,12 @@ def test_raise_hide_cause(): try: 1 / 0 except ArithmeticError: + lineno = get_next_lineno() raise ValueError("onoes") from None except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ValueError", exc_value="onoes", @@ -107,14 +128,14 @@ def test_raise_hide_cause(): frames=[ tracebacks.Frame( filename=__file__, - lineno=97, + lineno=lineno, name="test_raise_hide_cause", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_raise_with_cause(): @@ -124,13 +145,15 @@ def test_raise_with_cause(): """ try: try: + lineno_1 = get_next_lineno() 1 / 0 except ArithmeticError as orig_exc: + lineno_2 = get_next_lineno() raise ValueError("onoes") from orig_exc except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ValueError", exc_value="onoes", @@ -139,7 +162,7 @@ def test_raise_with_cause(): frames=[ tracebacks.Frame( filename=__file__, - lineno=129, + lineno=lineno_2, name="test_raise_with_cause", line="", locals=None, @@ -154,14 +177,14 @@ def test_raise_with_cause(): frames=[ tracebacks.Frame( filename=__file__, - lineno=127, + lineno=lineno_1, name="test_raise_with_cause", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_raise_with_cause_no_tb(): @@ -169,11 +192,12 @@ def test_raise_with_cause_no_tb(): If an exception's cause has no traceback, that cause is ignored. """ try: + lineno = get_next_lineno() raise ValueError("onoes") from RuntimeError("I am fake") except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ValueError", exc_value="onoes", @@ -182,14 +206,14 @@ def test_raise_with_cause_no_tb(): frames=[ tracebacks.Frame( filename=__file__, - lineno=172, + lineno=lineno, name="test_raise_with_cause_no_tb", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_raise_nested(): @@ -199,13 +223,15 @@ def test_raise_nested(): """ try: try: + lineno_1 = get_next_lineno() 1 / 0 except ArithmeticError: - raise ValueError("onoes") + lineno_2 = get_next_lineno() + raise ValueError("onoes") # noqa: B904 except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ValueError", exc_value="onoes", @@ -214,7 +240,7 @@ def test_raise_nested(): frames=[ tracebacks.Frame( filename=__file__, - lineno=204, + lineno=lineno_2, name="test_raise_nested", line="", locals=None, @@ -229,14 +255,14 @@ def test_raise_nested(): frames=[ tracebacks.Frame( filename=__file__, - lineno=202, + lineno=lineno_1, name="test_raise_nested", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_raise_no_msg(): @@ -245,11 +271,12 @@ def test_raise_no_msg(): string. """ try: + lineno = get_next_lineno() raise RuntimeError except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="RuntimeError", exc_value="", @@ -258,14 +285,14 @@ def test_raise_no_msg(): frames=[ tracebacks.Frame( filename=__file__, - lineno=248, + lineno=lineno, name="test_raise_no_msg", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_syntax_error(): @@ -274,11 +301,12 @@ def test_syntax_error(): """ try: # raises SyntaxError: invalid syntax - eval("2 +* 2") + lineno = get_next_lineno() + eval("2 +* 2") # noqa: PGH001 except SyntaxError as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="SyntaxError", exc_value="invalid syntax (<string>, line 1)", @@ -293,14 +321,14 @@ def test_syntax_error(): frames=[ tracebacks.Frame( filename=__file__, - lineno=277, + lineno=lineno, name="test_syntax_error", line="", locals=None, ), ], ), - ] + ] == trace.stacks def test_filename_with_bracket(): @@ -308,11 +336,12 @@ def test_filename_with_bracket(): Filenames with brackets (e.g., "<string>") are handled properly. """ try: + lineno = get_next_lineno() exec(compile("1/0", filename="<string>", mode="exec")) except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ZeroDivisionError", exc_value="division by zero", @@ -321,7 +350,7 @@ def test_filename_with_bracket(): frames=[ tracebacks.Frame( filename=__file__, - lineno=311, + lineno=lineno, name="test_filename_with_bracket", line="", locals=None, @@ -335,7 +364,7 @@ def test_filename_with_bracket(): ), ], ), - ] + ] == trace.stacks def test_filename_not_a_file(): @@ -343,11 +372,12 @@ def test_filename_not_a_file(): "Invalid" filenames are appended to CWD as if they were actual files. """ try: + lineno = get_next_lineno() exec(compile("1/0", filename="string", mode="exec")) except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="ZeroDivisionError", exc_value="division by zero", @@ -356,7 +386,7 @@ def test_filename_not_a_file(): frames=[ tracebacks.Frame( filename=__file__, - lineno=346, + lineno=lineno, name="test_filename_not_a_file", line="", locals=None, @@ -370,7 +400,7 @@ def test_filename_not_a_file(): ), ], ), - ] + ] == trace.stacks def test_show_locals(): @@ -411,6 +441,7 @@ def test_recursive(): return foo(n) try: + lineno = get_next_lineno() foo(1) except Exception as e: trace = tracebacks.extract(type(e), e, e.__traceback__) @@ -418,7 +449,7 @@ def test_recursive(): frames = trace.stacks[0].frames trace.stacks[0].frames = [] - assert trace.stacks == [ + assert [ tracebacks.Stack( exc_type="RecursionError", exc_value="maximum recursion depth exceeded", @@ -426,91 +457,102 @@ def test_recursive(): is_cause=False, frames=[], ), - ] + ] == trace.stacks assert ( len(frames) > sys.getrecursionlimit() - 50 ) # Buffer for frames from pytest - assert frames[0] == tracebacks.Frame( - filename=__file__, - lineno=414, - name="test_recursive", + assert ( + tracebacks.Frame( + filename=__file__, + lineno=lineno, + name="test_recursive", + ) + == frames[0] ) # Depending on whether we invoke pytest directly or run tox, either "foo()" # or "bar()" is at the end of the stack. assert frames[-1] in [ tracebacks.Frame( filename=__file__, - lineno=408, + lineno=lineno - 7, name="foo", ), tracebacks.Frame( filename=__file__, - lineno=411, + lineno=lineno - 4, name="bar", ), ] def test_json_traceback(): + """ + Tracebacks are formatted to JSON with all information. + """ try: + lineno = get_next_lineno() 1 / 0 except Exception as e: format_json = tracebacks.ExceptionDictTransformer(show_locals=False) result = format_json((type(e), e, e.__traceback__)) - assert result == [ - { - "exc_type": "ZeroDivisionError", - "exc_value": "division by zero", - "frames": [ - { - "filename": __file__, - "line": "", - "lineno": 456, - "locals": None, - "name": "test_json_traceback", - } - ], - "is_cause": False, - "syntax_error": None, - }, - ] + + assert [ + { + "exc_type": "ZeroDivisionError", + "exc_value": "division by zero", + "frames": [ + { + "filename": __file__, + "line": "", + "lineno": lineno, + "locals": None, + "name": "test_json_traceback", + } + ], + "is_cause": False, + "syntax_error": None, + }, + ] == result def test_json_traceback_locals_max_string(): + """ + Local variables in each frame are trimmed to locals_max_string. + """ try: - _var = "spamspamspam" # noqa + _var = "spamspamspam" + lineno = get_next_lineno() 1 / 0 except Exception as e: result = tracebacks.ExceptionDictTransformer(locals_max_string=4)( (type(e), e, e.__traceback__) ) - assert result == [ - { - "exc_type": "ZeroDivisionError", - "exc_value": "division by zero", - "frames": [ - { - "filename": __file__, - "line": "", - "lineno": 482, - "locals": { - "_var": "'spam'+8", - "e": "'Zero'+33", - }, - "name": "test_json_traceback_locals_max_string", - } - ], - "is_cause": False, - "syntax_error": None, - }, - ] + assert [ + { + "exc_type": "ZeroDivisionError", + "exc_value": "division by zero", + "frames": [ + { + "filename": __file__, + "line": "", + "lineno": lineno, + "locals": { + "_var": "'spam'+8", + "e": "'Zero'+33", + "lineno": str(lineno), + }, + "name": "test_json_traceback_locals_max_string", + } + ], + "is_cause": False, + "syntax_error": None, + }, + ] == result @pytest.mark.parametrize( - "max_frames, expected_frames, skipped_idx, skipped_count", + ("max_frames", "expected_frames", "skipped_idx", "skipped_count"), [ - # (0, 1, 0, 3), - # (1, 1, 0, 3), (2, 3, 1, 2), (3, 3, 1, 2), (4, 4, -1, 0), @@ -520,6 +562,11 @@ def test_json_traceback_locals_max_string(): def test_json_traceback_max_frames( max_frames: int, expected_frames: int, skipped_idx: int, skipped_count: int ): + """ + Only max_frames frames are included in the traceback and the skipped frames + are reported. + """ + def spam(): return 1 / 0 @@ -559,5 +606,10 @@ def test_json_traceback_max_frames( {"max_frames": 1}, ], ) -def test_json_traceback_value_error(kwargs: Dict[str, Any]): - pytest.raises(ValueError, tracebacks.ExceptionDictTransformer, **kwargs) +def test_json_traceback_value_error(kwargs): + """ + Wrong arguments to ExceptionDictTransformer raise a ValueError that + contains the name of the argument.. + """ + with pytest.raises(ValueError, match=next(iter(kwargs.keys()))): + tracebacks.ExceptionDictTransformer(**kwargs) diff --git a/tests/test_twisted.py b/tests/test_twisted.py index 5b9303198381e8389d5dbe285ed2e0ab5625ab67..902f430c99678c318ad829536954f80c1cb12217 100644 --- a/tests/test_twisted.py +++ b/tests/test_twisted.py @@ -35,6 +35,9 @@ except ImportError: def test_LoggerFactory(): + """ + Logger factory ultimately returns twisted.python.log for output. + """ from twisted.python import log assert log is LoggerFactory()() @@ -100,7 +103,10 @@ class TestExtractStuffAndWhy: """ Raise ValueError if both _stuff and event contain exceptions. """ - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, + match="Both _stuff and event contain an Exception/Failure.", + ): _extractStuffAndWhy( { "_stuff": Failure(ValueError()), @@ -108,20 +114,15 @@ class TestExtractStuffAndWhy: } ) - assert ( - "Both _stuff and event contain an Exception/Failure." - == e.value.args[0] - ) - def test_failsOnConflictingEventAnd_why(self): """ Raise ValueError if both _why and event are in the event_dict. """ - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, match="Both `_why` and `event` supplied." + ): _extractStuffAndWhy({"_why": "foo", "event": "bar"}) - assert "Both `_why` and `event` supplied." == e.value.args[0] - def test_handlesFailures(self): """ Extracts failures and events. @@ -148,6 +149,9 @@ class TestEventAdapter: """ def test_EventAdapterFormatsLog(self): + """ + EventAdapter formats log entries correctly. + """ la = EventAdapter(_render_repr) assert "{'foo': 'bar'}" == la(None, "msg", {"foo": "bar"}) @@ -212,15 +216,18 @@ class TestEventAdapter: ) def test_catchesConflictingEventAnd_why(self): + """ + Passing both _why and event raises a ValueError. + """ la = EventAdapter(_render_repr) - with pytest.raises(ValueError) as e: + with pytest.raises( + ValueError, match="Both `_why` and `event` supplied." + ): la(None, "err", {"event": "someEvent", "_why": "someReason"}) - assert "Both `_why` and `event` supplied." == e.value.args[0] - -@pytest.fixture +@pytest.fixture() def jr(): """ A plain Twisted JSONRenderer. @@ -264,6 +271,9 @@ class TestJSONRenderer: ) def test_handlesFailure(self, jr): + """ + JSONRenderer renders failures correctly. + """ rv = jr(None, "err", {"event": Failure(ValueError())})[0][0].string assert "Failure: builtins.ValueError" in rv @@ -287,9 +297,15 @@ class TestReprWrapper: class TestPlainFileLogObserver: def test_isLogObserver(self, sio): + """ + PlainFileLogObserver is an ILogObserver. + """ assert ILogObserver.providedBy(PlainFileLogObserver(sio)) def test_writesOnlyMessageWithLF(self, sio): + """ + PlainFileLogObserver writes only the message and a line feed. + """ PlainFileLogObserver(sio)( {"system": "some system", "message": ("hello",)} ) @@ -299,6 +315,9 @@ class TestPlainFileLogObserver: class TestJSONObserverWrapper: def test_IsAnObserver(self): + """ + JSONLogObserverWrapper is an ILogObserver. + """ assert ILogObserver.implementedBy(JSONLogObserverWrapper) def test_callsWrappedObserver(self): @@ -335,4 +354,7 @@ class TestJSONObserverWrapper: class TestPlainJSONStdOutLogger: def test_isLogObserver(self): + """ + plainJSONStdOutLogger is an ILogObserver. + """ assert ILogObserver.providedBy(plainJSONStdOutLogger()) diff --git a/tests/test_utils.py b/tests/test_utils.py index 87a8f2fd90a7a8390846f129b35cff622bea9f6c..7ef508a0c2ac12e2085f63756b93c85efeebe774 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -16,17 +16,31 @@ from structlog._utils import get_processname, until_not_interrupted class TestUntilNotInterrupted: def test_passes_arguments_and_returns_return_value(self): + """ + until_not_interrupted() passes arguments to the wrapped function and + returns its return value. + """ + def returner(*args, **kw): - return args, kw + assert (42,) == args + assert {"x": 23} == kw + + return "foo" - assert ((42,), {"x": 23}) == until_not_interrupted(returner, 42, x=23) + assert "foo" == until_not_interrupted(returner, 42, x=23) def test_leaves_unrelated_exceptions_through(self): + """ + Exceptions that are not an EINTR OSError are not intercepted/retried. + """ exc = IOError with pytest.raises(exc): until_not_interrupted(raiser(exc("not EINTR"))) def test_retries_on_EINTR(self): + """ + Wrapped functions that raise EINTR OSErrors are retried. + """ calls = [0] def raise_on_first_three(): diff --git a/typing_examples.py b/tests/typing/api.py similarity index 97% rename from typing_examples.py rename to tests/typing/api.py index ab10880f863f1ff499c04d957b7c314d1a5af4db..719f290c71c36fcfeb75d06c216c033da858e009 100644 --- a/typing_examples.py +++ b/tests/typing/api.py @@ -7,10 +7,12 @@ Make sure our configuration examples actually pass the type checker. """ +from __future__ import annotations + import logging import logging.config -from typing import Any, Callable, List, Optional +from typing import Any, Callable import structlog @@ -27,8 +29,8 @@ bls.info("hello", whom="world", x=42, y={}) def bytes_dumps( __obj: Any, - default: Optional[Callable[[Any], Any]] = None, - option: Optional[int] = None, + default: Callable[[Any], Any] | None = None, + option: int | None = None, ) -> bytes: """ Test with orjson's signature taken from @@ -114,14 +116,14 @@ root_logger.setLevel(logging.INFO) timestamper = structlog.processors.TimeStamper(fmt="%Y-%m-%d %H:%M:%S") -shared_processors: List[structlog.typing.Processor] = [ +shared_processors: list[structlog.typing.Processor] = [ structlog.stdlib.add_log_level, timestamper, ] structlog.configure( - processors=shared_processors - + [ + processors=[ + *shared_processors, structlog.stdlib.ProcessorFormatter.wrap_for_formatter, ], logger_factory=structlog.stdlib.LoggerFactory(), diff --git a/tests/utils.py b/tests/utils.py index 79630d75da12c15318f3dcb9ad5d1fe59fa14fa6..44b2b6ec52bb479a473ba49031aa8dece9e69e62 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -11,3 +11,9 @@ from structlog._log_levels import _NAME_TO_LEVEL stdlib_log_methods = [m for m in _NAME_TO_LEVEL if m != "notset"] + + +class CustomError(Exception): + """ + Custom exception for testing purposes. + """ diff --git a/tox.ini b/tox.ini index 71e52e656052111880cd99b56cddf10e02f53e48..36c773ace27ce5473070a464e799391a2eec9771 100644 --- a/tox.ini +++ b/tox.ini @@ -2,25 +2,76 @@ min_version = 4 env_list = pre-commit, - mypy, - py37, - py38, - py39{,-colorama}, - py310, - py311{,-be,-rich}, + mypy-pkg, + py3{8,9,10,11,12}-{tests,mypy} + py3{8,11}-tests-{colorama,be,rich}, docs, coverage-report +[testenv] +package = wheel +wheel_build_env = .pkg +extras = + tests: tests + mypy: typing +pass_env = + FORCE_COLOR + NO_COLOR +commands = + tests: pytest {posargs} + mypy: mypy tests/typing + + +# Run oldest and latest under Coverage. +[testenv:py3{8,11}-tests{,-colorama,-be,-rich}] +deps = + coverage[toml] + py311: twisted + colorama: colorama + rich: rich + be: better-exceptions +commands = coverage run -m pytest {posargs} + + +[testenv:coverage-report] +# Keep in sync with .python-version +base_python = py311 +deps = coverage[toml] +skip_install = true +parallel_show_output = true +depends = py3{8,11}-{tests,colorama,be,rich} +commands = + coverage combine + coverage report + + [testenv:docs] -# Keep basepython in sync with ci.yml/docs and .readthedocs.yaml. -base_python = python3.11 +# Keep base_python in sync with ci.yml/docs and .readthedocs.yaml. +base_python = py311 extras = docs -pass_env = TERM commands = sphinx-build -n -T -W -b html -d {envtmpdir}/doctrees docs docs/_build/html sphinx-build -n -T -W -b doctest -d {envtmpdir}/doctrees docs docs/_build/html +[testenv:docs-watch] +package = editable +base_python = {[testenv:docs]base_python} +extras = {[testenv:docs]extras} +deps = watchfiles +commands = + watchfiles \ + --ignore-paths docs/_build/ \ + 'sphinx-build -W -n --jobs auto -b html -d {envtmpdir}/doctrees docs docs/_build/html' \ + src \ + docs + + +[testenv:docs-linkcheck] +base_python = {[testenv:docs]base_python} +extras = {[testenv:docs]extras} +commands = sphinx-build -W -b linkcheck -d {envtmpdir}/doctrees docs docs/_build/html + [testenv:pre-commit] skip_install = true @@ -28,49 +79,15 @@ deps = pre-commit commands = pre-commit run --all-files -[testenv:mypy] -description = Check types +[testenv:mypy-pkg] extras = typing -commands = mypy src typing_examples.py - +commands = mypy src -[testenv] -extras = tests -set_env = PYTHONHASHSEED = 0 -commands = pytest {posargs} - - -# For missing types we get from typing-extensions -[testenv:py3{7,11}] -deps = twisted -commands = coverage run -m pytest {posargs} - - -[testenv:py39-colorama] -deps = colorama -commands = coverage run -m pytest tests/test_dev.py {posargs} - - -[testenv:py311-be] -deps = better-exceptions -commands = {[testenv:py39-colorama]commands} - - -[testenv:py311-rich] -deps = rich -commands = {[testenv:py39-colorama]commands} - -[testenv:coverage-report] -# Keep in sync with ci.yml/PYTHON_LATEST -basepython = python3.11 -deps = coverage[toml] -skip_install = true -parallel_show_output = true -depends = py37,py39-colorama,py311{,-be,-rich} -commands = - coverage combine - coverage report +[testenv:pyright] +deps = pyright +extras = typing +commands = pyright tests/typing [testenv:color-force]