Passed
Push — master ( 685271...e13ce9 )
by Konrad
10:55
created

check_config_dict_key()   A

Complexity

Conditions 2

Size

Total Lines 9
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 3
dl 0
loc 9
rs 10
c 0
b 0
f 0
cc 2
nop 2
1
#!/usr/bin/env python3
2
# -*- coding: future_fstrings -*-
3
4
"""
5
System module
6
"""
7
8
import sys
9
import json
10
import os
11
import getpass
12
import yaml
13
from db_sync_tool.utility import log, parser, mode, helper, output, validation
14
from db_sync_tool.remote import utility as remote_utility
15
16
#
17
# GLOBALS
18
#
19
20
config = {
21
    'verbose': False,
22
    'mute': False,
23
    'dry_run': False,
24
    'keep_dump': False,
25
    'dump_name': '',
26
    'import': '',
27
    'link_hosts': '',
28
    'default_origin_dump_dir': True,
29
    'default_target_dump_dir': True,
30
    'check_dump': True,
31
    'is_same_client': False,
32
    'config_file_path': None,
33
    'clear_database': False,
34
    'force_password': False,
35
    'use_rsync': False,
36
    'use_rsync_options': None,
37
    'use_sshpass': False,
38
    'ssh_agent': False,
39
    'ssh_password': {
40
        mode.Client.ORIGIN: None,
41
        mode.Client.TARGET: None
42
    },
43
    'link_target': None,
44
    'link_origin': None,
45
    'tables': ''
46
}
47
48
#
49
# DEFAULTS
50
#
51
52
default_local_sync_path = '/tmp/db_sync_tool/'
0 ignored issues
show
Coding Style Naming introduced by
Constant name "default_local_sync_path" doesn't conform to UPPER_CASE naming style ('([^\\W\\da-z][^\\Wa-z]*|__.*__)$' pattern)

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...
53
54
55
#
56
# FUNCTIONS
57
#
58
59
def check_target_configuration():
60
    """
61
    Checking target database configuration
62
    :return:
63
    """
64
    parser.get_database_configuration(mode.Client.TARGET)
65
66
67
def get_configuration(host_config, args = {}):
0 ignored issues
show
Coding Style introduced by
No space allowed around keyword argument assignment
Loading history...
Bug Best Practice introduced by
The default value {} might cause unintended side-effects.

Objects as default values are only created once in Python and not on each invocation of the function. If the default object is modified, this modification is carried over to the next invocation of the method.

# Bad:
# If array_param is modified inside the function, the next invocation will
# receive the modified object.
def some_function(array_param=[]):
    # ...

# Better: Create an array on each invocation
def some_function(array_param=None):
    array_param = array_param or []
    # ...
Loading history...
68
    """
69
    Checking configuration information by file or dictionary
70
    :param host_config: Dictionary
71
    :param args: Dictionary
72
    :return:
73
    """
74
    global config
0 ignored issues
show
Coding Style introduced by
Usage of the global statement should be avoided.

Usage of global can make code hard to read and test, its usage is generally not recommended unless you are dealing with legacy code.

Loading history...
Coding Style Naming introduced by
Constant name "config" doesn't conform to UPPER_CASE naming style ('([^\\W\\da-z][^\\Wa-z]*|__.*__)$' pattern)

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...
75
    config[mode.Client.TARGET] = {}
76
    config[mode.Client.ORIGIN] = {}
77
78
    if host_config:
79
        if type(host_config) is dict:
0 ignored issues
show
introduced by
Using type() instead of isinstance() for a typecheck.
Loading history...
80
            config.update(__m=host_config)
81
        else:
82
            config.update(__m=json.dumps(obj=host_config))
83
84
    _config_file_path = config['config_file_path']
85
    if not _config_file_path is None:
86
        if os.path.isfile(_config_file_path):
87
            with open(_config_file_path, 'r') as read_file:
88
                if _config_file_path.endswith('.json'):
89
                    config.update(json.load(read_file))
90
                elif _config_file_path.endswith('.yaml') or _config_file_path.endswith('.yml'):
91
                    config.update(yaml.safe_load(read_file))
92
                else:
93
                    sys.exit(
94
                        output.message(
95
                            output.Subject.ERROR,
96
                            f'Unsupported configuration file type [json,yml,yaml]: '
97
                            f'{config["config_file_path"]}',
98
                            False
99
                        )
100
                    )
101
                output.message(
102
                    output.Subject.LOCAL,
103
                    f'Loading host configuration '
104
                    f'{output.CliFormat.BLACK}{_config_file_path}{output.CliFormat.ENDC}',
105
                    True
106
                )
107
        else:
