├── .ackrc ├── .bumpversion.cfg ├── .dockerignore ├── .drone.yml ├── .github └── workflows │ └── codeql.yml ├── .gitignore ├── .globality └── build.json ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── README.md ├── entrypoint.sh ├── logging_format ├── __init__.py ├── api.py ├── tests │ ├── __init__.py │ ├── coverage │ │ └── cov.xml │ ├── test_visitor.py │ └── test_whitelist.py ├── violations.py ├── visitor.py └── whitelist.py ├── setup.cfg └── setup.py /.ackrc: -------------------------------------------------------------------------------- 1 | #ack is a tool like grep, designed for programmers with large trees of heterogeneous source code 2 | 3 | #to install ack, see http://betterthangrep.com/ 4 | #to use ack, launch terminal (mac osx) and type 'ack ' 5 | #ack will search all files in the current directory & sub-directories 6 | 7 | # Always color, even if piping to a another program 8 | --color 9 | 10 | # Python project settings 11 | --ignore-dir=.eggs/ 12 | --ignore-dir=.tox/ 13 | --ignore-dir=build/ 14 | --ignore-dir=cover/ 15 | -------------------------------------------------------------------------------- /.bumpversion.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | current_version = 1.0.0 3 | commit = False 4 | tag = False 5 | 6 | [bumpversion:file:setup.py] 7 | search = version = "{current_version}" 8 | replace = version = "{new_version}" 9 | 10 | [bumpversion:file:logging_format/api.py] 11 | search = __version__ = "{current_version}" 12 | replace = __version__ = "{new_version}" 13 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | * 2 | !logging_format 3 | logging_format/tests 4 | !dist/*-none-any.whl 5 | !entrypoint.sh 6 | !MANIFEST.in 7 | !README.md 8 | !HISTORY.rst 9 | !requirements*.txt 10 | !setup.py 11 | !setup.cfg 12 | !mypy.ini 13 | !pyproject.toml 14 | !conftest.py 15 | **/*.pyc 16 | **/*~ 17 | -------------------------------------------------------------------------------- /.drone.yml: -------------------------------------------------------------------------------- 1 | --- 2 | kind: pipeline 3 | type: kubernetes 4 | name: build 5 | 6 | environment: 7 | NAME: logging_format 8 | 9 | trigger: 10 | event: 11 | - push 12 | 13 | 14 | 15 | steps: 16 | - name: lint-and-type-check 17 | image: python:3.11-slim 18 | environment: 19 | AWS_ACCOUNT_ID: 20 | from_secret: AWS_ACCOUNT_ID 21 | AWS_ACCESS_KEY_ID: 22 | from_secret: AWS_ACCESS_KEY_ID 23 | AWS_SECRET_ACCESS_KEY: 24 | from_secret: AWS_SECRET_ACCESS_KEY 25 | EXTRA_INDEX_URL: 26 | from_secret: EXTRA_INDEX_URL 27 | commands: 28 | - pip install -U pip==24.0 29 | - pip install awscli 30 | - aws codeartifact login --tool pip --repository globality-pypi-local --domain globality --domain-owner $AWS_ACCOUNT_ID --region us-east-1 31 | - ./entrypoint.sh lint 32 | - ./entrypoint.sh typehinting 33 | 34 | - name: test-py311-latest 35 | image: python:3.11-slim 36 | environment: 37 | AWS_ACCOUNT_ID: 38 | from_secret: AWS_ACCOUNT_ID 39 | AWS_ACCESS_KEY_ID: 40 | from_secret: AWS_ACCESS_KEY_ID 41 | AWS_SECRET_ACCESS_KEY: 42 | from_secret: AWS_SECRET_ACCESS_KEY 43 | EXTRA_INDEX_URL: 44 | from_secret: EXTRA_INDEX_URL 45 | commands: 46 | - pip install -U pip==24.0 47 | - pip install awscli 48 | - aws codeartifact login --tool pip --repository globality-pypi-local --domain globality --domain-owner $AWS_ACCOUNT_ID --region us-east-1 49 | - ./entrypoint.sh test 50 | 51 | - name: release-python-library-codeartifact 52 | image: python:3.11-slim 53 | environment: 54 | AWS_ACCESS_KEY_ID: 55 | from_secret: AWS_ACCESS_KEY_ID 56 | AWS_SECRET_ACCESS_KEY: 57 | from_secret: AWS_SECRET_ACCESS_KEY 58 | AWS_ACCOUNT_ID: 59 | from_secret: AWS_ACCOUNT_ID 60 | depends_on: 61 | - lint-and-type-check 62 | - test-py311-latest 63 | commands: 64 | - pip install -U pip==24.0 65 | - pip install --quiet awscli twine==4.0.2 packaging==24.0 66 | - export version=$(cat .bumpversion.cfg | awk '/current_version / {print $3}') 67 | - aws codeartifact login --tool pip --repository globality-pypi-local --domain globality --domain-owner $AWS_ACCOUNT_ID --region us-east-1 68 | - python setup.py sdist bdist_wheel 69 | - aws codeartifact login --tool twine --domain globality --repository globality-pypi-local --region us-east-1 && twine upload --repository codeartifact dist/flake8_logging_format-${version}* --verbose 70 | when: 71 | branch: 72 | - master 73 | 74 | - name: publish_library_to_pypi 75 | image: python:3.11-slim 76 | depends_on: 77 | - release-python-library-codeartifact 78 | environment: 79 | TWINE_USERNAME: __token__ 80 | TWINE_PASSWORD: 81 | from_secret: PYPI_TOKEN 82 | TWINE_REPOSITORY: https://upload.pypi.org/legacy/ 83 | commands: 84 | - pip install -U pip==24.0 85 | - pip install --quiet awscli twine==4.0.2 86 | - export version=$(cat .bumpversion.cfg | awk '/current_version / {print $3}') 87 | - echo "Publishing ${version}" 88 | - python setup.py sdist bdist_wheel 89 | - twine upload --repository pypi dist/flake8_logging_format-${version}* --non-interactive --verbose 90 | when: 91 | branch: 92 | - master 93 | 94 | --- 95 | kind: pipeline 96 | type: kubernetes 97 | name: pr 98 | 99 | trigger: 100 | event: 101 | - pull_request 102 | 103 | steps: 104 | - name: dependency-validation-dummy 105 | pull: always 106 | image: python:3.11-slim 107 | commands: 108 | - echo "Dummy step to trigger dependency-validation" 109 | 110 | 111 | 112 | --- 113 | kind: secret 114 | name: AWS_ACCOUNT_ID 115 | get: 116 | path: secrets/dev/drone 117 | name: AWS_ACCOUNT_ID 118 | 119 | --- 120 | kind: secret 121 | name: PYPI_TOKEN 122 | get: 123 | path: secrets/dev/drone 124 | name: PYPI_TOKEN 125 | 126 | --- 127 | kind: secret 128 | name: CFGR_GITHUB_PRIVATE_KEY 129 | get: 130 | path: secrets/dev/drone 131 | name: CFGR_GITHUB_PRIVATE_KEY 132 | 133 | --- 134 | kind: secret 135 | name: AWS_ACCESS_KEY_ID 136 | get: 137 | path: secrets/dev/drone 138 | name: DRONE_AWS_ACCESS_KEY 139 | 140 | --- 141 | kind: secret 142 | name: AWS_SECRET_ACCESS_KEY 143 | get: 144 | path: secrets/dev/drone 145 | name: DRONE_AWS_ACCESS_SECRET_KEY 146 | 147 | --- 148 | kind: secret 149 | name: EXTRA_INDEX_URL 150 | get: 151 | path: secrets/dev/drone 152 | name: DRONE_EXTRA_INDEX_URL 153 | 154 | --- 155 | kind: secret 156 | name: GITHUB_PRIVATE_KEY 157 | get: 158 | path: secrets/dev/drone 159 | name: DRONE_GITHUB_PRIVATE_KEY 160 | 161 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: "CodeQL Advanced" 13 | 14 | on: 15 | push: 16 | branches: [ "master" ] 17 | pull_request: 18 | branches: [ "master" ] 19 | schedule: 20 | - cron: '47 14 * * 2' 21 | 22 | jobs: 23 | analyze: 24 | name: Analyze (${{ matrix.language }}) 25 | # Runner size impacts CodeQL analysis time. To learn more, please see: 26 | # - https://gh.io/recommended-hardware-resources-for-running-codeql 27 | # - https://gh.io/supported-runners-and-hardware-resources 28 | # - https://gh.io/using-larger-runners (GitHub.com only) 29 | # Consider using larger runners or machines with greater resources for possible analysis time improvements. 30 | runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} 31 | permissions: 32 | # required for all workflows 33 | security-events: write 34 | 35 | # required to fetch internal or private CodeQL packs 36 | packages: read 37 | 38 | # only required for workflows in private repositories 39 | actions: read 40 | contents: read 41 | 42 | strategy: 43 | fail-fast: false 44 | matrix: 45 | include: 46 | - language: python 47 | build-mode: none 48 | # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'swift' 49 | # Use `c-cpp` to analyze code written in C, C++ or both 50 | # Use 'java-kotlin' to analyze code written in Java, Kotlin or both 51 | # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both 52 | # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, 53 | # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. 54 | # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how 55 | # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages 56 | steps: 57 | - name: Checkout repository 58 | uses: actions/checkout@v4 59 | 60 | # Add any setup steps before running the `github/codeql-action/init` action. 61 | # This includes steps like installing compilers or runtimes (`actions/setup-node` 62 | # or others). This is typically only required for manual builds. 63 | # - name: Setup runtime (example) 64 | # uses: actions/setup-example@v1 65 | 66 | # Initializes the CodeQL tools for scanning. 67 | - name: Initialize CodeQL 68 | uses: github/codeql-action/init@v3 69 | with: 70 | languages: ${{ matrix.language }} 71 | build-mode: ${{ matrix.build-mode }} 72 | # If you wish to specify custom queries, you can do so here or in a config file. 73 | # By default, queries listed here will override any specified in a config file. 74 | # Prefix the list here with "+" to use these queries and those in the config file. 75 | 76 | # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 77 | queries: security-extended,security-and-quality 78 | 79 | # If the analyze step fails for one of the languages you are analyzing with 80 | # "We were unable to automatically build your code", modify the matrix above 81 | # to set the build mode to "manual" for that language. Then modify this step 82 | # to build your code. 83 | # ℹ️ Command-line programs to run using the OS shell. 84 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 85 | - if: matrix.build-mode == 'manual' 86 | shell: bash 87 | run: | 88 | echo 'If you are using a "manual" build mode for one or more of the' \ 89 | 'languages you are analyzing, replace this with the commands to build' \ 90 | 'your code, for example:' 91 | echo ' make bootstrap' 92 | echo ' make release' 93 | exit 1 94 | 95 | - name: Perform CodeQL Analysis 96 | uses: github/codeql-action/analyze@v3 97 | with: 98 | category: "/language:${{matrix.language}}" 99 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## 2 | # .gitignore 3 | # 4 | # Based on: 5 | # 6 | # https://github.com/github/gitignore/blob/master/Global/OSX.gitignore 7 | # https://github.com/github/gitignore/blob/master/Python.gitignore 8 | ## 9 | 10 | .DS_Store 11 | .AppleDouble 12 | .LSOverride 13 | 14 | # Icon must end with two \r 15 | Icon 16 | 17 | 18 | # Thumbnails 19 | ._* 20 | 21 | # Files that might appear in the root of a volume 22 | .DocumentRevisions-V100 23 | .fseventsd 24 | .Spotlight-V100 25 | .TemporaryItems 26 | .Trashes 27 | .VolumeIcon.icns 28 | 29 | # Directories potentially created on remote AFP share 30 | .AppleDB 31 | .AppleDesktop 32 | Network Trash Folder 33 | Temporary Items 34 | .apdisk 35 | 36 | 37 | # Byte-compiled / optimized / DLL files 38 | __pycache__/ 39 | *.py[cod] 40 | 41 | # C extensions 42 | *.so 43 | 44 | # Distribution / packaging 45 | .Python 46 | env/ 47 | build/ 48 | develop-eggs/ 49 | dist/ 50 | downloads/ 51 | eggs/ 52 | .eggs/ 53 | lib/ 54 | lib64/ 55 | parts/ 56 | sdist/ 57 | var/ 58 | *.egg-info/ 59 | .installed.cfg 60 | *.egg 61 | 62 | # PyInstaller 63 | # Usually these files are written by a python script from a template 64 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 65 | *.manifest 66 | *.spec 67 | 68 | # Installer logs 69 | pip-log.txt 70 | pip-delete-this-directory.txt 71 | 72 | # Unit test / coverage reports 73 | htmlcov/ 74 | .tox/ 75 | .coverage 76 | .coverage.* 77 | .cache 78 | nosetests.xml 79 | coverage.xml 80 | *,cover 81 | cover 82 | 83 | # Translations 84 | *.mo 85 | *.pot 86 | 87 | # Django stuff: 88 | *.log 89 | 90 | # Sphinx documentation 91 | docs/_build/ 92 | 93 | # PyBuilder 94 | target/ 95 | 96 | .mypy_cache/ 97 | -------------------------------------------------------------------------------- /.globality/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "params": { 3 | "docker": { 4 | "docker_tag": "python:slim-stretch" 5 | }, 6 | "lib_name": "logging_format", 7 | "name": "flake8-logging-format", 8 | "pypi": { 9 | "repository": "pypi" 10 | }, 11 | "test_command": "pytest" 12 | }, 13 | "type": "python-library", 14 | "version": "2024.51.0" 15 | } 16 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | **Find this project useful?** Help us make it even better by submitting any bugs or improvement 4 | suggestions you have as GitHub Issues and Pull Requests. 5 | 6 | 7 | ## Pull Requests 8 | 9 | This project uses [git-flow](https://github.com/nvie/gitflow). 10 | 11 | Please submit PRs against the `develop` branch. 12 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # 2 | # Globality autogenerated Docker configuration 3 | # 4 | # This file is auto generated with globality-build. 5 | # You should not make any changes to this file manually 6 | # 7 | # Any changes made to this file will be overwritten in the 8 | # next version of the build. 9 | # 10 | # See: http://github.com/globality-corp/globality-build 11 | # 12 | # 13 | 14 | # ----------- deps ----------- 15 | FROM python:3.11-slim as deps 16 | 17 | # 18 | # Most services will use the same set of packages here, though a few will install 19 | # custom dependencies for native requirements. 20 | # 21 | 22 | ARG EXTRA_INDEX_URL 23 | ENV EXTRA_INDEX_URL ${EXTRA_INDEX_URL} 24 | 25 | ENV CORE_PACKAGES locales 26 | ENV BUILD_PACKAGES build-essential libffi-dev 27 | ENV OTHER_PACKAGES libssl-dev 28 | 29 | 30 | RUN apt-get update && \ 31 | apt-get install -y --no-install-recommends ${CORE_PACKAGES} ${BUILD_PACKAGES} && \ 32 | apt-get install -y --no-install-recommends ${OTHER_PACKAGES} && \ 33 | apt-get autoremove -y && \ 34 | rm -rf /var/lib/apt/lists/* 35 | 36 | 37 | # ----------- base ----------- 38 | 39 | FROM deps as base 40 | 41 | # Install dependencies 42 | # 43 | # Since many Python distributions require a compile for their native dependencies 44 | # we install a compiler and any required development libraries before the installation 45 | # and then *remove* the the compiler when we are done. 46 | # 47 | # We can control dependency freezing by managing the contents of `requirements.txt`. 48 | # 49 | # We can speed up the installation a little bit by breaking out the common 50 | # pip dependencies into their own layer, but avoid this optimization for 51 | # now to improve clarity. 52 | # 53 | # We also install the web application server (which should not be one of our 54 | # explicit dependencies). 55 | # 56 | # Many services will need to modify this step for Python libraries with other 57 | # native dependencies. 58 | 59 | 60 | # Work in /src 61 | # 62 | # We'll copy local source code here for development. 63 | WORKDIR src 64 | 65 | # Set a proper locale 66 | # 67 | # UTF-8 everywhere. 68 | 69 | RUN echo "en_US.UTF-8 UTF-8" > /etc/locale.gen && \ 70 | locale-gen "en_US.UTF-8" && \ 71 | /usr/sbin/update-locale LANG=en_US.UTF-8 72 | ENV LC_ALL en_US.UTF-8 73 | 74 | # Install top-level files 75 | # 76 | # These are enough to install dependencies and have a stable base layer 77 | # when source code changes. 78 | 79 | # copy pyproject.toml and HISTORY.rst only if they exist 80 | COPY README.md MANIFEST.in setup.cfg setup.py pyproject.tom[l] HISTORY.rs[t] conftest.p[y] /src/ 81 | 82 | RUN pip install --no-cache-dir --upgrade --extra-index-url ${EXTRA_INDEX_URL} /src/ && \ 83 | apt-get remove --purge -y ${BUILD_PACKAGES} && \ 84 | apt-get autoremove -y && \ 85 | rm -rf /var/lib/apt/lists/* 86 | 87 | # ----------- final ----------- 88 | FROM base 89 | 90 | # Setup invocation 91 | # 92 | # We expose the application on the standard HTTP port and use an entrypoint 93 | # to customize the `dev` and `test` targets. 94 | 95 | ENV NAME logging_format 96 | COPY entrypoint.sh /src/ 97 | ENTRYPOINT ["./entrypoint.sh"] 98 | 99 | # Install source 100 | # 101 | # We should not need to reinstall dependencies here, but we do need to import 102 | # the distribution properly. We also save build arguments to the image using 103 | # microcosm-compatible environment variables. 104 | 105 | 106 | ARG BUILD_NUM 107 | ARG SHA1 108 | ENV FLAKE8_LOGGING_FORMAT__BUILD_INFO_CONVENTION__BUILD_NUM ${BUILD_NUM} 109 | ENV FLAKE8_LOGGING_FORMAT__BUILD_INFO_CONVENTION__SHA1 ${SHA1} 110 | COPY $NAME /src/$NAME/ 111 | RUN pip install --no-cache-dir --extra-index-url $EXTRA_INDEX_URL -e . 112 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright {yyyy} {name of copyright owner} 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/globality-corp/flake8-logging-format/6bb4da82c0883d3483bda9561f964def52ecf864/MANIFEST.in -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # flake8-logging-format 2 | 3 | Flake8 extension to validate (lack of) logging format strings 4 | 5 | 6 | ## What's This? 7 | 8 | Python [logging](https://docs.python.org/3/library/logging.html#logging.Logger.debug) supports a special `extra` keyword 9 | for passing a dictionary of user-defined attributes to include in a logging event. One way to ensure consistency and 10 | rigor in logging is to **always** use `extra` to pass non-constant data and, therefore, to **never** use format strings, 11 | concatenation, or other similar techniques to construct a log string. 12 | 13 | In other words, do this: 14 | 15 | ```python 16 | logger.info( 17 | "Hello {world}", 18 | extra=dict( 19 | world="Earth" 20 | ) 21 | ) 22 | ``` 23 | 24 | Instead of: 25 | 26 | ```python 27 | logger.info( 28 | "Hello {world}".format(world=Earth) 29 | ) 30 | ``` 31 | 32 | ## Extra Whitelist 33 | 34 | As a further level of rigor, we can enforce that `extra` dictionaries only use keys from a well-known whitelist. 35 | 36 | Usage: 37 | 38 | ```bash 39 | flake8 --enable-extra-whitelist 40 | ``` 41 | 42 | The built-in `Whitelist` supports plugins using `entry_points` with a key of `"logging.extra.whitelist"`. Each 43 | registered entry point must be a callable that returns an iterable of string. 44 | 45 | In some cases you may want to log sensitive data only in debugging scenarios. This is supported in 2 ways: 46 | 1. We do not check the logging.extra.whitelist for lines logged at the `debug` level 47 | 2. You may also prefix a keyword with 'debug\_' and log it at another level. You can safely assume these will be 48 | filtered out of shipped logs. 49 | 50 | ## Violations Detected 51 | 52 | - `G001` Logging statements should not use `string.format()` for their first argument 53 | - `G002` Logging statements should not use `%` formatting for their first argument 54 | - `G003` Logging statements should not use `+` concatenation for their first argument 55 | - `G004` Logging statements should not use `f"..."` for their first argument (only in Python 3.6+) 56 | - `G010` Logging statements should not use `warn` (use `warning` instead) 57 | - `G100` Logging statements should not use `extra` arguments unless whitelisted 58 | - `G101` Logging statement should not use `extra` arguments that clash with LogRecord fields 59 | - `G200` Logging statements should not include the exception in logged string (use `exception` or `exc_info=True`) 60 | - `G201` Logging statements should not use `error(..., exc_info=True)` (use `exception(...)` instead) 61 | - `G202` Logging statements should not use redundant `exc_info=True` in `exception` 62 | 63 | These violations are disabled by default. To enable them for your project, specify the code(s) in your `setup.cfg`: 64 | 65 | ```ini 66 | [flake8] 67 | enable-extensions=G 68 | ``` 69 | 70 | ## Motivation 71 | 72 | Our motivation has to do with balancing the needs of our team and those of our customers. 73 | On the one hand, developers and front-line support should be able to look at application logs. On the other hand, our customers don't want their data shared with anyone, including internal employees. 74 | 75 | The implementation approaches this in two ways: 76 | 77 | 1. By trying to prevent the use of string concatenation in logs (vs explicit variable passing in the standard logging `extra` dictionary) 78 | 79 | 2. By providing an (optional) mechanism for whitelisting which field names may appear in the `extra` dictionary 80 | 81 | Naturally, this _does not_ prevent developers from doing something like: 82 | 83 | ```python 84 | extra=dict( 85 | user_id=user.name, 86 | ) 87 | ``` 88 | 89 | but then avoiding a case like this falls back to other processes around pull-requests, code review and internal policy. 90 | -------------------------------------------------------------------------------- /entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash -e 2 | 3 | # This file is auto generated with globality-build. 4 | # You should not make any changes to this file manually 5 | # 6 | # See: http://github.com/globality-corp/globality-build 7 | 8 | # Container entrypoint to simplify running the production and dev servers. 9 | 10 | # Entrypoint conventions are as follows: 11 | # 12 | # - If the container is run without a custom CMD, the service should run as it would in production. 13 | # - If the container is run with the "dev" CMD, the service should run in development mode. 14 | # 15 | # Normally, this means that if the user's source has been mounted as a volume, the server will 16 | # restart on code changes and will inject extra debugging/diagnostics. 17 | # 18 | # - If the CMD is "test" or "lint", the service should run its unit tests or linting. 19 | # 20 | # There is no requirement that unit tests work unless user source has been mounted as a volume; 21 | # test code should not normally be shipped with production images. 22 | # 23 | # - Otherwise, the CMD should be run verbatim. 24 | 25 | 26 | if [ "$1" = "test" ]; then 27 | # Install standard test dependencies; YMMV 28 | pip --quiet install \ 29 | .[test] 30 | pytest 31 | elif [ "$1" = "lint" ]; then 32 | # Install standard linting dependencies; YMMV 33 | pip --quiet install \ 34 | .[lint] 35 | exec flake8 ${NAME} 36 | elif [ "$1" = "typehinting" ]; then 37 | # Install standard type-linting dependencies 38 | pip --quiet install mypy types-setuptools 39 | exec mypy ${NAME} --ignore-missing-imports 40 | else 41 | echo "Cannot execute $@" 42 | exit 3 43 | fi 44 | -------------------------------------------------------------------------------- /logging_format/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/globality-corp/flake8-logging-format/6bb4da82c0883d3483bda9561f964def52ecf864/logging_format/__init__.py -------------------------------------------------------------------------------- /logging_format/api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Flake8 entry point. 3 | 4 | """ 5 | from logging_format.visitor import LoggingVisitor 6 | from logging_format.whitelist import Whitelist 7 | 8 | 9 | __version__ = "1.0.0" 10 | 11 | 12 | class LoggingFormatValidator: 13 | name = "logging-format" 14 | version = __version__ 15 | enable_extra_whitelist = False 16 | 17 | def __init__(self, tree, filename): 18 | self.tree = tree 19 | 20 | @classmethod 21 | def add_options(cls, parser): 22 | parser.add_option("--enable-extra-whitelist", action="store_true") 23 | 24 | @classmethod 25 | def parse_options(cls, options): 26 | cls.enable_extra_whitelist = options.enable_extra_whitelist 27 | 28 | def run(self): 29 | whitelist = None 30 | 31 | if LoggingFormatValidator.enable_extra_whitelist: 32 | whitelist = Whitelist() 33 | 34 | visitor = LoggingVisitor(whitelist=whitelist) 35 | visitor.visit(self.tree) 36 | 37 | for node, reason in visitor.violations: 38 | yield node.lineno, node.col_offset, reason, type(self) 39 | -------------------------------------------------------------------------------- /logging_format/tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/globality-corp/flake8-logging-format/6bb4da82c0883d3483bda9561f964def52ecf864/logging_format/tests/__init__.py -------------------------------------------------------------------------------- /logging_format/tests/coverage/cov.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | /Users/florinmoisa/workspace/flake8-logging-format/logging_format 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | 186 | 187 | 188 | 189 | 190 | 191 | 192 | 193 | 194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | 207 | 208 | 209 | 210 | 211 | 212 | 213 | 214 | 215 | 216 | 217 | 218 | 219 | 220 | 221 | 222 | 223 | 224 | 225 | 226 | 227 | 228 | 229 | 230 | 231 | 232 | 233 | 234 | 235 | 236 | 237 | 238 | 239 | 240 | 241 | 242 | 243 | 244 | 245 | 246 | 247 | 248 | 249 | 250 | 251 | 252 | 253 | 254 | 255 | 256 | 257 | 258 | 259 | 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 294 | 295 | 296 | 297 | 298 | 299 | 300 | 301 | 302 | 303 | 304 | 305 | 306 | 307 | 308 | 309 | 310 | 311 | 312 | 313 | 314 | 315 | 316 | 317 | 318 | 319 | 320 | 321 | 322 | 323 | 324 | 325 | 326 | 327 | 328 | 329 | 330 | 331 | 332 | 333 | 334 | 335 | 336 | 337 | 338 | 339 | 340 | 341 | 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | 350 | 351 | 352 | 353 | 354 | 355 | 356 | 357 | 358 | 359 | 360 | 361 | 362 | 363 | 364 | 365 | 366 | 367 | 368 | 369 | 370 | 371 | 372 | 373 | 374 | 375 | 376 | 377 | 378 | 379 | 380 | 381 | 382 | 383 | 384 | 385 | 386 | 387 | 388 | 389 | 390 | 391 | 392 | 393 | 394 | 395 | 396 | 397 | 398 | 399 | 400 | 401 | 402 | 403 | 404 | 405 | 406 | 407 | 408 | 409 | 410 | 411 | 412 | 413 | 414 | 415 | 416 | 417 | 418 | 419 | 420 | 421 | 422 | 423 | 424 | 425 | 426 | 427 | 428 | 429 | 430 | 431 | 432 | 433 | 434 | 435 | 436 | 437 | 438 | 439 | 440 | 441 | 442 | 443 | 444 | -------------------------------------------------------------------------------- /logging_format/tests/test_visitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | Visitor tests. 3 | 4 | """ 5 | import logging 6 | from ast import parse 7 | from textwrap import dedent 8 | 9 | from hamcrest import ( 10 | assert_that, 11 | contains, 12 | empty, 13 | equal_to, 14 | has_item, 15 | has_length, 16 | is_, 17 | ) 18 | 19 | from logging_format.violations import ( 20 | ERROR_EXC_INFO_VIOLATION, 21 | EXCEPTION_VIOLATION, 22 | EXTRA_ATTR_CLASH_VIOLATION, 23 | FSTRING_VIOLATION, 24 | PERCENT_FORMAT_VIOLATION, 25 | REDUNDANT_EXC_INFO_VIOLATION, 26 | STRING_CONCAT_VIOLATION, 27 | STRING_FORMAT_VIOLATION, 28 | WARN_VIOLATION, 29 | WHITELIST_VIOLATION, 30 | ) 31 | from logging_format.visitor import RESERVED_ATTRS, LoggingVisitor 32 | from logging_format.whitelist import Whitelist 33 | 34 | 35 | def test_simple(): 36 | """ 37 | Simple logging statements are fine. 38 | 39 | """ 40 | tree = parse(dedent("""\ 41 | import logging 42 | 43 | logging.info("Hello World!") 44 | """)) 45 | visitor = LoggingVisitor() 46 | visitor.visit(tree) 47 | 48 | assert_that(visitor.violations, is_(empty())) 49 | 50 | 51 | def test_extra(): 52 | """ 53 | Extra dictionary is fine. 54 | 55 | """ 56 | tree = parse(dedent("""\ 57 | import logging 58 | 59 | logging.info( 60 | "Hello {world}!", 61 | extra=dict( 62 | world="World", 63 | ), 64 | ) 65 | """)) 66 | visitor = LoggingVisitor() 67 | visitor.visit(tree) 68 | 69 | assert_that(visitor.violations, is_(empty())) 70 | 71 | 72 | def test_extra_with_string_format(): 73 | """ 74 | String format is ok within the extra value. 75 | 76 | """ 77 | tree = parse(dedent("""\ 78 | import logging 79 | 80 | logging.info( 81 | "Hello {world}!", 82 | extra=dict( 83 | world="{}".format("World"), 84 | ), 85 | ) 86 | """)) 87 | visitor = LoggingVisitor() 88 | visitor.visit(tree) 89 | 90 | assert_that(visitor.violations, is_(empty())) 91 | 92 | 93 | def test_extra_with_whitelisted_keyword(): 94 | """ 95 | Extra keyword is ok if in whitelist. 96 | 97 | """ 98 | tree = parse(dedent("""\ 99 | import logging 100 | 101 | logging.info( 102 | "Hello {world}!", 103 | extra=dict( 104 | world="World", 105 | ), 106 | ) 107 | """)) 108 | whitelist = Whitelist(group="logging.extra.example") 109 | visitor = LoggingVisitor(whitelist=whitelist) 110 | visitor.visit(tree) 111 | 112 | assert_that(whitelist, contains("world")) 113 | assert_that(visitor.violations, is_(empty())) 114 | 115 | 116 | def test_extra_with_not_whitelisted_keyword(): 117 | """ 118 | Extra keyword is not ok if not in whitelist. 119 | 120 | """ 121 | tree = parse(dedent("""\ 122 | import logging 123 | 124 | logging.info( 125 | "Hello {hello}!", 126 | extra=dict( 127 | hello="{}", 128 | ), 129 | ) 130 | """)) 131 | whitelist = Whitelist(group="logging.extra.example") 132 | visitor = LoggingVisitor(whitelist=whitelist) 133 | visitor.visit(tree) 134 | 135 | assert_that(whitelist, contains("world")) 136 | assert_that(visitor.violations, has_length(1)) 137 | assert_that(visitor.violations[0][1], is_(equal_to(WHITELIST_VIOLATION.format("hello")))) 138 | 139 | 140 | def test_extra_with_dict_unpacking(): 141 | """ 142 | Validates dict unpacking in extra 143 | 144 | """ 145 | tree = parse(dedent("""\ 146 | import logging 147 | 148 | baz = {"world", "World!"} 149 | logging.info( 150 | "Hello {world}!", 151 | extra={ 152 | "some-key": "some-value", 153 | **baz, 154 | }, 155 | ) 156 | """)) 157 | visitor = LoggingVisitor() 158 | visitor.visit(tree) 159 | 160 | assert_that(visitor.violations, is_(empty())) 161 | 162 | 163 | def test_reserved_attrs(): 164 | """ 165 | RESERVED_ATTRS should include all attributes of an empty LogRecord 166 | 167 | """ 168 | 169 | dummy_record = logging.LogRecord("foo", logging.DEBUG, "foo", 42, "foo", {}, None) 170 | for key in dummy_record.__dict__.keys(): 171 | assert_that(RESERVED_ATTRS, has_item(key)) 172 | 173 | 174 | def test_extra_with_default_keyword_dict_call(): 175 | """ 176 | Extra dict overwriting default LogRecord fields is not OK (direct "dict" call) 177 | 178 | """ 179 | reserved_field = "name" 180 | 181 | tree = parse(dedent("""\ 182 | import logging 183 | 184 | logging.info( 185 | "Hello world!", 186 | extra=dict( 187 | {reserved_field}="foobar", 188 | ), 189 | ) 190 | """.format(reserved_field=reserved_field))) 191 | visitor = LoggingVisitor() 192 | visitor.visit(tree) 193 | 194 | assert_that(visitor.violations, has_length(1)) 195 | assert_that(visitor.violations[0][1], is_(equal_to(EXTRA_ATTR_CLASH_VIOLATION.format(reserved_field)))) 196 | 197 | 198 | def test_extra_with_default_keyword_dict_literal(): 199 | """ 200 | Extra dict overwriting default LogRecord fields is not OK (dict literal) 201 | 202 | """ 203 | reserved_field = "name" 204 | 205 | tree = parse(dedent("""\ 206 | import logging 207 | 208 | logging.info( 209 | "Hello world!", 210 | extra={{ 211 | "{reserved_field}": "foobar", 212 | }}, 213 | ) 214 | """.format(reserved_field=reserved_field))) 215 | visitor = LoggingVisitor() 216 | visitor.visit(tree) 217 | 218 | assert_that(visitor.violations, has_length(1)) 219 | assert_that(visitor.violations[0][1], is_(equal_to(EXTRA_ATTR_CLASH_VIOLATION.format(reserved_field)))) 220 | 221 | 222 | def test_debug_ok_with_not_whitelisted_keyword(): 223 | """ 224 | Extra keyword is ok for debug if not in whitelist. 225 | 226 | """ 227 | tree = parse(dedent("""\ 228 | import logging 229 | 230 | logging.debug( 231 | "Hello {goodbye}!", 232 | extra=dict( 233 | goodbye="{}", 234 | ), 235 | ) 236 | logging.info( 237 | "Hello {hello}!", 238 | extra=dict( 239 | hello="{}", 240 | ), 241 | ) 242 | """)) 243 | whitelist = Whitelist(group="logging.extra.example") 244 | visitor = LoggingVisitor(whitelist=whitelist) 245 | visitor.visit(tree) 246 | 247 | assert_that(whitelist, contains("world")) 248 | assert_that(visitor.violations, has_length(1)) 249 | assert_that(visitor.violations[0][1], is_(equal_to(WHITELIST_VIOLATION.format("hello")))) 250 | 251 | 252 | def test_debug_prefix_ok_with_not_whitelisted_keyword(): 253 | """ 254 | Extra keyword is ok if prefix 'debug_'. 255 | 256 | """ 257 | tree = parse(dedent("""\ 258 | import logging 259 | 260 | logging.info( 261 | "Hello {debug_hello}!", 262 | extra=dict( 263 | debug_hello="{}", 264 | ), 265 | ) 266 | """)) 267 | whitelist = Whitelist(group="logging.extra.example") 268 | visitor = LoggingVisitor(whitelist=whitelist) 269 | visitor.visit(tree) 270 | 271 | assert_that(whitelist, contains("world")) 272 | assert_that(visitor.violations, is_(empty())) 273 | 274 | 275 | def test_extra_with_non_whitelisted_dict_keyword(): 276 | """ 277 | Extra keyword is not ok if not in whitelist and passed in `{}` 278 | 279 | """ 280 | tree = parse(dedent("""\ 281 | import logging 282 | 283 | logging.info( 284 | "Hello {hello}!", 285 | extra={ 286 | "hello": "World!", 287 | }, 288 | ) 289 | """)) 290 | whitelist = Whitelist(group="logging.extra.example") 291 | visitor = LoggingVisitor(whitelist=whitelist) 292 | visitor.visit(tree) 293 | 294 | assert_that(whitelist, contains("world")) 295 | assert_that(visitor.violations, has_length(1)) 296 | assert_that(visitor.violations[0][1], is_(equal_to(WHITELIST_VIOLATION.format("hello")))) 297 | 298 | 299 | def test_string_format(): 300 | """ 301 | String formatting is not ok in logging statements. 302 | 303 | """ 304 | tree = parse(dedent("""\ 305 | import logging 306 | 307 | logging.info("Hello {}".format("World!")) 308 | """)) 309 | visitor = LoggingVisitor() 310 | visitor.visit(tree) 311 | 312 | assert_that(visitor.violations, has_length(1)) 313 | assert_that(visitor.violations[0][1], is_(equal_to(STRING_FORMAT_VIOLATION))) 314 | 315 | 316 | def test_debug_string_format(): 317 | """ 318 | String formatting is not ok in logging statements. 319 | 320 | """ 321 | tree = parse(dedent("""\ 322 | import logging 323 | 324 | logging.debug("Hello {}".format("World!")) 325 | """)) 326 | visitor = LoggingVisitor() 327 | visitor.visit(tree) 328 | 329 | assert_that(visitor.violations, has_length(1)) 330 | assert_that(visitor.violations[0][1], is_(equal_to(STRING_FORMAT_VIOLATION))) 331 | 332 | 333 | def test_format_percent(): 334 | """ 335 | Percent formatting is not ok in logging statements. 336 | 337 | """ 338 | tree = parse(dedent("""\ 339 | import logging 340 | 341 | logging.info("Hello %s" % "World!") 342 | """)) 343 | visitor = LoggingVisitor() 344 | visitor.visit(tree) 345 | 346 | assert_that(visitor.violations, has_length(1)) 347 | assert_that(visitor.violations[0][1], is_(equal_to(PERCENT_FORMAT_VIOLATION))) 348 | 349 | 350 | def test_fstring(): 351 | """ 352 | F-Strings are not ok in logging statements. 353 | 354 | """ 355 | tree = parse(dedent("""\ 356 | import logging 357 | name = "world" 358 | logging.info(f"Hello {name}") 359 | """)) 360 | visitor = LoggingVisitor() 361 | visitor.visit(tree) 362 | 363 | assert_that(visitor.violations, has_length(1)) 364 | assert_that(visitor.violations[0][1], is_(equal_to(FSTRING_VIOLATION))) 365 | 366 | 367 | def test_string_concat(): 368 | """ 369 | String concatenation is not ok in logging statements. 370 | 371 | """ 372 | tree = parse(dedent("""\ 373 | import logging 374 | 375 | logging.info("Hello" + " " + "World!") 376 | """)) 377 | visitor = LoggingVisitor() 378 | visitor.visit(tree) 379 | 380 | assert_that(visitor.violations, has_length(2)) 381 | # NB: We could easily decide to report only one of these 382 | assert_that(visitor.violations[0][1], is_(equal_to(STRING_CONCAT_VIOLATION))) 383 | assert_that(visitor.violations[1][1], is_(equal_to(STRING_CONCAT_VIOLATION))) 384 | 385 | 386 | def test_warn(): 387 | """ 388 | Warn is deprecated in place of warning. 389 | 390 | """ 391 | tree = parse(dedent("""\ 392 | import logging 393 | 394 | logging.warn("Hello World!") 395 | """)) 396 | visitor = LoggingVisitor() 397 | visitor.visit(tree) 398 | 399 | assert_that(visitor.violations, has_length(1)) 400 | assert_that(visitor.violations[0][1], is_(equal_to(WARN_VIOLATION))) 401 | 402 | 403 | def test_warnings(): 404 | """ 405 | Warnings library is forgiven. 406 | 407 | """ 408 | tree = parse(dedent("""\ 409 | import warnings 410 | 411 | warnings.warn("Hello World!") 412 | """)) 413 | visitor = LoggingVisitor() 414 | visitor.visit(tree) 415 | 416 | assert_that(visitor.violations, is_(empty())) 417 | 418 | 419 | def test_exception_standard(): 420 | """ 421 | In an except block, exceptions should be logged using .exception(). 422 | 423 | """ 424 | tree = parse(dedent("""\ 425 | import logging 426 | 427 | try: 428 | pass 429 | except Exception: 430 | logging.exception('Something bad has happened') 431 | """)) 432 | visitor = LoggingVisitor() 433 | visitor.visit(tree) 434 | 435 | assert_that(visitor.violations, is_(empty())) 436 | 437 | 438 | def test_exception_warning(): 439 | """ 440 | In an except block, logging exceptions using exc_info=True is ok. 441 | 442 | """ 443 | tree = parse(dedent("""\ 444 | import logging 445 | 446 | try: 447 | pass 448 | except Exception: 449 | logging.warning('Something bad has happened', exc_info=True) 450 | """)) 451 | visitor = LoggingVisitor() 452 | visitor.visit(tree) 453 | 454 | assert_that(visitor.violations, is_(empty())) 455 | 456 | 457 | def test_exception_attribute_as_main_arg(): 458 | """ 459 | In an except block, passing an exception attribute into logging as main argument is ok. 460 | 461 | """ 462 | tree = parse(dedent("""\ 463 | import logging 464 | 465 | class CustomException(Exception): 466 | def __init__(self, custom_arg): 467 | self.custom_arg = custom_arg 468 | 469 | try: 470 | pass 471 | except CustomException as e: 472 | logging.error(e.custom_arg) 473 | """)) 474 | visitor = LoggingVisitor() 475 | visitor.visit(tree) 476 | 477 | assert_that(visitor.violations, is_(empty())) 478 | 479 | 480 | def test_exception_attribute_as_formatting_arg(): 481 | """ 482 | In an except block, passing an exception attribute into logging as a formatting argument is ok. 483 | 484 | """ 485 | tree = parse(dedent("""\ 486 | import logging 487 | 488 | class CustomException(Exception): 489 | def __init__(self, custom_arg): 490 | self.custom_arg = custom_arg 491 | 492 | try: 493 | pass 494 | except CustomException as e: 495 | logging.error('Custom exception has occurred: %s', e.custom_arg) 496 | """)) 497 | visitor = LoggingVisitor() 498 | visitor.visit(tree) 499 | 500 | assert_that(visitor.violations, is_(empty())) 501 | 502 | 503 | def test_exception_attribute_in_extra(): 504 | """ 505 | In an except block, passing an exception attribute into logging as a value of extra dict is ok. 506 | 507 | """ 508 | tree = parse(dedent("""\ 509 | import logging 510 | 511 | class CustomException(Exception): 512 | def __init__(self, custom_arg): 513 | self.custom_arg = custom_arg 514 | 515 | try: 516 | pass 517 | except CustomException as e: 518 | logging.error('Custom exception has occurred: {custom_arg}', extra=dict(custom_arg=e.custom_arg)) 519 | """)) 520 | visitor = LoggingVisitor() 521 | visitor.visit(tree) 522 | 523 | assert_that(visitor.violations, is_(empty())) 524 | 525 | 526 | def test_exception_as_main_arg(): 527 | """ 528 | In an except block, passing the exception into logging as main argument is not ok. 529 | 530 | """ 531 | tree = parse(dedent("""\ 532 | import logging 533 | 534 | try: 535 | pass 536 | except Exception as e: 537 | logging.exception(e) 538 | """)) 539 | visitor = LoggingVisitor() 540 | visitor.visit(tree) 541 | 542 | assert_that(visitor.violations, has_length(1)) 543 | assert_that(visitor.violations[0][1], is_(equal_to(EXCEPTION_VIOLATION))) 544 | 545 | 546 | def test_exception_as_formatting_arg(): 547 | """ 548 | In an except block, passing the exception into logging as a formatting argument is not ok. 549 | 550 | """ 551 | tree = parse(dedent("""\ 552 | import logging 553 | 554 | try: 555 | pass 556 | except Exception as e: 557 | logging.exception('Exception occurred: %s', str(e)) 558 | """)) 559 | visitor = LoggingVisitor() 560 | visitor.visit(tree) 561 | 562 | assert_that(visitor.violations, has_length(1)) 563 | assert_that(visitor.violations[0][1], is_(equal_to(EXCEPTION_VIOLATION))) 564 | 565 | 566 | def test_exception_in_extra(): 567 | """ 568 | In an except block, passing the exception into logging as a value of extra dict is not ok. 569 | 570 | """ 571 | tree = parse(dedent("""\ 572 | import logging 573 | 574 | try: 575 | pass 576 | except Exception as e: 577 | logging.exception('Exception occurred: {exc}', extra=dict(exc=e)) 578 | """)) 579 | visitor = LoggingVisitor() 580 | visitor.visit(tree) 581 | 582 | assert_that(visitor.violations, has_length(1)) 583 | assert_that(visitor.violations[0][1], is_(equal_to(EXCEPTION_VIOLATION))) 584 | 585 | 586 | def test_nested_exception(): 587 | """ 588 | In a nested except block, using the exception from an outer except block is not ok. 589 | 590 | """ 591 | tree = parse(dedent("""\ 592 | import logging 593 | 594 | try: 595 | pass 596 | except Exception as e1: 597 | try: 598 | pass 599 | except Exception as e2: 600 | logging.exception(e1) 601 | logging.exception(e1) 602 | """)) 603 | visitor = LoggingVisitor() 604 | visitor.visit(tree) 605 | 606 | assert_that(visitor.violations, has_length(2)) 607 | assert_that(visitor.violations[0][1], is_(equal_to(EXCEPTION_VIOLATION))) 608 | assert_that(visitor.violations[1][1], is_(equal_to(EXCEPTION_VIOLATION))) 609 | 610 | 611 | def test_error_exc_info(): 612 | """ 613 | .error(..., exc_info=True) should not be used in favor of .exception(...). 614 | 615 | """ 616 | 617 | tree = parse(dedent("""\ 618 | import logging 619 | 620 | logging.error('Hello World', exc_info=True) 621 | """)) 622 | visitor = LoggingVisitor() 623 | visitor.visit(tree) 624 | 625 | assert_that(visitor.violations, has_length(1)) 626 | assert_that(visitor.violations[0][1], is_(equal_to(ERROR_EXC_INFO_VIOLATION))) 627 | 628 | 629 | def test_exception_exc_info(): 630 | """ 631 | .exception(..., exc_info=True) is redundant. 632 | 633 | """ 634 | 635 | tree = parse(dedent("""\ 636 | import logging 637 | 638 | logging.exception('Hello World', exc_info=True) 639 | """)) 640 | visitor = LoggingVisitor() 641 | visitor.visit(tree) 642 | 643 | assert_that(visitor.violations, has_length(1)) 644 | assert_that(visitor.violations[0][1], is_(equal_to(REDUNDANT_EXC_INFO_VIOLATION))) 645 | 646 | 647 | def test_app_log(): 648 | """ 649 | Detect nested loggers. 650 | 651 | """ 652 | tree = parse(dedent("""\ 653 | import logging 654 | 655 | class TestApp(object): 656 | 657 | def __init__(self, logger: logging.Logger, child=None): 658 | self.log = logger 659 | self.child = child 660 | 661 | app_name = "test-app" 662 | logger = logging.getLogger(app_name) 663 | 664 | app = TestApp(logger) 665 | 666 | logger = logging.getLogger("child1") 667 | app.child = TestApp(logger) 668 | 669 | app.log.info(f"Hello World for {app_name}") 670 | app.child.log.debug(f"Hello World for {app_name}") 671 | try: 672 | raise Exception("Another test") 673 | except Exception as exp: 674 | app.log.exception("Something bad has happened") 675 | """)) 676 | visitor = LoggingVisitor() 677 | visitor.visit(tree) 678 | 679 | assert_that(visitor.violations, has_length(1)) 680 | assert_that(visitor.violations[0][1], is_(equal_to(FSTRING_VIOLATION))) 681 | 682 | 683 | def test_argparse_parser_error(): 684 | """ 685 | argparse.ArgumentParser.error method should not be detected. 686 | """ 687 | tree = parse(dedent("""\ 688 | import argparse 689 | parser = argparse.ArgumentParser() 690 | parser.add_argument("target_dir", type=Path) 691 | args = parser.parse_args() 692 | parser.error(f"Target directory {args.target_dir} does not exist") 693 | """)) 694 | visitor = LoggingVisitor() 695 | visitor.visit(tree) 696 | 697 | assert_that(visitor.violations, is_(empty())) 698 | -------------------------------------------------------------------------------- /logging_format/tests/test_whitelist.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test whitelist. 3 | 4 | """ 5 | from hamcrest import assert_that, contains 6 | 7 | from logging_format.whitelist import Whitelist 8 | 9 | 10 | def test_whitelist(): 11 | whitelist = Whitelist(group="logging.extra.example") 12 | assert_that(whitelist.legal_keys, contains("world")) 13 | -------------------------------------------------------------------------------- /logging_format/violations.py: -------------------------------------------------------------------------------- 1 | """ 2 | Defined violations 3 | 4 | """ 5 | 6 | 7 | STRING_FORMAT_VIOLATION = "G001 Logging statement uses string.format()" 8 | STRING_CONCAT_VIOLATION = "G003 Logging statement uses '+'" 9 | 10 | PERCENT_FORMAT_VIOLATION = "G002 Logging statement uses '%'" 11 | 12 | FSTRING_VIOLATION = "G004 Logging statement uses f-string" 13 | 14 | WARN_VIOLATION = "G010 Logging statement uses 'warn' instead of 'warning'" 15 | 16 | WHITELIST_VIOLATION = "G100 Logging statement uses non-whitelisted extra keyword argument: {}" 17 | EXTRA_ATTR_CLASH_VIOLATION = "G101 Logging statement uses an extra field that clashes with a LogRecord field: {}" 18 | 19 | EXCEPTION_VIOLATION = "G200 Logging statement uses exception in arguments" 20 | 21 | ERROR_EXC_INFO_VIOLATION = "G201 Logging: .exception(...) should be used instead of .error(..., exc_info=True)" 22 | REDUNDANT_EXC_INFO_VIOLATION = "G202 Logging statement has redundant exc_info" 23 | -------------------------------------------------------------------------------- /logging_format/visitor.py: -------------------------------------------------------------------------------- 1 | """ 2 | AST Visitor to identify logging expressions. 3 | 4 | """ 5 | from ast import ( 6 | Add, 7 | Call, 8 | FormattedValue, 9 | Mod, 10 | Name, 11 | NodeVisitor, 12 | iter_child_nodes, 13 | keyword, 14 | ) 15 | from sys import version_info 16 | 17 | from logging_format.violations import ( 18 | ERROR_EXC_INFO_VIOLATION, 19 | EXCEPTION_VIOLATION, 20 | EXTRA_ATTR_CLASH_VIOLATION, 21 | FSTRING_VIOLATION, 22 | PERCENT_FORMAT_VIOLATION, 23 | REDUNDANT_EXC_INFO_VIOLATION, 24 | STRING_CONCAT_VIOLATION, 25 | STRING_FORMAT_VIOLATION, 26 | WARN_VIOLATION, 27 | WHITELIST_VIOLATION, 28 | ) 29 | 30 | 31 | LOGGING_LEVELS = { 32 | "debug", 33 | "critical", 34 | "error", 35 | "exception", 36 | "info", 37 | "warn", 38 | "warning", 39 | } 40 | 41 | 42 | # default LogRecord attributes that shouldn't be overwritten by extra dict 43 | RESERVED_ATTRS = { 44 | "args", "asctime", "created", "exc_info", "exc_text", "filename", 45 | "funcName", "levelname", "levelno", "lineno", "module", 46 | "msecs", "message", "msg", "name", "pathname", "process", "taskName", 47 | "processName", "relativeCreated", "stack_info", "thread", "threadName"} 48 | 49 | 50 | class LoggingVisitor(NodeVisitor): 51 | 52 | def __init__(self, whitelist=None): 53 | super().__init__() 54 | self.current_logging_call = None 55 | self.current_logging_argument = None 56 | self.current_logging_level = None 57 | self.current_extra_keyword = None 58 | self.current_except_names = [] 59 | self.violations = [] 60 | self.whitelist = whitelist 61 | 62 | def within_logging_statement(self): 63 | return self.current_logging_call is not None 64 | 65 | def within_logging_argument(self): 66 | return self.current_logging_argument is not None 67 | 68 | def within_extra_keyword(self, node): 69 | return self.current_extra_keyword is not None and self.current_extra_keyword != node 70 | 71 | def visit_Call(self, node): 72 | """ 73 | Visit a function call. 74 | 75 | We expect every logging statement and string format to be a function call. 76 | 77 | """ 78 | # CASE 1: We're in a logging statement 79 | if self.within_logging_statement(): 80 | if self.within_logging_argument() and self.is_format_call(node): 81 | self.violations.append((node, STRING_FORMAT_VIOLATION)) 82 | super().generic_visit(node) 83 | return 84 | 85 | logging_level = self.detect_logging_level(node) 86 | 87 | if logging_level and self.current_logging_level is None: 88 | self.current_logging_level = logging_level 89 | 90 | # CASE 2: We're in some other statement 91 | if logging_level is None: 92 | super().generic_visit(node) 93 | return 94 | 95 | # CASE 3: We're entering a new logging statement 96 | self.current_logging_call = node 97 | 98 | if logging_level == "warn": 99 | self.violations.append((node, WARN_VIOLATION)) 100 | 101 | self.check_exc_info(node) 102 | 103 | for index, child in enumerate(iter_child_nodes(node)): 104 | if index == 1: 105 | self.current_logging_argument = child 106 | if index >= 1: 107 | self.check_exception_arg(child) 108 | if index > 1 and isinstance(child, keyword) and child.arg == "extra": 109 | self.current_extra_keyword = child 110 | 111 | super().visit(child) 112 | 113 | self.current_logging_argument = None 114 | self.current_extra_keyword = None 115 | 116 | self.current_logging_call = None 117 | self.current_logging_level = None 118 | 119 | def visit_BinOp(self, node): 120 | """ 121 | Process binary operations while processing the first logging argument. 122 | 123 | """ 124 | if self.within_logging_statement() and self.within_logging_argument(): 125 | # handle percent format 126 | if isinstance(node.op, Mod): 127 | self.violations.append((node, PERCENT_FORMAT_VIOLATION)) 128 | # handle string concat 129 | if isinstance(node.op, Add): 130 | self.violations.append((node, STRING_CONCAT_VIOLATION)) 131 | super().generic_visit(node) 132 | 133 | def visit_Dict(self, node): 134 | """ 135 | Process dict arguments. 136 | 137 | """ 138 | if self.should_check_whitelist(node): 139 | for key in node.keys: 140 | if key.s in self.whitelist or key.s.startswith("debug_"): 141 | continue 142 | self.violations.append((self.current_logging_call, WHITELIST_VIOLATION.format(key.s))) 143 | 144 | if self.should_check_extra_field_clash(node): 145 | for key in node.keys: 146 | # key can be None if the dict uses double star syntax 147 | if key is not None and key.s in RESERVED_ATTRS: 148 | self.violations.append((self.current_logging_call, EXTRA_ATTR_CLASH_VIOLATION.format(key.s))) 149 | 150 | if self.should_check_extra_exception(node): 151 | for value in node.values: 152 | self.check_exception_arg(value) 153 | 154 | super().generic_visit(node) 155 | 156 | def visit_JoinedStr(self, node): 157 | """ 158 | Process f-string arguments. 159 | 160 | """ 161 | if self.within_logging_statement(): 162 | if any(isinstance(i, FormattedValue) for i in node.values): 163 | if self.within_logging_argument(): 164 | self.violations.append((node, FSTRING_VIOLATION)) 165 | super().generic_visit(node) 166 | 167 | def visit_keyword(self, node): 168 | """ 169 | Process keyword arguments. 170 | 171 | """ 172 | if self.should_check_whitelist(node): 173 | if node.arg not in self.whitelist and not node.arg.startswith("debug_"): 174 | self.violations.append((self.current_logging_call, WHITELIST_VIOLATION.format(node.arg))) 175 | 176 | if self.should_check_extra_field_clash(node): 177 | if node.arg in RESERVED_ATTRS: 178 | self.violations.append((self.current_logging_call, EXTRA_ATTR_CLASH_VIOLATION.format(node.arg))) 179 | 180 | if self.should_check_extra_exception(node): 181 | self.check_exception_arg(node.value) 182 | 183 | super().generic_visit(node) 184 | 185 | def visit_ExceptHandler(self, node): 186 | """ 187 | Process except blocks. 188 | 189 | """ 190 | name = self.get_except_handler_name(node) 191 | if not name: 192 | super().generic_visit(node) 193 | return 194 | 195 | self.current_except_names.append(name) 196 | super().generic_visit(node) 197 | self.current_except_names.pop() 198 | 199 | def detect_logging_level(self, node): 200 | """ 201 | Heuristic to decide whether an AST Call is a logging call. 202 | 203 | """ 204 | try: 205 | if self.get_id_attr(node.func.value) in ["parser", "warnings"]: 206 | return None 207 | # NB: We could also look at the argument signature or the target attribute 208 | if node.func.attr in LOGGING_LEVELS: 209 | return node.func.attr 210 | except AttributeError: 211 | pass 212 | return None 213 | 214 | def is_format_call(self, node): 215 | """ 216 | Does a function call use format? 217 | 218 | """ 219 | try: 220 | return node.func.attr == "format" 221 | except AttributeError: 222 | return False 223 | 224 | def should_check_whitelist(self, node): 225 | return all( 226 | ( 227 | self.current_logging_level != 'debug', 228 | self.within_logging_statement(), 229 | self.within_extra_keyword(node), 230 | self.whitelist is not None, 231 | ) 232 | ) 233 | 234 | def should_check_extra_field_clash(self, node): 235 | return all( 236 | ( 237 | self.within_logging_statement(), 238 | self.within_extra_keyword(node), 239 | ) 240 | ) 241 | 242 | def should_check_extra_exception(self, node): 243 | return all( 244 | ( 245 | self.within_logging_statement(), 246 | self.within_extra_keyword(node), 247 | len(self.current_except_names) > 0, 248 | ) 249 | ) 250 | 251 | def get_except_handler_name(self, node): 252 | """ 253 | Helper to get the exception name from an ExceptHandler node in both py2 and py3. 254 | 255 | """ 256 | name = node.name 257 | if not name: 258 | return None 259 | 260 | if version_info < (3,): 261 | return name.id 262 | return name 263 | 264 | def get_id_attr(self, value): 265 | """Check if value has id attribute and return it. 266 | 267 | :param value: The value to get id from. 268 | :return: The value.id. 269 | """ 270 | if not hasattr(value, "id") and hasattr(value, "value"): 271 | value = value.value 272 | return value.id 273 | 274 | def is_bare_exception(self, node): 275 | """ 276 | Checks if the node is a bare exception name from an except block. 277 | 278 | """ 279 | return isinstance(node, Name) and node.id in self.current_except_names 280 | 281 | def is_str_exception(self, node): 282 | """ 283 | Checks if the node is the expression str(e) or unicode(e), where e is an exception name from an except block 284 | 285 | """ 286 | return ( 287 | isinstance(node, Call) 288 | and isinstance(node.func, Name) 289 | and node.func.id in ('str', 'unicode') 290 | and node.args 291 | and self.is_bare_exception(node.args[0]) 292 | ) 293 | 294 | def check_exception_arg(self, node): 295 | if self.is_bare_exception(node) or self.is_str_exception(node): 296 | self.violations.append((self.current_logging_call, EXCEPTION_VIOLATION)) 297 | 298 | def check_exc_info(self, node): 299 | """ 300 | Reports a violation if exc_info keyword is used with logging.error or logging.exception. 301 | 302 | """ 303 | if self.current_logging_level not in ('error', 'exception'): 304 | return 305 | 306 | for kw in node.keywords: 307 | if kw.arg == 'exc_info': 308 | if self.current_logging_level == 'error': 309 | violation = ERROR_EXC_INFO_VIOLATION 310 | else: 311 | violation = REDUNDANT_EXC_INFO_VIOLATION 312 | self.violations.append((node, violation)) 313 | -------------------------------------------------------------------------------- /logging_format/whitelist.py: -------------------------------------------------------------------------------- 1 | """ 2 | A logging extra keyword argument whitelist. 3 | 4 | """ 5 | from pkg_resources import iter_entry_points 6 | 7 | 8 | class Whitelist: 9 | """ 10 | A pluggable whitelist. 11 | 12 | Uses entry points. 13 | 14 | """ 15 | def __init__(self, group="logging.extra.whitelist"): 16 | self.legal_keys = { 17 | legal_key 18 | for entry_point in iter_entry_points(group) 19 | for legal_key in entry_point.load()() 20 | } 21 | 22 | def __iter__(self): 23 | return iter(self.legal_keys) 24 | 25 | def __contains__(self, key): 26 | return key in self.legal_keys 27 | 28 | 29 | def example_whitelist(): 30 | """ 31 | Example whitelist entry point used for testing. 32 | 33 | """ 34 | return ["world"] 35 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | max-line-length = 120 3 | max-complexity = 15 4 | exclude = */migrations/*,.eggs/* 5 | 6 | [isort] 7 | combine_as_imports = True 8 | force_grid_wrap = 4 9 | float_to_top = True 10 | include_trailing_comma = True 11 | known_first_party = logging_format 12 | extra_standard_library = pkg_resources 13 | line_length = 99 14 | lines_after_imports = 2 15 | multi_line_output = 3 16 | skip = __init__.py 17 | 18 | [mypy] 19 | ignore_missing_imports = True 20 | 21 | [tool:pytest] 22 | addopts = 23 | --cov logging_format 24 | --cov-report xml:logging_format/tests/coverage/cov.xml 25 | --junitxml=logging_format/tests/test-results/pytest/junit.xml 26 | 27 | [coverage:report] 28 | show_missing = True 29 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from setuptools import find_packages, setup 3 | 4 | 5 | project = "flake8-logging-format" 6 | version = "1.0.0" 7 | long_description = open("README.md").read() 8 | 9 | setup( 10 | name=project, 11 | version=version, 12 | author="Globality Engineering", 13 | author_email="engineering@globality.com", 14 | url="https://github.com/globality-corp/flake8-logging-format", 15 | license="Apache License 2.0", 16 | long_description=long_description, 17 | long_description_content_type="text/markdown", 18 | packages=find_packages(exclude=["*.tests", "*.tests.*", "tests.*", "tests"]), 19 | include_package_data=True, 20 | zip_safe=False, 21 | keywords="microcosm", 22 | install_requires=[ 23 | ], 24 | extras_require={ 25 | "test": [ 26 | "pytest", 27 | "pytest-cov", 28 | "PyHamcrest", 29 | ], 30 | "lint": [ 31 | "flake8", 32 | ] 33 | }, 34 | dependency_links=[ 35 | ], 36 | entry_points={ 37 | "flake8.extension": [ 38 | "G = logging_format.api:LoggingFormatValidator", 39 | ], 40 | "logging.extra.example": [ 41 | "example = logging_format.whitelist:example_whitelist", 42 | ], 43 | }, 44 | classifiers=[ 45 | "Framework :: Flake8", 46 | ], 47 | ) 48 | --------------------------------------------------------------------------------