Completed
Push — publish ( b6ade5...fee4e2 )
by Michael
08:48
created

BumpVersion.read_from_file()   B

Complexity

Conditions 4

Size

Total Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
c 1
b 0
f 0
dl 0
loc 23
rs 8.7972
1
import textwrap
2
from collections import OrderedDict
3
from os.path import exists, expanduser, expandvars, join, curdir
4
import io
5
import os
6
import sys
7
8
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...
9
from pathlib import Path
10
11
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...
12
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...
13
14
import changes
15
from changes.models import GitRepository
16
from .commands import info, note, debug
17
18
19
AUTH_TOKEN_ENVVAR = 'GITHUB_AUTH_TOKEN'
20
21
# via https://github.com/jakubroztocil/httpie/blob/6bdfc7a/httpie/config.py#L9
22
IS_WINDOWS = 'win32' in str(sys.platform).lower()
23
DEFAULT_CONFIG_FILE = str(os.environ.get(
24
    'CHANGES_CONFIG_FILE',
25
    expanduser('~/.changes') if not IS_WINDOWS else
26
    expandvars(r'%APPDATA%\\.changes')
27
))
28
29
PROJECT_CONFIG_FILE = '.changes.toml'
30
DEFAULT_RELEASES_DIRECTORY = 'docs/releases'
31
32
33
@attr.s
34
class Changes(object):
35
    auth_token = attr.ib()
36
37
    @classmethod
38
    def load(cls):
39
        tool_config_path = Path(str(os.environ.get(
40
            'CHANGES_CONFIG_FILE',
41
            expanduser('~/.changes') if not IS_WINDOWS else
42
            expandvars(r'%APPDATA%\\.changes')
43
        )))
44
45
        tool_settings = None
46
        if tool_config_path.exists():
47
            tool_settings = Changes(
48
                **(toml.load(tool_config_path.open())['changes'])
49
            )
50
51
        # envvar takes precedence over config file settings
52
        auth_token = os.environ.get(AUTH_TOKEN_ENVVAR)
53
        if auth_token:
54
            info('Found Github Auth Token in the environment')
55
            tool_settings = Changes(auth_token=auth_token)
56
        elif not (tool_settings and tool_settings.auth_token):
57
            while not auth_token:
58
                info('No auth token found, asking for it')
59
                # to interact with the Git*H*ub API
60
                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...
61
                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...
62
                            'token" page, to create a token for changes.')
63
                click.launch('https://github.com/settings/tokens/new')
64
                auth_token = click.prompt('Enter your changes token')
65
66
            if not tool_settings:
67
                tool_settings = Changes(auth_token=auth_token)
68
69
            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...
70
                toml.dumps({
71
                    'changes': attr.asdict(tool_settings)
72
                })
73
            )
74
75
        return tool_settings
76
77
78
@attr.s
79
class Project(object):
80
    releases_directory = attr.ib()
81
    repository = attr.ib(default=None)
82
    bumpversion = attr.ib(default=None)
83
    labels = attr.ib(default=attr.Factory(dict))
84
85
    @classmethod
86
    def load(cls):
87
        repository = GitRepository(
88
            auth_token=changes.settings.auth_token
89
        )
90
91
        project_settings = configure_changes(repository)
92
        project_settings.repository = repository
93
        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...
94
95
        return project_settings
96
97
98
@attr.s
99
class BumpVersion(object):
100
    DRAFT_OPTIONS = [
101
        '--dry-run', '--verbose',
102
        '--no-commit', '--no-tag',
103
        '--allow-dirty',
104
    ]
105
    STAGE_OPTIONS = [
106
        '--verbose', '--allow-dirty',
107
        '--no-commit', '--no-tag',
108
    ]
109
110
    current_version = attr.ib()
111
    version_files_to_replace = attr.ib(default=attr.Factory(list))
112
113
    @classmethod
114
    def load(cls, latest_version):
115
        return configure_bumpversion(latest_version)
116
117
    @classmethod
118
    def read_from_file(cls, config_path: Path):
119
        config = RawConfigParser('')
0 ignored issues
show
Comprehensibility Best Practice introduced by
Undefined variable 'RawConfigParser'
Loading history...
120
        config.readfp(config_path.open('rt', encoding='utf-8'))
121
122
        current_version = config.get("bumpversion", 'current_version')
123
124
        filenames = []
125
        for section_name in config.sections():
126
127
            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...
Comprehensibility Best Practice introduced by
Undefined variable 're'
Loading history...
128
129
            if not section_name_match:
130
                continue
131
132
            section_prefix, section_value = section_name_match.groups()
133
134
            if section_prefix == "file":
135
                filenames.append(section_value)
136
137
        return cls(
138
            current_version=current_version,
139
            version_files_to_replace=filenames,
140
        )
141
142
    def write_to_file(self, config_path: Path):
143
        bumpversion_cfg = textwrap.dedent(
144
            """\
145
            [bumpversion]
146
            current_version = {current_version}
147
148
            """
149
        ).format(**attr.asdict(self))
150
151
        bumpversion_files = '\n\n'.join([
152
            '[bumpversion:file:{}]'.format(file_name)
153
            for file_name in self.version_files_to_replace
154
        ])
155
156
        config_path.write_text(
157
            bumpversion_cfg + bumpversion_files
158
        )
159
160
161
def configure_changes(repository):
162
    changes_project_config_path = Path(PROJECT_CONFIG_FILE)
