Completed
Push — publish ( fee4e2...70e944 )
by Michael
06:05
created

choose_labels()   D

Complexity

Conditions 11

Size

Total Lines 56

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 11
c 0
b 0
f 0
dl 0
loc 56
rs 4.4262

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

Complexity

Complex classes like choose_labels() often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
import re
2
import textwrap
3
from collections import OrderedDict
4
from configparser import RawConfigParser
5
from os.path import exists, expanduser, expandvars, join, curdir
6
import io
7
import os
8
import sys
9
10
import click
0 ignored issues
show
Configuration introduced by
The import click could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
11
from pathlib import Path
12
13
import inflect
0 ignored issues
show
Configuration introduced by
The import inflect could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
14
import toml
0 ignored issues
show
Configuration introduced by
The import toml could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
15
import attr
0 ignored issues
show
Configuration introduced by
The import attr could not be resolved.

This can be caused by one of the following:

1. Missing Dependencies

This error could indicate a configuration issue of Pylint. Make sure that your libraries are available by adding the necessary commands.

# .scrutinizer.yml
before_commands:
    - sudo pip install abc # Python2
    - sudo pip3 install abc # Python3
Tip: We are currently not using virtualenv to run pylint, when installing your modules make sure to use the command for the correct version.

2. Missing __init__.py files

This error could also result from missing __init__.py files in your module folders. Make sure that you place one file in each sub-folder.

Loading history...
16
17
import changes
18
from changes.models import GitRepository
19
from .commands import info, note, debug, error
20
21
AUTH_TOKEN_ENVVAR = 'GITHUB_AUTH_TOKEN'
22
23
# via https://github.com/jakubroztocil/httpie/blob/6bdfc7a/httpie/config.py#L9
24
IS_WINDOWS = 'win32' in str(sys.platform).lower()
25
DEFAULT_CONFIG_FILE = str(os.environ.get(
26
    'CHANGES_CONFIG_FILE',
27
    expanduser('~/.changes') if not IS_WINDOWS else
28
    expandvars(r'%APPDATA%\\.changes')
29
))
30
31
PROJECT_CONFIG_FILE = '.changes.toml'
32
DEFAULT_RELEASES_DIRECTORY = 'docs/releases'
33
34
35
@attr.s
36
class Changes(object):
37
    auth_token = attr.ib()
38
39
    @classmethod
40
    def load(cls):
41
        tool_config_path = Path(str(os.environ.get(
42
            'CHANGES_CONFIG_FILE',
43
            expanduser('~/.changes') if not IS_WINDOWS else
44
            expandvars(r'%APPDATA%\\.changes')
45
        )))
46
47
        tool_settings = None
48
        if tool_config_path.exists():
49
            tool_settings = Changes(
50
                **(toml.load(tool_config_path.open())['changes'])
51
            )
52
53
        # envvar takes precedence over config file settings
54
        auth_token = os.environ.get(AUTH_TOKEN_ENVVAR)
55
        if auth_token:
56
            info('Found Github Auth Token in the environment')
57
            tool_settings = Changes(auth_token=auth_token)
58
        elif not (tool_settings and tool_settings.auth_token):
59
            while not auth_token:
60
                info('No auth token found, asking for it')
61
                # to interact with the Git*H*ub API
