Login

Calculating Maintainability Index for a whole project

Author:
MrtnBckr
Posted:
April 18, 2019
Language:
Python
Version:
1.11
Tags:
django command git maintainability
Score:
1 (after 1 ratings)

With this command you can calculate the maintainability index for your whole project.

In your settings you have to add a dictionary called RADON_MI_SETTINGS. It could be like this:

python RADON_MI_SETTINGS = { 'paths': ['projectname'], 'exclude': 'projectname/some_app/some_file.py', 'ignore': 'migrations,tests', }

I had to add following packages: radon==3.0.1 progress==1.5 plotly==3.7.0 GitPython==2.1.11

Following commands are available:

  • python manage.py calculate_maintainability_index Only display the maintainability index of the project. The average from every file is build by using the logical lines of code per file.
  • python manage.py calculate_maintainability_index --init Go through every commit filtered by their commit_message (is set to “bump version” currently) and calculate the maintainability index for the whole project. This creates a file with the history.
  • python manage.py calculate_maintainability_index --showhistory Display the history of the maintainability_index in a graph in your browser.
  • python manage.py calculate_maintainability_index --commit Calculate the current maintainability_index and append it to your history. Commit your edited history file.
  • python manage.py calculate_maintainability_index --fail Calculate the current maintainability_index and raise an Error, if it is lower than the last entry in your history. Useful for use in an automated pipeline.

Hints:

  • radon has a problem with large lists and dictionaries. If you have a file with a list or dictionary with more than 100 entries, you should exclude it.
  • To initialize your history you should change the commitmessage filter to something, that suits your needs.

Created by Martin Becker at Jonas und der Wolf GmbH

  1
  2
  3
  4
  5
  6
  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
import datetime
import json
import tempfile
import webbrowser
from collections import OrderedDict

import plotly
from git import Repo
from progress.bar import Bar
from radon.cli import Config, MIHarvester, RawHarvester

from django.conf import settings
from django.core.management.base import BaseCommand
from django.utils import timezone


class WorseMaintainabilityIndexError(Exception):
    pass


class Command(BaseCommand):
    help = "Calculate current Maintainability Index"

    def add_arguments(self, parser):
        parser.add_argument(
            '--commit',
            action='store_true',
            dest='commit',
            default=False,
            help="Commit result.",
        )
        parser.add_argument(
            '--fail',
            action='store_true',
            dest='fail',
            default=False,
            help="Raise Error, if MI is worse than before.",
        )
        parser.add_argument(
            '--showhistory',
            action='store_true',
            dest='showhistory',
            default=False,
            help="Show history of MI in browser.",
        )
        parser.add_argument(
            '--init',
            action='store_true',
            dest='init',
            default=False,
            help="Initialize history for past commits.",
        )

    def handle(self, *args, **options):
        if options.get('init', False):
            build_for_past('bump version')
            return

        mi_calculator = MaintainabilityIndexCalculater()
        if options.get('showhistory', False):
            mi_calculator.show_history()
            return
        if options.get('fail', False):
            mi_calculator.check_for_fail()
        if options.get('commit', False):
            mi_calculator.commit_new_mi()
        return str(mi_calculator.maintainability_index)


