Completed
Push — refactor-and-polish ( 062128...4da4f2 )
by Michael
18:22 queued 08:24
created

Project.load()   B

Complexity

Conditions 4

Size

Total Lines 37

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 4
c 0
b 0
f 0
dl 0
loc 37
rs 8.5806
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 inflection
0 ignored issues
show
Configuration introduced by
The import inflection 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.repository 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, repository):
89
        changes_project_config_path = Path(PROJECT_CONFIG_FILE)
90
        project_settings = None
91
92
        if changes_project_config_path.exists():
93
            # releases_directory, labels
94
            project_settings = Project(
95
                **(toml.load(changes_project_config_path.open())['changes'])
96
            )
97
98
        if not project_settings:
99
            releases_directory = Path(click.prompt(
100
                'Enter the directory to store your releases notes',
101
                DEFAULT_RELEASES_DIRECTORY,
102
                type=click.Path(exists=True, dir_okay=True)
103
            ))
104
105
            if not releases_directory.exists():
106
                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 (97/79).

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

Loading history...
107
                releases_directory.mkdir(parents=True)
108
109
            project_settings = Project(
110
                releases_directory=str(releases_directory),
111
                labels=configure_labels(repository.labels),
112
            )
113
            # write config file
114
            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...
115
                toml.dumps({
116
                    'changes': attr.asdict(project_settings)
117
                })
118
            )
119
120
        project_settings.repository = repository
121
        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...
122
123
        return project_settings
124
125
126
def configure_labels(github_labels):
127
    labels_keyed_by_name = {}
128
    for label in github_labels:
129
        labels_keyed_by_name[label['name']] = label
130
131
    # TODO: streamlined support for github defaults: enhancement, bug
132
    changelog_worthy_labels = choose_labels([
133
        properties['name']
134
        for _, properties in labels_keyed_by_name.items()
135
    ])
136
137
    # TODO: apply description transform in labels_prompt function
138
    described_labels = {}
139
    # auto-generate label descriptions
140
    for label_name in changelog_worthy_labels:
141
        label_properties = labels_keyed_by_name[label_name]
142
        # Auto-generate description as pluralised titlecase label name
143
        label_properties['description'] = inflection.pluralize(
144
            inflection.titleize(label_name)
145
        )
146
147
        described_labels[label_name] = label_properties
148
149
    return described_labels
150
151
152
def choose_labels(alternatives):
153
    """
154
    Prompt the user select several labels from the provided alternatives.
155
156
    At least one label must be selected.
157
158
    :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...
159
    :return: Several selected labels
160
    """
161
    if not alternatives:
162
        raise ValueError
163
164
    if not isinstance(alternatives, list):
165
        raise TypeError
166
167
    choice_map = OrderedDict(
168
      ('{}'.format(i), value) for i, value in enumerate(alternatives, 1)
169
    )
170
    # prepend a termination option
171
    input_terminator = '0'
172
    choice_map.update({input_terminator: '<done>'})
173
    choice_map.move_to_end('0', last=False)
174
175
    choice_indexes = choice_map.keys()
176
177
    choice_lines = ['{} - {}'.format(*c) for c in choice_map.items()]
178
    prompt = '\n'.join((
179
        'Select labels:',
180
        '\n'.join(choice_lines),
181
        'Choose from {}'.format(', '.join(choice_indexes))
182
    ))
183
184
    user_choices = set()
185
    user_choice = None
186
187
    while not user_choice == input_terminator:
188
        if user_choices:
189
            note('Selected labels: [{}]'.format(', '.join(user_choices)))
190
191
        user_choice = click.prompt(
192
            prompt,
193
            type=click.Choice(choice_indexes),
194
            default=input_terminator,
195
        )
196
        done = user_choice == input_terminator
197
        new_selection = user_choice not in user_choices
198
        nothing_selected = not user_choices
199
200
        if not done and new_selection:
201
            user_choices.add(choice_map[user_choice])
202
203
        if done and nothing_selected:
204
            error('Please select at least one label')
205
            user_choice = None
206
207
    return user_choices