163
    project_settings = None
164
    if changes_project_config_path.exists():
165
        # releases_directory, labels
166
        project_settings = Project(
167
            **(toml.load(changes_project_config_path.open())['changes'])
168
        )
169
170
    if not project_settings:
171
        releases_directory = Path(click.prompt(
172
            'Enter the directory to store your releases notes',
173
            DEFAULT_RELEASES_DIRECTORY,
174
            type=click.Path(exists=True, dir_okay=True)
175
        ))
176
177
        # releases_directory = Path(changes.project_settings.releases_directory)
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (80/79).

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

Loading history...
178
        if not releases_directory.exists():
179
            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...
180
            releases_directory.mkdir(parents=True)
181
182
        # FIXME: GitHub(repository).labels()
183
        project_settings = Project(
184
            releases_directory=releases_directory,
185
            labels=configure_labels(repository.github_labels()),
186
        )
187
        # write config file
188
        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...
189
            toml.dumps({
190
                'changes': attr.asdict(project_settings)
191
            })
192
        )
193
194
    return project_settings
195
196
197
def configure_bumpversion(latest_version):
198
    # TODO: look in other supported bumpversion config locations
199
    bumpversion = None
200
    bumpversion_config_path = Path('.bumpversion.cfg')
201
    if not bumpversion_config_path.exists():
202
        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...
203
204
        version_file_path_answer = None
205
        input_terminator = '.'
206
        while not version_file_path_answer == input_terminator:
207
            version_file_path_answer = click.prompt(
208
                'Enter a path to a file that contains a version number '
209
                "(enter a path of '.' when you're done selecting files)",
210
                type=click.Path(
211
                    exists=True,
212
                    dir_okay=True,
213
                    file_okay=True,
214
                    readable=True
215
                )
216
            )
217
218
            if version_file_path_answer != input_terminator:
219
                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...
220
221
        bumpversion = BumpVersion(
222
            current_version=latest_version,
223
            version_files_to_replace=user_supplied_versioned_file_paths,
224
        )
225
        bumpversion.write_to_file(bumpversion_config_path)
226
227
    return bumpversion
228
229
230
def configure_labels(github_labels):
231
    # ask which github tags they want to track
232
    # TODO: streamlined support for github defaults: enhancement, bug
233
    # TODO: apply description transform in labels_prompt function
234
    changelog_worthy_labels = read_user_choices(
235
        'labels',
236
        [
237
            properties['name']
238
            for label, properties in github_labels.items()
239
        ]
240
    )
241
242
    described_labels = {}
243
    # auto-generate label descriptions
244
    for label_name in changelog_worthy_labels:
245
        label_properties = github_labels[label_name]
246
        # Auto-generate description as titlecase label name
247
        label_properties['description'] = label_name.title()
248
        described_labels[label_name] = label_properties
249
250
    return described_labels
251
252
253
def read_user_choices(var_name, options):
254
    """Prompt the user to choose from several options for the given variable.
255
256
    # cookiecutter/cookiecutter/prompt.py
257
    The first item will be returned if no input happens.
258
259
    :param str var_name: Variable as specified in the context
260
    :param list options: Sequence of options that are available to select from
261
    :return: Exactly one item of ``options`` that has been chosen by the user
262
    """
263
    raise NotImplementedError()
264
    #
265
266
    # Please see http://click.pocoo.org/4/api/#click.prompt
267
    if not isinstance(options, list):
268
        raise TypeError
269
270
    if not options:
271
        raise ValueError
272
273
    choice_map = OrderedDict(
274
        (u'{}'.format(i), value) for i, value in enumerate(options, 1)
275
    )
276
    choices = choice_map.keys()
277
    default = u'1'
278
279
    choice_lines = [u'{} - {}'.format(*c) for c in choice_map.items()]
280
    prompt = u'\n'.join((
281
        u'Select {}:'.format(var_name),
282
        u'\n'.join(choice_lines),
283
        u'Choose from {}'.format(u', '.join(choices))
284
    ))
285
286
    # TODO: multi-select
287
    user_choice = click.prompt(
288
        prompt, type=click.Choice(choices), default=default
289
    )
290
    return choice_map[user_choice]
291
292
DEFAULTS = {
293
    'changelog': 'CHANGELOG.md',
294
    'readme': 'README.md',
295
    'github_auth_token': None,
296
}
297
298
299
class Config:
300
    test_command = None
301
    pypi = None
302
    skip_changelog = None
303
    changelog_content = None
304
    repo = None
305
306
    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 16).

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...
307
                 new_version, current_version, repo_url, version_prefix):
308
        self.module_name = module_name
309
        # module_name => project_name => curdir
310
        self.dry_run = dry_run
311
        self.debug = debug
312
        self.no_input = no_input
313
        self.requirements = requirements
314
        self.new_version = (
315
            version_prefix + new_version
316
            if version_prefix
317
            else new_version
318
        )
319
        self.current_version = current_version
320
321
322
def project_config():
323
    """Deprecated"""
324
    project_name = curdir
325
326
    config_path = Path(join(project_name, PROJECT_CONFIG_FILE))
327
328
    if not exists(config_path):
329
        store_settings(DEFAULTS.copy())
330
        return DEFAULTS
331
332
    return toml.load(io.open(config_path)) or {}
333
334
335
def store_settings(settings):
336
    pass
337
338