108
            sys.exit(
109
                output.message(
110
                    output.Subject.ERROR,
111
                    f'Local configuration not found: {config["config_file_path"]}',
112
                    False
113
                )
114
            )
115
116
    args_config = build_config(args)
0 ignored issues
show
Unused Code introduced by
The variable args_config seems to be unused.
Loading history...
117
118
    validation.check(config)
119
    check_options()
120
121
    if not config[mode.Client.TARGET] and not config[mode.Client.ORIGIN]:
122
        sys.exit(
123
            output.message(
124
                output.Subject.ERROR,
125
                f'Configuration is missing, use a separate file or provide host parameter',
0 ignored issues
show
introduced by
Using an f-string that does not have any interpolated variables
Loading history...
126
                False
127
            )
128
        )
129
    helper.run_script(script='before')
130
    log.get_logger().info('Starting db_sync_tool')
131
132
133
def build_config(args):
134
    """
135
    ADding the provided arguments
136
    :param args:
137
    :return:
138
    """
139
    if args is None or not args:
140
        return {}
141
142
    if not args.type is None:
143
        config['type'] = args.type
144
145
    if not args.tables is None:
146
        config['tables'] = args.tables
147
148
    if not args.origin is None:
149
        config['link_origin'] = args.origin
150
151
    if not args.target is None:
152
        config['link_target'] = args.target
153
154
    if not args.target_path is None:
155
        config[mode.Client.TARGET]['path'] = args.target_path
156
157
    if not args.target_name is None:
158
        config[mode.Client.TARGET]['name'] = args.target_name
159
160
    if not args.target_host is None:
161
        config[mode.Client.TARGET]['host'] = args.target_host
162
163
    if not args.target_user is None:
164
        config[mode.Client.TARGET]['user'] = args.target_user
165
166
    if not args.target_password is None:
167
        config[mode.Client.TARGET]['password'] = args.target_password
168
169
    if not args.target_key is None:
170
        config[mode.Client.TARGET]['ssh_key'] = args.target_key
171
172
    if not args.target_port is None:
173
        config[mode.Client.TARGET]['port'] = args.target_port
174
175
    if not args.target_dump_dir is None:
176
        config[mode.Client.TARGET]['dump_dir'] = args.target_dump_dir
177
178
    if not args.target_db_name is None:
179
        check_config_dict_key(mode.Client.TARGET, 'db')
180
        config[mode.Client.TARGET]['db']['name'] = args.target_db_name
181
182
    if not args.target_db_host is None:
183
        check_config_dict_key(mode.Client.TARGET, 'db')
184
        config[mode.Client.TARGET]['db']['host'] = args.target_db_host
185
186
    if not args.target_db_user is None:
187
        check_config_dict_key(mode.Client.TARGET, 'db')
188
        config[mode.Client.TARGET]['db']['user'] = args.target_db_user
189
190
    if not args.target_db_password is None:
191
        check_config_dict_key(mode.Client.TARGET, 'db')
192
        config[mode.Client.TARGET]['db']['password'] = args.target_db_password
193
194
    if not args.target_db_port is None:
195
        check_config_dict_key(mode.Client.TARGET, 'db')
196
        config[mode.Client.TARGET]['db']['port'] = args.target_db_port
197
198
    if not args.target_after_dump is None:
199
        config[mode.Client.TARGET]['after_dump'] = args.target_after_dump
200
201
    if not args.origin_path is None:
202
        config[mode.Client.ORIGIN]['path'] = args.origin_path
203
204
    if not args.origin_name is None:
205
        config[mode.Client.ORIGIN]['name'] = args.origin_name
206
207
    if not args.origin_host is None:
208
        config[mode.Client.ORIGIN]['host'] = args.origin_host
209
210
    if not args.origin_user is None:
211
        config[mode.Client.ORIGIN]['user'] = args.origin_user
212
213
    if not args.origin_password is None:
214
        config[mode.Client.ORIGIN]['password'] = args.origin_password
215
216
    if not args.origin_key is None:
217
        config[mode.Client.ORIGIN]['ssh_key'] = args.origin_key
218
219
    if not args.origin_port is None:
220
        config[mode.Client.ORIGIN]['port'] = args.origin_port
221
222
    if not args.origin_dump_dir is None:
223
        config[mode.Client.ORIGIN]['dump_dir'] = args.origin_dump_dir
224
225
    if not args.origin_db_name is None:
226
        check_config_dict_key(mode.Client.ORIGIN, 'db')
227
        config[mode.Client.ORIGIN]['db']['name'] = args.origin_db_name
228
229
    if not args.origin_db_host is None:
230
        check_config_dict_key(mode.Client.ORIGIN, 'db')
231
        config[mode.Client.ORIGIN]['db']['host'] = args.origin_db_host
232
233
    if not args.origin_db_user is None:
234
        check_config_dict_key(mode.Client.ORIGIN, 'db')
235
        config[mode.Client.ORIGIN]['db']['user'] = args.origin_db_user
236
237
    if not args.origin_db_password is None:
238
        check_config_dict_key(mode.Client.ORIGIN, 'db')
239
        config[mode.Client.ORIGIN]['db']['password'] = args.origin_db_password
240
241
    if not args.origin_db_port is None:
242
        check_config_dict_key(mode.Client.ORIGIN, 'db')
243
        config[mode.Client.ORIGIN]['db']['port'] = args.origin_db_port
244
245
    return config
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable config does not seem to be defined.
Loading history...
246
247
248
def check_options():
249
    """
250
    Checking configuration provided file
251
    :return:
252
    """
253
    global config
0 ignored issues
show
Coding Style Naming introduced by
Constant name "config" doesn't conform to UPPER_CASE naming style ('([^\\W\\da-z][^\\Wa-z]*|__.*__)$' pattern)

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...
Coding Style introduced by
Usage of the global statement should be avoided.

Usage of global can make code hard to read and test, its usage is generally not recommended unless you are dealing with legacy code.

Loading history...
254
    if 'dump_dir' in config[mode.Client.ORIGIN]:
255
        config['default_origin_dump_dir'] = False
256
257
    if 'dump_dir' in config[mode.Client.TARGET]:
258
        config['default_target_dump_dir'] = False
259
260
    if 'check_dump' in config:
261
        config['check_dump'] = config['check_dump']
262
263
    link_configuration_with_hosts()
264
    reverse_hosts()
265
    mode.check_sync_mode()
266
267
268
def check_authorizations():
269
    """
270
    Checking authorization for clients
271
    :return:
272
    """
273
    check_authorization(mode.Client.ORIGIN)
274
    check_authorization(mode.Client.TARGET)
275
276
277
def check_authorization(client):
278
    """
279
    Checking arguments and fill options array
280
    :param client: String
281
    :return:
282
    """
283
    # only need authorization if client is remote
284
    if mode.is_remote(client):
285
        # Workaround if no authorization is needed
286
        if (mode.get_sync_mode() == mode.SyncMode.DUMP_REMOTE and
0 ignored issues
show
best-practice introduced by
Too many boolean expressions in if statement (6/5)
Loading history...
287
            client == mode.Client.TARGET) or \
0 ignored issues
show
Coding Style introduced by
Wrong continued indentation before block (add 4 spaces).
Loading history...
288
                (mode.get_sync_mode() == mode.SyncMode.DUMP_LOCAL and
289
                 client == mode.Client.ORIGIN) or \
290
                (mode.get_sync_mode() == mode.SyncMode.IMPORT_REMOTE and
291
                 client == mode.Client.ORIGIN):
292
            return
293
294
        # ssh key authorization
295
        if config['force_password']:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable config does not seem to be defined.
Loading history...
296
            config[client]['password'] = get_password_by_user(client)
297
        elif 'ssh_key' in config[client]:
298
            _ssh_key = config[client]['ssh_key']
299
            if not os.path.isfile(_ssh_key):
300
                sys.exit(
301
                    output.message(
302
                        output.Subject.ERROR,
303
                        f'SSH {client} private key not found: {_ssh_key}',
304
                        False
305
                    )
306
                )
307
        elif 'password' in config[client]:
308
            config[client]['password'] = config[client]['password']
309
        elif remote_utility.check_keys_from_ssh_agent():
310
            config['ssh_agent'] = True
311
        else:
312
            # user input authorization
313
            config[client]['password'] = get_password_by_user(client)
314
315
        if mode.get_sync_mode() == mode.SyncMode.DUMP_REMOTE and \
316
                client == mode.Client.ORIGIN and 'password' in \
317
                config[mode.Client.ORIGIN]:
318
            config[mode.Client.TARGET]['password'] = config[mode.Client.ORIGIN]['password']
319
320
321
def get_password_by_user(client):
322
    """
323
    Getting password by user input
324
    :param client: String
325
    :return: String password
326
    """
327
    _password = getpass.getpass(
328
        output.message(
329
            output.Subject.INFO,
330
            'SSH password ' + helper.get_ssh_host_name(client, True) + ': ',
331
            False
332
        )
333
    )
334
335
    while _password.strip() == '':
336
        output.message(
337
            output.Subject.WARNING,
338
            'Password seems to be empty. Please enter a valid password.',
339
            True
340
        )