208
209
210
@attr.s
211
class BumpVersion(object):
212
    DRAFT_OPTIONS = [
213
        '--dry-run', '--verbose',
214
        '--no-commit', '--no-tag',
215
        '--allow-dirty',
216
    ]
217
    STAGE_OPTIONS = [
218
        '--verbose', '--allow-dirty',
219
        '--no-commit', '--no-tag',
220
    ]
221
222
    current_version = attr.ib()
223
    version_files_to_replace = attr.ib(default=attr.Factory(list))
224
225
    @classmethod
226
    def load(cls, latest_version):
227
        return configure_bumpversion(latest_version)
228
229
    @classmethod
230
    def read_from_file(cls, config_path: Path):
231
        config = RawConfigParser('')
232
        config.readfp(config_path.open('rt', encoding='utf-8'))
233
234
        current_version = config.get("bumpversion", 'current_version')
235
236
        filenames = []
237
        for section_name in config.sections():
238
239
            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...
240
241
            if not section_name_match:
242
                continue
243
244
            section_prefix, section_value = section_name_match.groups()
245
246
            if section_prefix == "file":
247
                filenames.append(section_value)
248
249
        return cls(
250
            current_version=current_version,
251
            version_files_to_replace=filenames,
252
        )
253
254
    def write_to_file(self, config_path: Path):
255
        bumpversion_cfg = textwrap.dedent(
256
            """\
257
            [bumpversion]
258
            current_version = {current_version}
259
260
            """
261
        ).format(**attr.asdict(self))
262
263
        bumpversion_files = '\n\n'.join([
264
            '[bumpversion:file:{}]'.format(file_name)
265
            for file_name in self.version_files_to_replace
266
        ])
267
268
        config_path.write_text(
269
            bumpversion_cfg + bumpversion_files
270
        )
271
272
273
274
def configure_bumpversion(latest_version):
275
    # TODO: look in other supported bumpversion config locations
276
    bumpversion = None
277
    bumpversion_config_path = Path('.bumpversion.cfg')
278
    if not bumpversion_config_path.exists():
279
        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...
280
281
        version_file_path_answer = None
282
        input_terminator = '.'
283
        while not version_file_path_answer == input_terminator:
284
            version_file_path_answer = click.prompt(
285
                'Enter a path to a file that contains a version number '
286
                "(enter a path of '.' when you're done selecting files)",
287
                type=click.Path(
288
                    exists=True,
289
                    dir_okay=True,
290
                    file_okay=True,
291
                    readable=True
292
                )
293
            )
294
295
            if version_file_path_answer != input_terminator:
296
                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...
297
298
        bumpversion = BumpVersion(
299
            current_version=latest_version,
300
            version_files_to_replace=user_supplied_versioned_file_paths,
301
        )
302
        bumpversion.write_to_file(bumpversion_config_path)
303
304
    return bumpversion
305
306
307
308
309
310
DEFAULTS = {
311
    'changelog': 'CHANGELOG.md',
312
    'readme': 'README.md',
313
    'github_auth_token': None,
314
}
315
316
317
class Config:
318
    """Deprecated"""
319
    test_command = None
320
    pypi = None
321
    skip_changelog = None
322
    changelog_content = None
323
    repo = None
324
325
    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...
326
                 new_version, current_version, repo_url, version_prefix):
327
        self.module_name = module_name
328
        # module_name => project_name => curdir
329
        self.dry_run = dry_run
330
        self.debug = debug
331
        self.no_input = no_input
332
        self.requirements = requirements
333
        self.new_version = (
334
            version_prefix + new_version
335
            if version_prefix
336
            else new_version
337
        )
338
        self.current_version = current_version
339
340
341
def project_config():
342
    """Deprecated"""
343
    project_name = curdir
344
345
    config_path = Path(join(project_name, PROJECT_CONFIG_FILE))
346
347
    if not exists(config_path):
348
        store_settings(DEFAULTS.copy())
349
        return DEFAULTS
350
351
    return toml.load(io.open(config_path)) or {}
352
353
354
def store_settings(settings):
355
    pass
356
357