Passed
Pull Request — master (#310)
by
unknown
01:29
created

kytos.utils.napps   F

Complexity

Total Complexity 86

Size/Duplication

Total Lines 584
Duplicated Lines 0 %

Test Coverage

Coverage 95.48%

Importance

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