341
342
        _password = getpass.getpass(
343
            output.message(
344
                output.Subject.INFO,
345
                'SSH password ' + helper.get_ssh_host_name(client, True) + ': ',
346
                False
347
            )
348
        )
349
350
    return _password
351
352
353
def check_args_options(config_file=None,
0 ignored issues
show
best-practice introduced by
Too many arguments (14/5)
Loading history...
354
                       verbose=False,
355
                       yes=False,
356
                       mute=False,
357
                       dry_run=False,
358
                       import_file=None,
359
                       dump_name=None,
360
                       keep_dump=None,
361
                       host_file=None,
362
                       clear=False,
363
                       force_password=False,
364
                       use_rsync=False,
365
                       use_rsync_options=None,
366
                       reverse=False):
367
    """
368
    Checking arguments and fill options array
369
    :param config_file:
370
    :param verbose:
371
    :param yes:
372
    :param mute:
373
    :param dry_run:
374
    :param import_file:
375
    :param dump_name:
376
    :param keep_dump:
377
    :param host_file:
378
    :param clear:
379
    :param force_password:
380
    :param use_rsync:
381
    :param use_rsync_options:
382
    :param reverse:
383
    :return:
384
    """
385
    global config
0 ignored issues
show
Coding Style Naming introduced by
Constant name "config" doesn't conform to UPPER_CASE naming style ('([^\\W\\da-z][^\\Wa-z]*|__.*__)$' pattern)

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...
Coding Style introduced by
Usage of the global statement should be avoided.

Usage of global can make code hard to read and test, its usage is generally not recommended unless you are dealing with legacy code.

Loading history...
386
    global default_local_sync_path
0 ignored issues
show
Coding Style Naming introduced by
Constant name "default_local_sync_path" doesn't conform to UPPER_CASE naming style ('([^\\W\\da-z][^\\Wa-z]*|__.*__)$' pattern)

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...
Coding Style introduced by
Usage of the global statement should be avoided.

Usage of global can make code hard to read and test, its usage is generally not recommended unless you are dealing with legacy code.

Loading history...
387
388
    if not config_file is None:
389
        config['config_file_path'] = config_file
390
391
    if not verbose is None:
392
        config['verbose'] = verbose
393
394
    if not yes is None:
395
        config['yes'] = yes
396
397
    if not mute is None:
398
        config['mute'] = mute
399
400
    if not dry_run is None:
401
        config['dry_run'] = dry_run
402
403
        if dry_run:
404
            output.message(
405
                output.Subject.INFO,
406
                'Test mode: DRY RUN',
407
                True
408
            )
409
410
    if not import_file is None:
411
        config['import'] = import_file
412
413
    if not dump_name is None:
414
        config['dump_name'] = dump_name
415
416
    if not host_file is None:
417
        config['link_hosts'] = host_file
418
419
    if not clear is None:
420
        config['clear_database'] = clear
421
422
    if not force_password is None:
423
        config['force_password'] = force_password
424
425
    if not use_rsync is None:
426
        config['use_rsync'] = use_rsync
427
428
        if use_rsync is True:
429
            helper.check_rsync_version()
430
            helper.check_sshpass_version()
431
432
        if not use_rsync_options is None:
433
            config['use_rsync_options'] = use_rsync_options
434
435
    if not reverse is None:
436
        config['reverse'] = reverse
437
438
    if not keep_dump is None:
439
        default_local_sync_path = keep_dump
440
441
        # Adding trailing slash if necessary
442
        if default_local_sync_path[-1] != '/':
443
            default_local_sync_path += '/'
444
445
        config['keep_dump'] = True
446
        output.message(
447
            output.Subject.INFO,
448
            '"Keep dump" option chosen',
449
            True
450
        )
451
452
453
def reverse_hosts():
454
    """
455
    Checking authorization for clients
456
    :return:
457
    """
458
    if config['reverse']:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable config does not seem to be defined.
Loading history...
459
        _origin = config[mode.Client.ORIGIN]
460
        _target = config[mode.Client.TARGET]
461
462
        config[mode.Client.ORIGIN] = _target
463
        config[mode.Client.TARGET] = _origin
464
465
        output.message(
466
            output.Subject.INFO,
467
            'Reverse origin and target hosts',
468
            True
469
        )
470
471
472
def link_configuration_with_hosts():
473
    """
474
    Merging the hosts definition with the given configuration file
475
    @ToDo Simplify function
476
    :return:
477
    """
478
    if ('link' in config[mode.Client.ORIGIN] or 'link' in config[mode.Client.TARGET]) and config['link_hosts'] == '':
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable config does not seem to be defined.
Loading history...
Coding Style introduced by
This line is too long as per the coding-style (117/100).

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