class MaintainabilityIndexCalculater(object):
    def __init__(self):
        self.radon_settings = getattr(settings, 'RADON_MI_SETTINGS', {})

        self.history_file_path = self.radon_settings.get(
            'history_file_path', 'mi_history.json'
        )
        self.current_state_file_path = self.radon_settings.get(
            'current_state_file_path', 'mi_current_state.json'
        )
        self.__init_harvesters()

    def get_current_history(self) -> dict:
        """
        Return dict of history of MIs by given path to history file.
        """
        mi_history = getattr(self, '_mi_history', False)
        if mi_history:
            return mi_history
        try:
            with open(self.history_file_path, 'r') as mi_history_file:
                current_history = json.loads(mi_history_file.read())
        except FileNotFoundError:
            current_history = {}
        setattr(self, '_mi_history', current_history)
        return current_history

    def check_for_fail(self) -> None:
        """
        Raise an Error if new_maintainability_index is worse than recent from history.
        """
        mi_history = self.get_current_history()
        if not mi_history:
            # No old data. No worse result possible.
            return
        last_history = mi_history[sorted(mi_history.keys())[-1]]
        last_maintainability_index = last_history['mi']
        if self.maintainability_index < last_maintainability_index:
            raise WorseMaintainabilityIndexError(
                f'From {last_maintainability_index} ' f'to {self.maintainability_index}!'
            )

    def commit_new_mi(self) -> None:
        """
        Save new maintainability index and current state of all files in a commit.
        """
        repo = Repo.init()
        index = Repo.init().index
        current_commit = repo.head.commit
        mi_history = self.get_current_history()
        with open(self.history_file_path, 'w') as mi_history_file:
            mi_history[timezone.now().isoformat()] = {
                'sha': current_commit.hexsha,
                'mi': self.maintainability_index,
                'lloc': self.lloc,
                'loc': self.loc,
                'sloc': self.sloc,
            }
            mi_history_file.write(json.dumps(mi_history))
        with open(self.current_state_file_path, 'w') as mi_current_state:
            mi_current_state.write(self.mi_harvester.as_json())
        index.add([self.history_file_path, self.current_state_file_path])
        index.commit('chore: Update maintainability index')
        repo.close()

    def __init_harvesters(self) -> None:
        """
        Initialize needed harvesters with settings.
        """
        paths = self.radon_settings.get('paths', [])
        config = Config(
            min='A',
            max='C',
            exclude=self.radon_settings.get('exclude'),
            ignore=self.radon_settings.get('ignore'),
            multi=self.radon_settings.get('multi', False),
            show=True,
            sort=True,
        )
        raw_harvester = RawHarvester(paths, config)
        mi_harvester = MIHarvester(paths, config)
        self.raw_harvester = raw_harvester
        self.mi_harvester = mi_harvester

    @property
    def maintainability_index(self) -> float:
        """
        Return maintainability index of given scope.

        1. Count logical lines of code (lloc) on every file.
        2. Calculate Maintainability Index on every file.
        3. Merge Maintainability Index of every file by rating files higher by their
           logical lines of code.
        """
        maintainability_index = getattr(self, '_maintainability_index', None)
        if maintainability_index:
            return maintainability_index

        loc_data = self.loc_data

        lloc_count = 0
        global_mi = 0

        for file_name, mi_data in self.mi_harvester.results:
            try:
                lloc = loc_data[file_name]['lloc']
                mi = mi_data['mi']
            except KeyError:
                continue
            lloc_count += lloc
            global_mi += mi * lloc

        maintainability_index = global_mi / lloc_count

        setattr(self, '_maintainability_index', maintainability_index)

        return maintainability_index

    @property
    def loc_data(self) -> dict:
        """
        Return data collected by RAW Harvester.
        """
        loc_data = getattr(self, '_loc_data', None)
        if loc_data:
            return loc_data
        loc_data = dict(self.raw_harvester.results)
        setattr(self, '_loc_data', loc_data)
        return loc_data

    @property
    def loc(self) -> int:
        """
        Return lines of code of given scope.
        """
        loc_data = self.loc_data
        return sum(file_loc_data.get('loc', 0) for file_loc_data in loc_data.values())

    @property
    def lloc(self) -> int:
        """
        Return logical lines of code of given scope.
        """
        loc_data = self.loc_data
        return sum(file_loc_data.get('lloc', 0) for file_loc_data in loc_data.values())

    @property
    def sloc(self) -> int:
        """
        Return logical lines of code of given scope.
        """
        loc_data = self.loc_data
        return sum(file_loc_data.get('sloc', 0) for file_loc_data in loc_data.values())

    @property
    def comments(self) -> int:
        """
        Return comment lines of code of given scope.
        """
        loc_data = self.loc_data
        return sum(
            file_loc_data.get('comments', 0) for file_loc_data in loc_data.values()
        )

    def show_history(self) -> None:
        """
        Show history of the maintainability index in graph in browser.
        """
        mi_history = self.get_current_history()
        mi_history = OrderedDict(sorted(mi_history.items(), key=lambda x: x[0]))
        temporary_file = tempfile.NamedTemporaryFile(prefix='mi_history', suffix='.html')
        file_name = temporary_file.name
        temporary_file.close()

        mi_data = plotly.graph_objs.Scatter(
            x=list(mi_history.keys()),
            y=[values['mi'] for values in mi_history.values()],
            name='MI',
        )
        loc_data = plotly.graph_objs.Scatter(
            x=list(mi_history.keys()),
            y=[values['loc'] for values in mi_history.values()],
            yaxis='y2',
            name='LOC',
        )
        lloc_data = plotly.graph_objs.Scatter(
            x=list(mi_history.keys()),
            y=[values['lloc'] for values in mi_history.values()],
            yaxis='y2',
            name='LLOC',
        )
        sloc_data = plotly.graph_objs.Scatter(
            x=list(mi_history.keys()),
            y=[values['sloc'] for values in mi_history.values()],
            yaxis='y2',
            name='SLOC',
        )
        plotly.offline.plot(
            {
                'data': [mi_data, loc_data, lloc_data, sloc_data],
                'layout': plotly.graph_objs.Layout(
                    yaxis={'rangemode': 'tozero'},
                    yaxis2={
                        'side': 'right',
                        'overlaying': 'y',
                        'showgrid': False,
                        'rangemode': 'tozero',
                    },
                ),
            },
            auto_open=False,
            filename=file_name,
        )
        if file_name[0] != '/':
            # windows ...
            url = 'file:///' + file_name
        else:
            url = 'file://' + file_name
        webbrowser.open_new(url)


def build_for_past(commit_lookup_message: str) -> None:
    repo = Repo.init()
    current_branch = repo.active_branch

    if repo.is_dirty():
        raise Exception('Dirty Repo! Stash/Commit unchanged files!')

    history = {}
    revisions = []
    for commit in repo.iter_commits(current_branch):
        if commit_lookup_message in commit.message:
            revisions.append(
                (
                    commit.hexsha,  # key
                    datetime.datetime.fromtimestamp(commit.committed_date),  # date
                )
            )
    bar = Bar("Processing", max=len(revisions))
    error = None
    try:
        for revision in revisions:
            repo.git.checkout(revision[0])
            mic = MaintainabilityIndexCalculater()
            mi = mic.maintainability_index
            lloc = mic.lloc
            loc = mic.loc
            sloc = mic.sloc

            history[revision[1].isoformat()] = {
                'sha': revision[0],
                'mi': mi,
                'lloc': lloc,
                'loc': loc,
                'sloc': sloc,
            }
            bar.next()
    except Exception as e:
        error = e
    finally:
        bar.finish()
        repo.git.checkout(current_branch)
        repo.close()

        radon_settings = getattr(settings, 'RADON_MI_SETTINGS', {})
        history_file_path = radon_settings.get('history_file_path', 'mi_history.json')
        with open(history_file_path, 'w') as f:
            f.write(json.dumps(history))
        if error:
            raise error

More like this

Comments

Please login first before commenting.