kytos.utils.napps   F
last analyzed

Complexity

Total Complexity 86

Size/Duplication

Total Lines 580
Duplicated Lines 0 %

Test Coverage

Coverage 95.45%

Importance

Changes 0
Metric Value
eloc 333
dl 0
loc 580
ccs 294
cts 308
cp 0.9545
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 77 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
        print('--------------------------------------------------------------')
322 1
        print('Welcome to the bootstrap process of your NApp.')
323 1
        print('--------------------------------------------------------------')
324 1
        print('In order to answer both the username and the NApp name,')
325 1
        print('You must follow these naming rules:')
326 1
        print(' - name starts with a letter')
327 1
        print(' - name contains only letters, numbers or underscores')
328 1
        print(' - at least three characters')
329 1
        print('--------------------------------------------------------------')
330 1
        print('')
331 1
        try:
332 1
            while not cls.valid_name(username):
333 1
                username = input('Please, insert your NApps Server username: ')
334
335 1
            while not cls.valid_name(napp_name):
336 1
                napp_name = input('Please, insert your NApp name: ')
337
338 1
            description = input('Please, insert a brief description for your '
339
                                'NApp [optional]: ')
340
        except KeyboardInterrupt:
341
            print("User cancelled NApp creation.")
342
            sys.exit(0)
343 1
        if not description:
344
            # pylint: disable=fixme
345 1
            description = '# TODO: <<<< Insert your NApp description here >>>>'
346
            # pylint: enable=fixme
347
348 1
        context = {'username': username, 'napp': napp_name,
349
                   'description': description}
350
351
        #: Creating the directory structure (username/napp_name)
352 1
        os.makedirs(username, exist_ok=True)
353
354
        #: Creating ``__init__.py`` files
355 1
        with open(os.path.join(username, '__init__.py'), 'w') as init_file:
356 1
            init_file.write(f'"""NApps for the user {username}.""""')
357
358 1
        os.makedirs(os.path.join(username, napp_name))
359
360
        #: Creating the other files based on the templates
361 1
        templates = os.listdir(templates_path)
362 1
        templates.remove('ui')
363 1
        templates.remove('openapi.yml.template')
364
365 1
        if meta_package:
366
            templates.remove('main.py.template')
367
            templates.remove('settings.py.template')
368
369 1
        for tmp in templates:
370 1
            fname = os.path.join(username, napp_name,
371
                                 tmp.rsplit('.template')[0])
372 1
            with open(fname, 'w') as file:
373 1
                content = cls.render_template(templates_path, tmp, context)
374 1
                file.write(content)
375
376 1
        if not meta_package:
377 1
            NAppsManager.create_ui_structure(username, napp_name,
378
                                             ui_templates_path, context)
379
380 1
        print('\nCongratulations! Your NApp has been bootstrapped!\nNow you'
381
              f' can go to the directory "{username}/{napp_name}" and begin'
382
              ' to code your NApp.')
383 1
        print('Have fun!')
384
385 1
    @classmethod
386
    def create_ui_structure(cls, username, napp_name, ui_templates_path,
387
                            context):
388
        """Create the ui directory structure."""
389 1
        for section in ['k-info-panel', 'k-toolbar', 'k-action-menu']:
390 1
            os.makedirs(os.path.join(username, napp_name, 'ui', section))
391
392 1
        templates = os.listdir(ui_templates_path)
393
394 1
        for tmp in templates:
395 1
            fname = os.path.join(username, napp_name, 'ui',
396
                                 tmp.rsplit('.template')[0])
397
398 1
            with open(fname, 'w') as file:
399 1
                content = cls.render_template(ui_templates_path, tmp,
400
                                              context)
401 1
                file.write(content)
402
403 1
    @staticmethod
404
    def _check_module(folder):
405
        """Create module folder with empty __init__.py if it doesn't exist.
406
407
        Args:
408
            folder (pathlib.pathlib.Path): Module path.
409
410
        """
411 1
        if not folder.exists():
412 1
            folder.mkdir(parents=True, exist_ok=True, mode=0o755)
413 1
            (folder / '__init__.py').touch()
414
415 1
    @staticmethod
416
    def build_napp_package(napp_name):
417
        """Build the .napp file to be sent to the napps server.
418
419
        Args:
420
            napp_identifier (str): Identifier formatted as
421
                <username>/<napp_name>
422
423
        Return:
424
            file_payload (binary): The binary representation of the napp
425
                package that will be POSTed to the napp server.
426
427
        """
428 1
        def get_matches(path):
429
            """Return all NApp files matching any .gitignore pattern."""
430 1
            ignored_files = [".git"]
431 1
            with open(".gitignore", 'r') as local_gitignore:
432 1
                ignored_files.extend(local_gitignore.readlines())
433
434 1
            user_gitignore_path = pathlib.Path("%s/.gitignore" %
435
                                               pathlib.Path.home())
436 1
            if user_gitignore_path.exists():
437
                with open(user_gitignore_path, 'r') as user_gitignore:
438
                    ignored_files.extend(user_gitignore.readlines())
439
440
            # Define Wildmatch pattern (default gitignore pattern)
441 1
            pattern = pathspec.patterns.GitWildMatchPattern