62
                note('You need a Github Auth Token for changes to create a release.')
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (85/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
63
                click.pause('Press [enter] to launch the GitHub "New personal access '
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (86/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
64
                            'token" page, to create a token for changes.')
65
                click.launch('https://github.com/settings/tokens/new')
66
                auth_token = click.prompt('Enter your changes token')
67
68
            if not tool_settings:
69
                tool_settings = Changes(auth_token=auth_token)
70
71
            tool_config_path.write_text(
0 ignored issues
show
Bug introduced by
The Instance of Path does not seem to have a member named write_text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
72
                toml.dumps({
73
                    'changes': attr.asdict(tool_settings)
74
                })
75
            )
76
77
        return tool_settings
78
79
80
@attr.s
81
class Project(object):
82
    releases_directory = attr.ib()
83
    repository = attr.ib(default=None)
84
    bumpversion = attr.ib(default=None)
85
    labels = attr.ib(default=attr.Factory(dict))
86
87
    @classmethod
88
    def load(cls):
89
        repository = GitRepository(
90
            auth_token=changes.settings.auth_token
91
        )
92
93
        project_settings = configure_changes(repository)
94
        project_settings.repository = repository
95
        project_settings.bumpversion = BumpVersion.load(repository.latest_version)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (82/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
96
97
        return project_settings
98
99
100
@attr.s
101
class BumpVersion(object):
102
    DRAFT_OPTIONS = [
103
        '--dry-run', '--verbose',
104
        '--no-commit', '--no-tag',
105
        '--allow-dirty',
106
    ]
107
    STAGE_OPTIONS = [
108
        '--verbose', '--allow-dirty',
109
        '--no-commit', '--no-tag',
110
    ]
111
112
    current_version = attr.ib()
113
    version_files_to_replace = attr.ib(default=attr.Factory(list))
114
115
    @classmethod
116
    def load(cls, latest_version):
117
        return configure_bumpversion(latest_version)
118
119
    @classmethod
120
    def read_from_file(cls, config_path: Path):
121
        config = RawConfigParser('')
122
        config.readfp(config_path.open('rt', encoding='utf-8'))
123
124
        current_version = config.get("bumpversion", 'current_version')
125
126
        filenames = []
127
        for section_name in config.sections():
128
129
            section_name_match = re.compile("^bumpversion:(file|part):(.+)").match(section_name)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (96/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
130
131
            if not section_name_match:
132
                continue
133
134
            section_prefix, section_value = section_name_match.groups()
135
136
            if section_prefix == "file":
137
                filenames.append(section_value)
138
139
        return cls(
140
            current_version=current_version,
141
            version_files_to_replace=filenames,
142
        )
143
144
    def write_to_file(self, config_path: Path):
145
        bumpversion_cfg = textwrap.dedent(
146
            """\
147
            [bumpversion]
148
            current_version = {current_version}
149
150
            """
151
        ).format(**attr.asdict(self))
152
153
        bumpversion_files = '\n\n'.join([
154
            '[bumpversion:file:{}]'.format(file_name)
155
            for file_name in self.version_files_to_replace
156
        ])
157
158
        config_path.write_text(
159
            bumpversion_cfg + bumpversion_files
160
        )
161
162
163
def configure_changes(repository):
164
    changes_project_config_path = Path(PROJECT_CONFIG_FILE)
165
    project_settings = None
166
    if changes_project_config_path.exists():
167
        # releases_directory, labels
168
        project_settings = Project(
169
            **(toml.load(changes_project_config_path.open())['changes'])
170
        )
171
172
    if not project_settings:
173
        releases_directory = Path(click.prompt(
174
            'Enter the directory to store your releases notes',
175
            DEFAULT_RELEASES_DIRECTORY,
176
            type=click.Path(exists=True, dir_okay=True)
177
        ))
178
179
        if not releases_directory.exists():
180
            debug('Releases directory {} not found, creating it.'.format(releases_directory))
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (93/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
181
            releases_directory.mkdir(parents=True)
182
183
        # FIXME: GitHub(repository).labels()
184
        project_settings = Project(
185
            releases_directory=str(releases_directory),
186
            labels=configure_labels(repository.github_labels()),
187
        )
188
        # write config file
189
        changes_project_config_path.write_text(
0 ignored issues
show
Bug introduced by
The Instance of Path does not seem to have a member named write_text.

This check looks for calls to members that are non-existent. These calls will fail.

The member could have been renamed or removed.

Loading history...
190
            toml.dumps({
191
                'changes': attr.asdict(project_settings)
192
            })
193
        )
194
195
    return project_settings
196
197
198
def configure_bumpversion(latest_version):
199
    # TODO: look in other supported bumpversion config locations
200
    bumpversion = None
201
    bumpversion_config_path = Path('.bumpversion.cfg')
202
    if not bumpversion_config_path.exists():
203
        user_supplied_versioned_file_paths = []
0 ignored issues
show
Coding Style Naming introduced by
The name user_supplied_versioned_file_paths does not conform to the variable naming conventions ([a-z_][a-z0-9_]{2,30}$).

This check looks for invalid names for a range of different identifiers.

You can set regular expressions to which the identifiers must conform if the defaults do not match your requirements.

If your project includes a Pylint configuration file, the settings contained in that file take precedence.

To find out more about Pylint, please refer to their site.

Loading history...
204
205
        version_file_path_answer = None
206
        input_terminator = '.'
207
        while not version_file_path_answer == input_terminator:
208
            version_file_path_answer = click.prompt(
209
                'Enter a path to a file that contains a version number '
210
                "(enter a path of '.' when you're done selecting files)",
211
                type=click.Path(
212
                    exists=True,
213
                    dir_okay=True,
214
                    file_okay=True,
215
                    readable=True
216
                )
217
            )
218
219
            if version_file_path_answer != input_terminator:
220
                user_supplied_versioned_file_paths.append(version_file_path_answer)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (83/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
221
222
        bumpversion = BumpVersion(
223
            current_version=latest_version,
224
            version_files_to_replace=user_supplied_versioned_file_paths,
225
        )
226
        bumpversion.write_to_file(bumpversion_config_path)
227
228
    return bumpversion
229
230
231
def configure_labels(github_labels):
232
    labels_keyed_by_name = {}
233
    for label in github_labels:
234
        labels_keyed_by_name[label['name']] = label
235
236
    # TODO: streamlined support for github defaults: enhancement, bug
237
    changelog_worthy_labels = choose_labels([
238
        properties['name']
239
        for _, properties in labels_keyed_by_name.items()
240
    ])
241
242
    # TODO: apply description transform in labels_prompt function
243
    described_labels = {}
244
    # auto-generate label descriptions
245
    for label_name in changelog_worthy_labels:
246
        label_properties = labels_keyed_by_name[label_name]
247
        # Auto-generate description as pluralised titlecase label name
248
        label_properties['description'] = inflect.engine().plural(label_name.title())
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (85/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
249
250
        described_labels[label_name] = label_properties
251
252
    return described_labels
253
254
255
def choose_labels(alternatives):
256
    """
257
    Prompt the user select several labels from the provided alternatives.
258
259
    At least one label must be selected
260
261
    :param list alternatives: Sequence of options that are available to select from
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (83/79).

This check looks for lines that are too long. You can specify the maximum line length.

Loading history...
262
    :return: Several selected labels
263
    """
264
    if not alternatives:
265
        raise ValueError
266
267
    if not isinstance(alternatives, list):
268
        raise TypeError
269
270
    choice_map = OrderedDict(
271
      ('{}'.format(i), value) for i, value in enumerate(alternatives, 1)
272
    )
273
    # prepend a termination option
274
    input_terminator = '0'
275
    choice_map.update({input_terminator: '<done>'})
276
    choice_map.move_to_end('0', last=False)
277
278
    choice_indexes = choice_map.keys()
279
280
    choice_lines = ['{} - {}'.format(*c) for c in choice_map.items()]
281
    prompt = '\n'.join((
282
        'Select labels:',
283
        '\n'.join(choice_lines),
284
        'Choose from {}'.format(', '.join(choice_indexes))
285
    ))
286
287
    user_choices = set()
288
    user_choice = None
289
290
    while not user_choice == input_terminator:
291
        if user_choices:
292
            note('Selected labels: [{}]'.format(','.join(user_choices)))
293
294
        user_choice = click.prompt(
295
            prompt,
296
            type=click.Choice(choice_indexes),
297
            default=input_terminator,
298
        )
299
        done = user_choice == input_terminator
300
        new_selection = user_choice not in user_choices
301
        nothing_selected = not user_choices
302
303
        if not done and new_selection:
304
            user_choices.add(choice_map[user_choice])
305
306
        if done and nothing_selected:
307
            error('Please select at least one label')
308
            user_choice = None
309
310
    return user_choices
311
312
DEFAULTS = {
313
    'changelog': 'CHANGELOG.md',
314
    'readme': 'README.md',
315
    'github_auth_token': None,
316
}
317
318
319
class Config:
320
    test_command = None
321
    pypi = None
322
    skip_changelog = None
323
    changelog_content = None
324
    repo = None
325
326
    def __init__(self, module_name, dry_run, debug, no_input, requirements,
0 ignored issues
show
Comprehensibility Bug introduced by
debug is re-defining a name which is already available in the outer-scope (previously defined on line 19).

It is generally a bad practice to shadow variables from the outer-scope. In most cases, this is done unintentionally and might lead to unexpected behavior:

param = 5

class Foo:
    def __init__(self, param):   # "param" would be flagged here
        self.param = param
Loading history...
327
                 new_version, current_version, repo_url, version_prefix):
328
        self.module_name = module_name
329
        # module_name => project_name => curdir
330
        self.dry_run = dry_run
331
        self.debug = debug
332
        self.no_input = no_input
333
        self.requirements = requirements
334
        self.new_version = (
335
            version_prefix + new_version
336
            if version_prefix
337
            else new_version
338
        )
339
        self.current_version = current_version
340
341
342
def project_config():
343
    """Deprecated"""
344
    project_name = curdir
345
346
    config_path = Path(join(project_name, PROJECT_CONFIG_FILE))
347
348
    if not exists(config_path):
349
        store_settings(DEFAULTS.copy())
350
        return DEFAULTS
351
352
    return toml.load(io.open(config_path)) or {}
353
354
355
def store_settings(settings):
356
    pass
357
358