├── .github └── workflows │ ├── static-check.yml │ └── unittest.yml ├── .gitignore ├── LICENSE ├── README.md ├── mypy_gitlab_code_quality └── __init__.py ├── pyproject.toml ├── requirements └── dev.txt └── tests.py /.github/workflows/static-check.yml: -------------------------------------------------------------------------------- 1 | name: Static check source code 2 | 3 | on: 4 | push: 5 | branches: ['main', 'develop'] 6 | pull_request: 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.13' 17 | cache: 'pip' 18 | cache-dependency-path: 'requirements/dev.txt' 19 | - name: Install requirements 20 | run: 'python -m pip install -r requirements/dev.txt' 21 | 22 | - name: Ruff format 23 | run: 'ruff format --diff' 24 | - name: Ruff check 25 | run: 'ruff check --output-format github' 26 | - name: Mypy 27 | run: 'mypy .' 28 | -------------------------------------------------------------------------------- /.github/workflows/unittest.yml: -------------------------------------------------------------------------------- 1 | name: Run unit tests 2 | 3 | on: 4 | push: 5 | branches: ['main', 'develop'] 6 | pull_request: 7 | 8 | jobs: 9 | check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - name: Set up Python 3.9 14 | uses: actions/setup-python@v5 15 | with: 16 | python-version: '3.9' 17 | 18 | - name: Unittest 19 | run: 'python -m unittest' 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__/ 2 | .idea/ 3 | .mypy_cache/ 4 | .ruff_cache/ 5 | *.egg-info/ 6 | dist/ 7 | venv/ 8 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Dmitry Samsonov 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Gitlab-CI](https://img.shields.io/badge/GitLab_CI-indigo?logo=gitlab) 2 | ![PyPI - Python Version](https://img.shields.io/pypi/pyversions/mypy-gitlab-code-quality) 3 | [![PyPI](https://img.shields.io/pypi/v/mypy-gitlab-code-quality)](https://pypi.org/project/mypy-gitlab-code-quality/) 4 | [![Downloads](https://static.pepy.tech/badge/mypy-gitlab-code-quality/month)](https://pepy.tech/project/mypy-gitlab-code-quality) 5 | ![PyPI - License](https://img.shields.io/pypi/l/mypy-gitlab-code-quality) 6 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 7 | 8 | # mypy-gitlab-code-quality 9 | Simple script to generate [gitlab code quality report](https://docs.gitlab.com/ee/user/project/merge_requests/code_quality.html) 10 | from output of [mypy](http://www.mypy-lang.org/). 11 | 12 | Example gitlab codequality report from [gitlab documentation](https://docs.gitlab.com/ee/ci/testing/code_quality.html#merge-request-widget): 13 | ![Example gitlab codequality widget](https://docs.gitlab.com/ee/ci/testing/img/code_quality_widget_v13_11.png) 14 | 15 | # Usage 16 | `$ mypy program.py --output=json | mypy-gitlab-code-quality` 17 | 18 | This command send to `STDOUT` generated json that can be used as Code Quality report artifact. 19 | 20 | Also, this script supports plain text output parsing for backward compatability but json is recommended. 21 | 22 | `$ mypy program.py | mypy-gitlab-code-quality` 23 | 24 | ## Example .gitlab-ci.yml 25 | ```yaml 26 | image: python:alpine 27 | codequality: 28 | script: 29 | - pip install mypy mypy-gitlab-code-quality 30 | - mypy program.py --output=json > mypy-out.json || true # "|| true" is used for preventing job fail when mypy find errors 31 | - mypy-gitlab-code-quality < mypy-out.json > codequality.json 32 | artifacts: 33 | when: always 34 | reports: 35 | codequality: codequality.json 36 | ``` 37 | Note: if you want to use this example you should replace `program.py` with yours module names. 38 | 39 | # Contributing 40 | Please run linters before creating pull request 41 | ```shell 42 | pip install requirements/dev.txt 43 | mypy . 44 | ruff check 45 | ruff format 46 | ``` 47 | Suggestions and pull requests are always welcome :) 48 | -------------------------------------------------------------------------------- /mypy_gitlab_code_quality/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import hashlib 4 | import json 5 | import re 6 | from enum import Enum 7 | from functools import reduce 8 | from sys import stdin, stdout 9 | from typing import TYPE_CHECKING, TypedDict 10 | 11 | if TYPE_CHECKING: 12 | from collections.abc import Iterable 13 | 14 | 15 | class Severity(str, Enum): 16 | major = "major" 17 | info = "info" 18 | unknown = "unknown" 19 | 20 | 21 | class GitlabIssueLines(TypedDict): 22 | begin: int 23 | 24 | 25 | class GitlabIssueLocation(TypedDict): 26 | path: str 27 | lines: GitlabIssueLines 28 | 29 | 30 | class GitlabIssue(TypedDict): 31 | description: str 32 | check_name: str | None 33 | fingerprint: str 34 | severity: Severity 35 | location: GitlabIssueLocation 36 | 37 | 38 | def parse_issue(line: str, fingerprints: set[str] | None = None) -> GitlabIssue | None: 39 | if line.startswith("{"): 40 | try: 41 | match = json.loads(line) 42 | except json.JSONDecodeError: 43 | match = None 44 | if hint := match.get("hint"): # attach hint to message 45 | match["message"] += "\n" + hint 46 | else: 47 | match = re.fullmatch( 48 | r"(?P.+?)" 49 | r":(?P\d+)(?::\d+)?" # ignore column number if exists 50 | r":\s(?P\w+)" 51 | r":\s(?P.+?)" 52 | r"(?:\s\s\[(?P.*)])?", 53 | line, 54 | ) 55 | if match is None: 56 | return None 57 | error_levels_table = {"error": Severity.major, "note": Severity.info} 58 | 59 | path = match["file"] 60 | line_number = int(match["line"]) 61 | error_level = match["severity"] 62 | message = match["message"] 63 | error_code = match["code"] 64 | 65 | if fingerprints is None: 66 | fingerprints = set() 67 | 68 | def make_fingerprint(salt: str) -> str: 69 | fingerprint_text = f"{salt}::{path}::{error_level}::{error_code}::{message}" 70 | return hashlib.md5( 71 | fingerprint_text.encode("utf-8"), 72 | usedforsecurity=False, 73 | ).hexdigest() 74 | 75 | fingerprint = make_fingerprint("") 76 | while fingerprint in fingerprints: 77 | fingerprint = make_fingerprint(fingerprint) 78 | fingerprints.add(fingerprint) 79 | 80 | return { 81 | "description": message, 82 | "check_name": error_code, 83 | "fingerprint": fingerprint, 84 | "severity": error_levels_table.get(error_level, Severity.unknown), 85 | "location": { 86 | "path": path, 87 | "lines": { 88 | "begin": line_number, 89 | }, 90 | }, 91 | } 92 | 93 | 94 | def append_or_extend(issues: list[GitlabIssue], new: GitlabIssue) -> list[GitlabIssue]: 95 | """ 96 | Extend previous issue with description of new one in case of "note" error level. 97 | 98 | It is useful to extend error issues with note issues to prevent inconsistent view 99 | of code quality widget. For more information see 100 | https://github.com/soul-catcher/mypy-gitlab-code-quality/pull/3 101 | """ 102 | is_extend_previous = ( 103 | new["severity"] == Severity.info 104 | and issues 105 | and issues[-1]["location"] == new["location"] 106 | ) 107 | if is_extend_previous: 108 | issues[-1]["description"] += "\n" + new["description"] 109 | else: 110 | issues.append(new) 111 | return issues 112 | 113 | 114 | def generate_report(lines: Iterable[str]) -> list[GitlabIssue]: 115 | fingerprints: set[str] = set() 116 | issues = filter(None, (parse_issue(line, fingerprints) for line in lines)) 117 | return reduce(append_or_extend, issues, []) 118 | 119 | 120 | def main() -> None: 121 | json.dump(generate_report(map(str.rstrip, stdin)), stdout, indent="\t") 122 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "mypy-gitlab-code-quality" 3 | version = "1.3.0" 4 | description = "Simple script to generate gitlab code quality report from output of mypy." 5 | readme = "README.md" 6 | requires-python = ">=3.9" 7 | license = { file = "LICENSE" } 8 | authors = [ 9 | { name = "Dmitry Samsonov", email = "dmitriy.samsonov28@gmail.com" }, 10 | { name = "OokamiTheLord" }, 11 | ] 12 | keywords = ["gitlab", "gitlab-ci", "mypy", "codequality"] 13 | classifiers = [ 14 | "Development Status :: 5 - Production/Stable", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python :: 3", 18 | "Programming Language :: Python :: 3.9", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | ] 24 | [project.urls] 25 | "Homepage" = "https://github.com/soul-catcher/mypy-gitlab-code-quality" 26 | "Bug Tracker" = "https://github.com/soul-catcher/mypy-gitlab-code-quality/issues" 27 | [project.scripts] 28 | mypy-gitlab-code-quality = "mypy_gitlab_code_quality:main" 29 | 30 | [build-system] 31 | requires = ["setuptools"] 32 | build-backend = "setuptools.build_meta" 33 | 34 | [tool.mypy] 35 | strict = true 36 | python_version = "3.9" 37 | exclude = ['test.*\.py'] 38 | 39 | [tool.ruff.lint] 40 | select = ["ALL"] 41 | ignore = [ 42 | "D1", # Docstrings 43 | "PT", # Pytest. Project uses Unittest 44 | "ANN", # Annotations. There is Mypy in project 45 | "FIX", # Check for temporary developer notes 46 | "TD003", # Temporary developer note missing link to issue 47 | "COM812", # Comma checks. Conflicts with Ruff formatter 48 | "ISC001", # Implicit string concatenation. Conflicts with Ruff formatter 49 | ] 50 | pydocstyle.convention = "pep257" 51 | -------------------------------------------------------------------------------- /requirements/dev.txt: -------------------------------------------------------------------------------- 1 | mypy == 1.15.0 2 | ruff == 0.11.2 3 | -------------------------------------------------------------------------------- /tests.py: -------------------------------------------------------------------------------- 1 | import json 2 | import unittest 3 | 4 | from mypy_gitlab_code_quality import Severity, parse_issue 5 | 6 | 7 | class ParsePlainTextTestCase(unittest.TestCase): 8 | def test_path(self): 9 | issue = parse_issue("dir/module.py:2: error: Description") 10 | self.assertEqual("dir/module.py", issue["location"]["path"]) 11 | 12 | def test_line_number(self): 13 | issue = parse_issue("module.py:2: error: Description") 14 | self.assertEqual(2, issue["location"]["lines"]["begin"]) 15 | 16 | def test_fingerprint(self): 17 | issue = parse_issue("module.py:2: error: Description") 18 | self.assertEqual("d84c90ce1414af244070c71da60f2388", issue["fingerprint"]) 19 | 20 | def test_error_level_error(self): 21 | issue = parse_issue("module.py:2: error: Description") 22 | self.assertEqual(Severity.major, issue["severity"]) 23 | 24 | def test_error_level_note(self): 25 | issue = parse_issue("module.py:2: note: Description") 26 | self.assertEqual(Severity.info, issue["severity"]) 27 | 28 | def test_error_level_unknown(self): 29 | issue = parse_issue("module.py:2: egg: Description") 30 | self.assertEqual(Severity.unknown, issue["severity"]) 31 | 32 | def test_description(self): 33 | issue = parse_issue("module.py:2: error: Description") 34 | self.assertEqual("Description", issue["description"]) 35 | 36 | def test_description_with_characters(self): 37 | issue = parse_issue('module.py:2: error: Description "abc" [123] (eee)') 38 | self.assertEqual('Description "abc" [123] (eee)', issue["description"]) 39 | 40 | def test_error_code(self): 41 | issue = parse_issue("module.py:2: error: Description [error-code]") 42 | self.assertEqual("error-code", issue["check_name"]) 43 | 44 | def test_column_number(self): 45 | issue = parse_issue("module.py:2:5: error: Description") 46 | self.assertEqual(2, issue["location"]["lines"]["begin"]) 47 | self.assertEqual(Severity.major, issue["severity"]) 48 | 49 | def test_summary(self): 50 | issue = parse_issue("Found 5 errors in 1 file (checked 1 source file)") 51 | self.assertIsNone(issue) 52 | 53 | 54 | class ParseJsonTestCase(unittest.TestCase): 55 | def test_path(self): 56 | issue = parse_issue( 57 | r"""{ 58 | "file": "dir/module.py", 59 | "line": 2, 60 | "column": 4, 61 | "message": "Description", 62 | "hint": null, 63 | "code": "error-code", 64 | "severity": "error" 65 | }""" 66 | ) 67 | self.assertEqual("dir/module.py", issue["location"]["path"]) 68 | 69 | def test_line_number(self): 70 | issue = parse_issue( 71 | r"""{ 72 | "file": "module.py", 73 | "line": 2, 74 | "column": 4, 75 | "message": "Description", 76 | "hint": null, 77 | "code": "error-code", 78 | "severity": "error" 79 | }""" 80 | ) 81 | self.assertEqual(2, issue["location"]["lines"]["begin"]) 82 | 83 | def test_fingerprint(self): 84 | fingerprints = set() 85 | issue_dict = { 86 | "file": "module.py", 87 | "line": 2, 88 | "column": 4, 89 | "message": "Description", 90 | "hint": None, 91 | "code": "error-code", 92 | "severity": "error", 93 | } 94 | issue = parse_issue(json.dumps(issue_dict), fingerprints) 95 | issue_dict["line"] = 3 96 | other_issue = parse_issue(json.dumps(issue_dict), fingerprints) 97 | self.assertEqual("59d5fc1777dba182a0bcac9dc1bd33a6", issue["fingerprint"]) 98 | self.assertEqual("895f1dad0c7bb31d7bb4f792b2b16784", other_issue["fingerprint"]) 99 | 100 | def test_error_level_error(self): 101 | issue = parse_issue( 102 | r"""{ 103 | "file": "module.py", 104 | "line": 2, 105 | "column": 4, 106 | "message": "Description", 107 | "hint": null, 108 | "code": "error-code", 109 | "severity": "error" 110 | }""" 111 | ) 112 | self.assertEqual(Severity.major, issue["severity"]) 113 | 114 | def test_error_level_note(self): 115 | issue = parse_issue( 116 | r"""{ 117 | "file": "module.py", 118 | "line": 2, 119 | "column": 4, 120 | "message": "Description", 121 | "hint": null, 122 | "code": "error-code", 123 | "severity": "note" 124 | }""" 125 | ) 126 | self.assertEqual(Severity.info, issue["severity"]) 127 | 128 | def test_error_level_unknown(self): 129 | issue = parse_issue( 130 | r"""{ 131 | "file": "module.py", 132 | "line": 2, 133 | "column": 4, 134 | "message": "Description", 135 | "hint": null, 136 | "code": "error-code", 137 | "severity": "egg" 138 | }""" 139 | ) 140 | self.assertEqual(Severity.unknown, issue["severity"]) 141 | 142 | def test_description(self): 143 | issue = parse_issue( 144 | r"""{ 145 | "file": "module.py", 146 | "line": 2, 147 | "column": 4, 148 | "message": "Description", 149 | "hint": null, 150 | "code": "error-code", 151 | "severity": "error" 152 | }""" 153 | ) 154 | self.assertEqual("Description", issue["description"]) 155 | 156 | def test_description_with_characters(self): 157 | issue = parse_issue( 158 | r"""{ 159 | "file": "module.py", 160 | "line": 2, 161 | "column": 4, 162 | "message": "Incompatible (got \"None\", expected \"Object\")", 163 | "hint": null, 164 | "code": "error-code", 165 | "severity": "error" 166 | }""" 167 | ) 168 | self.assertEqual( 169 | 'Incompatible (got "None", expected "Object")', 170 | issue["description"], 171 | ) 172 | 173 | def test_error_code(self): 174 | issue = parse_issue( 175 | r"""{ 176 | "file": "module.py", 177 | "line": 2, 178 | "column": 4, 179 | "message": "Description", 180 | "hint": null, 181 | "code": "error-code", 182 | "severity": "error" 183 | }""" 184 | ) 185 | self.assertEqual("error-code", issue["check_name"]) 186 | 187 | def test_hint(self): 188 | issue = parse_issue( 189 | r"""{ 190 | "file": "module.py", 191 | "line": 2, 192 | "column": 4, 193 | "message": "Description", 194 | "hint": "Hint", 195 | "code": "error-code", 196 | "severity": "error" 197 | }""" 198 | ) 199 | self.assertEqual("Description\nHint", issue["description"]) 200 | --------------------------------------------------------------------------------