Loading history...
479
        #
480
        # Try to read host file path from link entry
481
        #
482
        _host = str(config[mode.Client.ORIGIN]['link'].split('@')[0]) if 'link' in config[mode.Client.ORIGIN] else ''
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (117/100).

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

Loading history...
483
        _host = str(config[mode.Client.TARGET]['link'].split('@')[0]) if 'link' in config[mode.Client.TARGET] else _host
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (120/100).

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

Loading history...
484
485
        config['link_hosts'] = _host
486
487
        if config['link_hosts'] == '':
488
            # Try to find default hosts.json file in same directory
489
            sys.exit(
490
                output.message(
491
                    output.Subject.ERROR,
492
                    f'Missing hosts file for linking hosts with configuration. '
0 ignored issues
show
introduced by
Using an f-string that does not have any interpolated variables
Loading history...
493
                    f'Use the "-o" / "--hosts" argument to define the filepath for the hosts file, '
494
                    f'when using a link parameter within the configuration or define the the '
495
                    f'filepath direct in the link entry e.g. "host.yaml@entry1".',
496
                    False
497
                )
498
            )
499
500
    if config['link_hosts'] != '':
501
502
        # Adjust filepath from relative to absolute
503
        if config['link_hosts'][0] != '/':
504
            config['link_hosts'] = os.path.dirname(os.path.abspath(config['config_file_path'])) + '/' + config['link_hosts']
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (124/100).

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

Loading history...
505
506
        if os.path.isfile(config['link_hosts']):
507
            with open(config['link_hosts'], 'r') as read_file:
508
                if config['link_hosts'].endswith('.json'):
509
                    _hosts = json.load(read_file)
510
                elif config['link_hosts'].endswith('.yaml') or config['link_hosts'].endswith('.yml'):
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (101/100).

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

Loading history...
511
                    _hosts = yaml.safe_load(read_file)
512
513
                output.message(
514
                    output.Subject.INFO,
515
                    f'Linking configuration with hosts {output.CliFormat.BLACK}{config["link_hosts"]}{output.CliFormat.ENDC}',
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (126/100).

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

Loading history...
516
                    True
517
                )
518
                if not config['config_file_path'] is None:
519
                    if 'link' in config[mode.Client.ORIGIN]:
520
                        _host_name = str(config[mode.Client.ORIGIN]['link']).split('@')[1]
521
                        if _host_name in _hosts:
0 ignored issues
show
introduced by
The variable _hosts does not seem to be defined for all execution paths.
Loading history...
522
                            config[mode.Client.ORIGIN] = {**config[mode.Client.ORIGIN], **_hosts[_host_name]}
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (109/100).

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

Loading history...
523
524
                    if 'link' in config[mode.Client.TARGET]:
525
                        _host_name = str(config[mode.Client.TARGET]['link']).split('@')[1]
526
                        if _host_name in _hosts:
527
                            config[mode.Client.TARGET] = {**config[mode.Client.TARGET], **_hosts[_host_name]}
0 ignored issues
show
Coding Style introduced by
This line is too long as per the coding-style (109/100).

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

Loading history...
528
                else:
529
                    if 'link_target' in config and 'link_origin' in config:
530
                        if config['link_target'] in _hosts and config['link_origin'] in _hosts:
531
                            config[mode.Client.TARGET] = _hosts[config['link_target']]
532
                            config[mode.Client.ORIGIN] = _hosts[config['link_origin']]
533
                        else:
534
                            sys.exit(
535
                                output.message(
536
                                    output.Subject.ERROR,
537
                                    f'Misconfiguration of link hosts {config["link_origin"]}, '
538
                                    f'{config["link_target"]} in {config["link_hosts"]}',
539
                                    False
540
                                )
541
                            )
542
                    else:
543
                        sys.exit(
544
                            output.message(
545
                                output.Subject.ERROR,
546
                                f'Missing link hosts for {config["link_hosts"]}',
547
                                False
548
                            )
549
                        )
550
        else:
551
            sys.exit(
552
                output.message(
553
                    output.Subject.ERROR,
554
                    f'Local host file not found: {config["link_hosts"]}',
555
                    False
556
                )
557
            )
558
559
560
def check_config_dict_key(client, key):
561
    """
562
    Create config key if is not present
563
    :param client:
564
    :param key:
565
    :return:
566
    """
567
    if key not in config[client]:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable config does not seem to be defined.
Loading history...
568
        config[client][key] = {}
569
0 ignored issues
show
coding-style introduced by
Trailing newlines
Loading history...
570