Test Failed
Pull Request — master (#310)
by
unknown
01:40
created

kytos.utils.napps   F

Complexity

Total Complexity 86

Size/Duplication

Total Lines 585
Duplicated Lines 0 %

Test Coverage

Coverage 95.48%

Importance

Changes 0
Metric Value
eloc 335
dl 0
loc 585
ccs 296
cts 310
cp 0.9548
rs 2
c 0
b 0
f 0
wmc 86

37 Methods

Rating   Name   Duplication   Size   Complexity  
A NAppsManager.get_enabled_local() 0 3 1
A NAppsManager._get_napps() 0 8 1
A NAppsManager.__init__() 0 15 1
A NAppsManager.get_version() 0 3 1
A NAppsManager.napp_id() 0 4 1
A NAppsManager.set_napp() 0 12 1
A NAppsManager._installed() 0 5 2
A NAppsManager._enabled() 0 5 2
A NAppsManager.dependencies() 0 13 1
A NAppsManager.get_disabled() 0 8 1
A NAppsManager.get_description() 0 3 1
A NAppsManager.__require_kytos_config() 0 16 3
A NAppsManager.installed_dir() 0 3 1
A NAppsManager.is_installed() 0 3 1
A NAppsManager.is_enabled() 0 3 1
A NAppsManager.get_installed_local() 0 3 1
A NAppsManager._get_napp_key() 0 22 3
A NAppsManager.enabled_dir() 0 3 1
A NAppsManager.render_template() 0 8 1
A NAppsManager.valid_name() 0 9 1
A NAppsManager.search() 0 20 1
A NAppsManager.disable() 0 12 3
A NAppsManager.get_installed() 0 15 3
A NAppsManager.get_enabled() 0 15 3
A NAppsManager.enable() 0 12 3
A NAppsManager.remote_uninstall() 0 12 3
A NAppsManager.remote_install() 0 6 1
A NAppsManager._check_module() 0 11 2
B NAppsManager.create_metadata() 0 31 7
A NAppsManager.delete() 0 9 1
A NAppsManager.reload() 0 11 1
A NAppsManager.upload() 0 12 1
C NAppsManager.create_napp() 0 82 10
C NAppsManager.build_napp_package() 0 68 9
B NAppsManager._ask_openapi() 0 19 6
A NAppsManager.create_ui_structure() 0 17 4
A NAppsManager.prepare() 0 9 2

How to fix   Complexity   

Complexity

Complex classes like kytos.utils.napps 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
"""Manage Network Application files."""
2 1
import json
3 1
import logging
4 1
import os
5 1
import pathlib
6 1
import re
7 1
import sys
8 1
import tarfile
9 1
import urllib
10 1
from http import HTTPStatus
11
12
# Disable pylint import checks that conflict with isort
13
# pylint: disable=ungrouped-imports,wrong-import-order
14 1
import pathspec
15 1
from jinja2 import Environment, FileSystemLoader
16 1
from ruamel.yaml import YAML
17
18 1
from kytos.utils.client import NAppsClient
19 1
from kytos.utils.config import KytosConfig
20 1
from kytos.utils.exceptions import KytosException
21 1
from kytos.utils.openapi import OpenAPI
22 1
from kytos.utils.settings import SKEL_PATH
23
24 1
LOG = logging.getLogger(__name__)
25
26
27
# pylint: disable=too-many-instance-attributes,too-many-public-methods
28 1
class NAppsManager:
29
    """Deal with NApps at filesystem level and ask Kytos to (un)load NApps."""
30
31 1
    _NAPP_ENABLE = "api/kytos/core/napps/{}/{}/enable"
32 1
    _NAPP_DISABLE = "api/kytos/core/napps/{}/{}/disable"
33 1
    _NAPP_INSTALL = "api/kytos/core/napps/{}/{}/install"
34 1
    _NAPP_UNINSTALL = "api/kytos/core/napps/{}/{}/uninstall"
35 1
    _NAPPS_INSTALLED = "api/kytos/core/napps_installed"
36 1
    _NAPPS_ENABLED = "api/kytos/core/napps_enabled"
37 1
    _NAPP_METADATA = "api/kytos/core/napps/{}/{}/metadata/{}"
38
39 1
    def __init__(self):
40
        """Instance a new NAppsManager.
41
42
        This method do not need parameters.
43
        """
44 1
        self._config = KytosConfig().config
45 1
        self._kytos_api = self._config.get('kytos', 'api')
46
47 1
        self.user = None
48 1
        self.napp = None
49 1
        self.version = None
50
51
        # Automatically get from kytosd API when needed
52 1
        self.__local_enabled = None
53 1
        self.__local_installed = None
54
55 1
    @property
56
    def _enabled(self):
57 1
        if self.__local_enabled is None:
58 1
            self.__require_kytos_config()
59 1
        return self.__local_enabled
60
61 1
    @property
62
    def _installed(self):
63 1
        if self.__local_installed is None:
64 1
            self.__require_kytos_config()
65 1
        return self.__local_installed
66
67 1
    def __require_kytos_config(self):
68
        """Set path locations from kytosd API.
69
70
        It should not be called directly, but from properties that require a
71
        running kytosd instance.
72
        """
73 1
        if self.__local_enabled is None:
74 1
            uri = self._kytos_api + 'api/kytos/core/config/'
75 1
            try:
76 1
                ops = json.loads(urllib.request.urlopen(uri).read())
77 1
            except urllib.error.URLError as err:
78 1
                msg = f'Error connecting to Kytos daemon: {uri} {err.reason}'
79 1
                print(msg)
80 1
                sys.exit(1)
81 1
            self.__local_enabled = pathlib.Path(ops.get('napps'))
82 1
            self.__local_installed = pathlib.Path(ops.get('installed_napps'))
83
84 1
    def set_napp(self, user, napp, version=None):
85
        """Set info about NApp.
86
87
        Args:
88
            user (str): NApps Server username.
89
            napp (str): NApp name.
90
            version (str): NApp version.
91
92
        """
93 1
        self.user = user
94 1
        self.napp = napp
95 1
        self.version = version or 'latest'
96
97 1
    @property
98
    def napp_id(self):
99
        """Return a Identifier of NApp."""
100 1
        return '/'.join((self.user, self.napp))
101
102 1
    @staticmethod
103
    def _get_napps(napps_dir):
104
        """List of (username, napp_name) found in ``napps_dir``.
105
106
        Ex: [('kytos', 'of_core'), ('kytos', 'of_lldp')]
107
        """
108 1
        jsons = napps_dir.glob('*/*/kytos.json')
109 1
        return sorted(j.parts[-3:-1] for j in jsons)
110
111 1
    def get_enabled_local(self):
112
        """Sorted list of (username, napp_name) of enabled napps."""
113 1
        return self._get_napps(self._enabled)
114
115 1
    def get_installed_local(self):
116
        """Sorted list of (username, napp_name) of installed napps."""
117 1
        return self._get_napps(self._installed)
118
119 1
    def get_enabled(self):
120
        """Sorted list of (username, napp_name) of enabled napps."""
121 1
        uri = self._kytos_api + self._NAPPS_ENABLED
122
123 1
        try:
124 1
            response = urllib.request.urlopen(uri)
125 1
            if response.getcode() != 200:
126 1
                msg = "Error calling Kytos to check enabled NApps."
127 1
                raise KytosException(msg)
128
129 1
            content = json.loads(response.read())
130 1
            return sorted((c[0], c[1]) for c in content['napps'])
131 1
        except urllib.error.URLError as exception:
132 1
            LOG.error("Error checking enabled NApps. Is Kytos running?")
133 1
            raise KytosException(exception)
134
135 1
    def get_installed(self):
136
        """Sorted list of (username, napp_name) of installed napps."""
137 1
        uri = self._kytos_api + self._NAPPS_INSTALLED
138
139 1
        try:
140 1
            response = urllib.request.urlopen(uri)
141 1
            if response.getcode() != 200:
142 1
                msg = "Error calling Kytos to check installed NApps."
143 1
                raise KytosException(msg)
144
145 1
            content = json.loads(response.read())
146 1
            return sorted((c[0], c[1]) for c in content['napps'])
147 1
        except urllib.error.URLError as exception:
148 1
            LOG.error("Error checking installed NApps. Is Kytos running?")
149 1
            raise KytosException(exception)
150
151 1
    def is_installed(self):
152
        """Whether a NApp is installed."""
153 1
        return (self.user, self.napp) in self.get_installed()
154
155 1
    def get_disabled(self):
156
        """Sorted list of (username, napp_name) of disabled napps.
157
158
        The difference of installed and enabled.
159
        """
160 1
        installed = set(self.get_installed())
161 1
        enabled = set(self.get_enabled())
162 1
        return sorted(installed - enabled)
163
164 1
    def dependencies(self, user=None, napp=None):
165
        """Get napp_dependencies from install NApp.
166
167
        Args:
168
            user(string)  A Username.
169
            napp(string): A NApp name.
170
        Returns:
171
            napps(list): List with tuples with Username and NApp name.
172
                         e.g. [('kytos'/'of_core'), ('kytos/of_l2ls')]
173
174
        """
175 1
        napps = self._get_napp_key('napp_dependencies', user, napp)
176 1
        return [tuple(napp.split('/')) for napp in napps]
177
178 1
    def get_description(self, user=None, napp=None):
179
        """Return the description from kytos.json."""
180 1
        return self._get_napp_key('description', user, napp)
181
182 1
    def get_version(self, user=None, napp=None):
183
        """Return the version from kytos.json."""
184 1
        return self._get_napp_key('version', user, napp) or 'latest'
185
186 1
    def _get_napp_key(self, key, user=None, napp=None):
187
        """Return a value from kytos.json.
188
189
        Args:
190
            user (string): A Username.
191
            napp (string): A NApp name
192
            key (string): Key used to get the value within kytos.json.
193
194
        Returns:
195
            meta (object): Value stored in kytos.json.
196
197
        """
198 1
        if user is None:
199 1
            user = self.user
200 1
        if napp is None:
201 1
            napp = self.napp
202
203 1
        uri = self._kytos_api + self._NAPP_METADATA
204 1
        uri = uri.format(user, napp, key)
205
206 1
        meta = json.loads(urllib.request.urlopen(uri).read())
207 1
        return meta[key]
208
209 1
    def disable(self):
210
        """Disable a NApp if it is enabled."""
211 1
        uri = self._kytos_api + self._NAPP_DISABLE
212 1
        uri = uri.format(self.user, self.napp)
213
214 1
        try:
215 1
            json.loads(urllib.request.urlopen(uri).read())
216 1
        except urllib.error.HTTPError as exception:
217 1
            if exception.code == HTTPStatus.BAD_REQUEST.value:
218 1
                LOG.error("NApp is not installed. Check the NApp list.")
219
            else:
220 1
                LOG.error("Error disabling the NApp")
221
222 1
    def enable(self):
223
        """Enable a NApp if not already enabled."""
224 1
        uri = self._kytos_api + self._NAPP_ENABLE
225 1
        uri = uri.format(self.user, self.napp)
226
227 1
        try:
228 1
            json.loads(urllib.request.urlopen(uri).read())
229 1
        except urllib.error.HTTPError as exception:
230 1
            if exception.code == HTTPStatus.BAD_REQUEST.value:
231 1
                LOG.error("NApp is not installed. Check the NApp list.")
232
            else:
233 1
                LOG.error("Error enabling the NApp")
234
235 1
    def enabled_dir(self):
236
        """Return the enabled dir from current napp."""
237 1
        return self._enabled / self.user / self.napp
238
239 1
    def installed_dir(self):
240
        """Return the installed dir from current napp."""
241 1
        return self._installed / self.user / self.napp
242
243 1
    def is_enabled(self):
244
        """Whether a NApp is enabled."""
245 1
        return (self.user, self.napp) in self.get_enabled()
246
247 1
    def remote_uninstall(self):
248
        """Delete code inside NApp directory, if existent."""
249 1
        uri = self._kytos_api + self._NAPP_UNINSTALL
250 1
        uri = uri.format(self.user, self.napp)
251
252 1
        try:
253 1
            json.loads(urllib.request.urlopen(uri).read())
254 1
        except urllib.error.HTTPError as exception:
255 1
            if exception.code == HTTPStatus.BAD_REQUEST.value:
256 1
                LOG.error("Check if the NApp is installed.")
257
            else:
258 1
                LOG.error("Error uninstalling the NApp")
259
260 1
    @staticmethod
261
    def valid_name(username):
262
        """Check the validity of the given 'name'.
263
264
        The following checks are done:
265
        - name starts with a letter
266
        - name contains only letters, numbers or underscores
267
        """
268 1
        return username and re.match(r'[a-zA-Z][a-zA-Z0-9_]{2,}$', username)
269
270 1
    @staticmethod
271
    def render_template(templates_path, template_filename, context):
272
        """Render Jinja2 template for a NApp structure."""
273 1
        template_env = Environment(
274
            autoescape=False, trim_blocks=False,
275
            loader=FileSystemLoader(str(templates_path)))
276 1
        return template_env.get_template(str(template_filename)) \
277
            .render(context)
278
279 1
    @staticmethod
280
    def search(pattern):
281
        """Search all server NApps matching pattern.
282
283
        Args:
284
            pattern (str): Python regular expression.
285
286
        """
287 1
        def match(napp):
288
            """Whether a NApp metadata matches the pattern."""
289
            # WARNING: This will change for future versions, when 'author' will
290
            # be removed.
291 1
            username = napp.get('username', napp.get('author'))
292
293 1
            strings = ['{}/{}'.format(username, napp.get('name')),
294
                       napp.get('description')] + napp.get('tags')
295 1
            return any(pattern.match(string) for string in strings)
296
297 1
        napps = NAppsClient().get_napps()
298 1
        return [napp for napp in napps if match(napp)]
299
300 1
    def remote_install(self):
301
        """Ask kytos server to install NApp."""
302 1
        uri = self._kytos_api + self._NAPP_INSTALL
303 1
        uri = uri.format(self.user, self.napp)
304
305 1
        json.loads(urllib.request.urlopen(uri).read())
306
307 1
    @classmethod
308
    # pylint: disable=too-many-statements
309 1
    def create_napp(cls, meta_package=False):
310
        """Bootstrap a basic NApp structure for you to develop your NApp.
311
312
        This will create, on the current folder, a clean structure of a NAPP,
313
        filling some contents on this structure.
314
        """
315 1
        templates_path = SKEL_PATH / 'napp-structure/username/napp'
316
317 1
        ui_templates_path = os.path.join(templates_path, 'ui')
318
319 1
        username = None
320 1
        napp_name = None
321 1
        keyint_error = "\nUser cancelled napps creation."
322
323 1
        print('--------------------------------------------------------------')
324 1
        print('Welcome to the bootstrap process of your NApp.')
325 1
        print('--------------------------------------------------------------')
326 1
        print('In order to answer both the username and the NApp name,')
327 1
        print('You must follow these naming rules:')
328 1
        print(' - name starts with a letter')
329 1
        print(' - name contains only letters, numbers or underscores')
330 1
        print(' - at least three characters')
331 1
        print('--------------------------------------------------------------')
332 1
        print('')
333
        
334 1
        try:
335 1
            while not cls.valid_name(username):
336 1
                username = input('Please, insert your NApps Server username: ')
337
338 1
            while not cls.valid_name(napp_name):               
339 1
                napp_name = input('Please, insert your NApp name: ')        
340
                
341 1
                description = input('Please, insert a brief description for your '
342
                                    'NApp [optional]: ')
343
        except KeyboardInterrupt:
344
                    print(keyint_error)
345
                    sys.exit(0)
346 1
        if not description:
0 ignored issues
show
introduced by
The variable description does not seem to be defined in case the while loop on line 338 is not entered. Are you sure this can never be the case?
Loading history...
347
            # pylint: disable=fixme
348 1
            description = '# TODO: <<<< Insert your NApp description here >>>>'
349
            # pylint: enable=fixme
350
351 1
        context = {'username': username, 'napp': napp_name,
352
                   'description': description}
353
354
        #: Creating the directory structure (username/napp_name)
355 1
        os.makedirs(username, exist_ok=True)
356
357
        #: Creating ``__init__.py`` files
358 1
        with open(os.path.join(username, '__init__.py'), 'w') as init_file:
359 1
            init_file.write(f'"""NApps for the user {username}.""""')
360
361 1
        os.makedirs(os.path.join(username, napp_name))
362
363
        #: Creating the other files based on the templates
364 1
        templates = os.listdir(templates_path)
365 1
        templates.remove('ui')
366 1
        templates.remove('openapi.yml.template')
367
368 1
        if meta_package:
369
            templates.remove('main.py.template')
370
            templates.remove('settings.py.template')
371
372 1
        for tmp in templates:
373 1
            fname = os.path.join(username, napp_name,
374
                                 tmp.rsplit('.template')[0])
375 1
            with open(fname, 'w') as file:
376 1
                content = cls.render_template(templates_path, tmp, context)
377 1
                file.write(content)
378
379 1
        if not meta_package:
380 1
            NAppsManager.create_ui_structure(username, napp_name,
381
                                             ui_templates_path, context)
382
383 1
        print()
384
385 1
        print(f'Congratulations! Your NApp has been bootstrapped!\nNow  '
386
              f'you can go to the directory "{username}/{napp_name}" and '
387
              ' begin to code your NApp.')
388 1
        print('Have fun!')
389
390 1
    @classmethod
391
    def create_ui_structure(cls, username, napp_name, ui_templates_path,
392
                            context):
393
        """Create the ui directory structure."""
394 1
        for section in ['k-info-panel', 'k-toolbar', 'k-action-menu']:
395 1
            os.makedirs(os.path.join(username, napp_name, 'ui', section))
396
397 1
        templates = os.listdir(ui_templates_path)
398
399 1
        for tmp in templates:
400 1
            fname = os.path.join(username, napp_name, 'ui',
401
                                 tmp.rsplit('.template')[0])
402
403 1
            with open(fname, 'w') as file:
404 1
                content = cls.render_template(ui_templates_path, tmp,
405
                                              context)
406 1
                file.write(content)
407
408 1
    @staticmethod
409
    def _check_module(folder):
410
        """Create module folder with empty __init__.py if it doesn't exist.
411
412
        Args:
413
            folder (pathlib.pathlib.Path): Module path.
414
415
        """
416 1
        if not folder.exists():
417 1
            folder.mkdir(parents=True, exist_ok=True, mode=0o755)
418 1
            (folder / '__init__.py').touch()
419
420 1
    @staticmethod
421
    def build_napp_package(napp_name):
422
        """Build the .napp file to be sent to the napps server.
423
424
        Args:
425
            napp_identifier (str): Identifier formatted as
426
                <username>/<napp_name>
427
428
        Return:
429
            file_payload (binary): The binary representation of the napp
430
                package that will be POSTed to the napp server.
431
432
        """
433 1
        def get_matches(path):
434
            """Return all NApp files matching any .gitignore pattern."""
435 1
            ignored_files = [".git"]
436 1
            with open(".gitignore", 'r') as local_gitignore:
437 1
                ignored_files.extend(local_gitignore.readlines())
438
439 1
            user_gitignore_path = pathlib.Path("%s/.gitignore" %
440
                                               pathlib.Path.home())
441 1
            if user_gitignore_path.exists():
442
                with open(user_gitignore_path, 'r') as user_gitignore:
443
                    ignored_files.extend(user_gitignore.readlines())
444
445
            # Define Wildmatch pattern (default gitignore pattern)
446 1
            pattern = pathspec.patterns.GitWildMatchPattern
447 1
            spec = pathspec.PathSpec.from_lines(pattern, ignored_files)
448
            # Get tree containing all matching files
449 1
            match_tree = spec.match_tree(path)
450
            # Create list with all absolute paths of match tree
451 1
            return ["%s/%s" % (path, match) for match in match_tree]
452
453 1
        files = []
454 1
        path = os.getcwd()
455
456 1
        for dir_file in os.walk(path):
457 1
            dirname, _, arc = dir_file
458 1
            files.extend([os.path.join(dirname, f) for f in arc])
459
460
        # Allow the user to run `kytos napps upload` from outside the
461
        # napp directory.
462
        # Filter the files with the napp_name in their path
463
        # Example: home/user/napps/kytos/, napp_name = kronos
464
        # This filter will get all files from:
465
        # home/user/napps/kytos/kronos/*
466 1
        files = list(filter(lambda x: napp_name in x, files))
467
468 1
        matches = get_matches(path)
469
470 1
        for filename in files.copy():
471 1
            if filename in matches:
472 1
                files.remove(filename)
473
474
        # Create the '.napp' package
475 1
        napp_file = tarfile.open(napp_name + '.napp', 'x:xz')
476 1
        for local_f in files:
477
            # Add relative paths instead of absolute paths
478 1
            napp_file.add(pathlib.PurePosixPath(local_f).relative_to(path))
479 1
        napp_file.close()
480
481
        # Get the binary payload of the package
482 1
        file_payload = open(napp_name + '.napp', 'rb')
483
484
        # remove the created package from the filesystem
485 1
        os.remove(napp_name + '.napp')
486
487 1
        return file_payload
488
489 1
    @staticmethod
490
    def create_metadata(*args, **kwargs):  # pylint: disable=unused-argument
491
        """Generate the metadata to send the napp package."""
492 1
        json_filename = kwargs.get('json_filename', 'kytos.json')
493 1
        readme_filename = kwargs.get('readme_filename', 'README.rst')
494 1
        ignore_json = kwargs.get('ignore_json', False)
495 1
        metadata = {}
496
497 1
        if not ignore_json:
498 1
            try:
499 1
                with open(json_filename) as json_file:
500 1
                    metadata = json.load(json_file)
501
            except FileNotFoundError:
502
                print("ERROR: Could not access kytos.json file.")
503
                sys.exit(1)
504
505 1
        try:
506 1
            with open(readme_filename) as readme_file:
507 1
                metadata['readme'] = readme_file.read()
508
        except FileNotFoundError:
509
            metadata['readme'] = ''
510
511 1
        try:
512 1
            yaml = YAML(typ='safe')
513 1
            openapi_dict = yaml.load(pathlib.Path('openapi.yml').open())
514 1
            openapi = json.dumps(openapi_dict)
515
        except FileNotFoundError:
516
            openapi = ''
517 1
        metadata['OpenAPI_Spec'] = openapi
518
519 1
        return metadata
520
521 1
    def upload(self, *args, **kwargs):
522
        """Create package and upload it to NApps Server.
523
524
        Raises:
525
            FileNotFoundError: If kytos.json is not found.
526
527
        """
528 1
        self.prepare()
529 1
        metadata = self.create_metadata(*args, **kwargs)
530 1
        package = self.build_napp_package(metadata.get('name'))
531
532 1
        NAppsClient().upload_napp(metadata, package)
533
534 1
    def delete(self):
535
        """Delete a NApp.
536
537
        Raises:
538
            requests.HTTPError: When there's a server error.
539
540
        """
541 1
        client = NAppsClient(self._config)
542 1
        client.delete(self.user, self.napp)
543
544 1
    @classmethod
545
    def prepare(cls):
546
        """Prepare NApp to be uploaded by creating openAPI skeleton."""
547 1
        if cls._ask_openapi():
548 1
            napp_path = pathlib.Path()
549 1
            tpl_path = SKEL_PATH / 'napp-structure/username/napp'
550 1
            OpenAPI(napp_path, tpl_path).render_template()
551 1
            print('Please, update your openapi.yml file.')
552 1
            sys.exit()
553
554 1
    @staticmethod
555
    def _ask_openapi():
556
        """Return whether we should create a (new) skeleton."""
557 1
        if pathlib.Path('openapi.yml').exists():
558 1
            question = 'Override local openapi.yml with a new skeleton? (y/N) '
559 1
            default = False
560
        else:
561 1
            question = 'Do you have REST endpoints and wish to create an API' \
562
                ' skeleton in openapi.yml? (Y/n) '
563 1
            default = True
564
565 1
        while True:
566 1
            answer = input(question)
567 1
            if answer == '':
568 1
                return default
569 1
            if answer.lower() in ['y', 'yes']:
570 1
                return True
571 1
            if answer.lower() in ['n', 'no']:
572 1
                return False
573
574 1
    def reload(self, napps=None):
575
        """Reload a NApp or all NApps.
576
577
        Args:
578
            napps (list): NApp list to be reloaded.
579
        Raises:
580
            requests.HTTPError: When there's a server error.
581
582
        """
583 1
        client = NAppsClient(self._config)
584 1
        client.reload_napps(napps)
585
586
587
# pylint: enable=too-many-instance-attributes,too-many-public-methods
588