442 1
            spec = pathspec.PathSpec.from_lines(pattern, ignored_files)
443
            # Get tree containing all matching files
444 1
            match_tree = spec.match_tree(path)
445
            # Create list with all absolute paths of match tree
446 1
            return ["%s/%s" % (path, match) for match in match_tree]
447
448 1
        files = []
449 1
        path = os.getcwd()
450
451 1
        for dir_file in os.walk(path):
452 1
            dirname, _, arc = dir_file
453 1
            files.extend([os.path.join(dirname, f) for f in arc])
454
455
        # Allow the user to run `kytos napps upload` from outside the
456
        # napp directory.
457
        # Filter the files with the napp_name in their path
458
        # Example: home/user/napps/kytos/, napp_name = kronos
459
        # This filter will get all files from:
460
        # home/user/napps/kytos/kronos/*
461 1
        files = list(filter(lambda x: napp_name in x, files))
462
463 1
        matches = get_matches(path)
464
465 1
        for filename in files.copy():
466 1
            if filename in matches:
467 1
                files.remove(filename)
468
469
        # Create the '.napp' package
470 1
        napp_file = tarfile.open(napp_name + '.napp', 'x:xz')
471 1
        for local_f in files:
472
            # Add relative paths instead of absolute paths
473 1
            napp_file.add(pathlib.PurePosixPath(local_f).relative_to(path))
474 1
        napp_file.close()
475
476
        # Get the binary payload of the package
477 1
        file_payload = open(napp_name + '.napp', 'rb')
478
479
        # remove the created package from the filesystem
480 1
        os.remove(napp_name + '.napp')
481
482 1
        return file_payload
483
484 1
    @staticmethod
485
    def create_metadata(*args, **kwargs):  # pylint: disable=unused-argument
486
        """Generate the metadata to send the napp package."""
487 1
        json_filename = kwargs.get('json_filename', 'kytos.json')
488 1
        readme_filename = kwargs.get('readme_filename', 'README.rst')
489 1
        ignore_json = kwargs.get('ignore_json', False)
490 1
        metadata = {}
491
492 1
        if not ignore_json:
493 1
            try:
494 1
                with open(json_filename) as json_file:
495 1
                    metadata = json.load(json_file)
496
            except FileNotFoundError:
497
                print("ERROR: Could not access kytos.json file.")
498
                sys.exit(1)
499
500 1
        try:
501 1
            with open(readme_filename) as readme_file:
502 1
                metadata['readme'] = readme_file.read()
503
        except FileNotFoundError:
504
            metadata['readme'] = ''
505
506 1
        try:
507 1
            yaml = YAML(typ='safe')
508 1
            openapi_dict = yaml.load(pathlib.Path('openapi.yml').open())
509 1
            openapi = json.dumps(openapi_dict)
510
        except FileNotFoundError:
511
            openapi = ''
512 1
        metadata['OpenAPI_Spec'] = openapi
513
514 1
        return metadata
515
516 1
    def upload(self, *args, **kwargs):
517
        """Create package and upload it to NApps Server.
518
519
        Raises:
520
            FileNotFoundError: If kytos.json is not found.
521
522
        """
523 1
        self.prepare()
524 1
        metadata = self.create_metadata(*args, **kwargs)
525 1
        package = self.build_napp_package(metadata.get('name'))
526
527 1
        NAppsClient().upload_napp(metadata, package)
528
529 1
    def delete(self):
530
        """Delete a NApp.
531
532
        Raises:
533
            requests.HTTPError: When there's a server error.
534
535
        """
536 1
        client = NAppsClient(self._config)
537 1
        client.delete(self.user, self.napp)
538
539 1
    @classmethod
540
    def prepare(cls):
541
        """Prepare NApp to be uploaded by creating openAPI skeleton."""
542 1
        if cls._ask_openapi():
543 1
            napp_path = pathlib.Path()
544 1
            tpl_path = SKEL_PATH / 'napp-structure/username/napp'
545 1
            OpenAPI(napp_path, tpl_path).render_template()
546 1
            print('Please, update your openapi.yml file.')
547 1
            sys.exit()
548
549 1
    @staticmethod
550
    def _ask_openapi():
551
        """Return whether we should create a (new) skeleton."""
552 1
        if pathlib.Path('openapi.yml').exists():
553 1
            question = 'Override local openapi.yml with a new skeleton? (y/N) '
554 1
            default = False
555
        else:
556 1
            question = 'Do you have REST endpoints and wish to create an API' \
557
                ' skeleton in openapi.yml? (Y/n) '
558 1
            default = True
559
560 1
        while True:
561 1
            answer = input(question)
562 1
            if answer == '':
563 1
                return default
564 1
            if answer.lower() in ['y', 'yes']:
565 1
                return True
566 1
            if answer.lower() in ['n', 'no']:
567 1
                return False
568
569 1
    def reload(self, napps=None):
570
        """Reload a NApp or all NApps.
571
572
        Args:
573
            napps (list): NApp list to be reloaded.
574
        Raises:
575
            requests.HTTPError: When there's a server error.
576
577
        """
578 1
        client = NAppsClient(self._config)
579 1
        client.reload_napps(napps)
580
581
582
# pylint: enable=too-many-instance-attributes,too-many-public-methods
583