NAppsManager   F
last analyzed

Complexity

Total Complexity 97

Size/Duplication

Total Lines 532
Duplicated Lines 0 %

Importance

Changes 21
Bugs 2 Features 0
Metric Value
c 21
b 2
f 0
dl 0
loc 532
rs 1.5789
wmc 97

38 Methods

Rating   Name   Duplication   Size   Complexity  
A _enabled() 0 5 2
A __require_kytos_config() 0 15 3
A _installed() 0 5 2
B _get_napp_key() 0 23 5
A is_enabled() 0 3 1
A dependencies() 0 13 2
A get_version() 0 3 1
A installed_dir() 0 3 1
A enabled_dir() 0 3 1
C create_metadata() 0 31 7
A uninstall() 0 8 3
A get_enabled() 0 3 1
B _ask_openapi() 0 19 6
A get_installed() 0 3 1
A upload() 0 12 1
A _get_napps() 0 5 2
A prepare() 0 10 2
A delete() 0 9 1
D build_napp_package() 0 36 8
A render_template() 0 8 1
A valid_name() 0 9 1
C create_napp() 0 66 8
C _get_local_folder() 0 28 7
A get_disabled() 0 8 1
A get_description() 0 3 1
A _extract() 0 14 2
B search() 0 19 5
B __init__() 0 25 1
A disable() 0 9 3
A set_napp() 0 11 1
A _check_module() 0 10 2
B enable() 0 25 6
A install_local() 0 11 1
A install_remote() 0 16 4
A is_installed() 0 3 1
A napp_id() 0 4 1
A match() 0 9 2
A _download() 0 14 1

How to fix   Complexity   

Complex Class

Complex classes like NAppsManager 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
import json
3
import logging
4
import os
5
import re
6
import shutil
7
import sys
8
import tarfile
9
import urllib
10
from pathlib import Path
11
from random import randint
12
13
from jinja2 import Environment, FileSystemLoader
14
from ruamel.yaml import YAML
15
16
from kytos.utils.client import NAppsClient
17
from kytos.utils.config import KytosConfig
18
from kytos.utils.openapi import OpenAPI
19
20
LOG = logging.getLogger(__name__)
21
22
23
# pylint: disable=too-many-instance-attributes,too-many-public-methods
24
class NAppsManager:
25
    """Deal with NApps at filesystem level and ask Kytos to (un)load NApps."""
26
27
    def __init__(self, controller=None):
28
        """If controller is not informed, the necessary paths must be.
29
30
        If ``controller`` is available, NApps will be (un)loaded at runtime and
31
        you don't need to inform the paths. Otherwise, you should inform the
32
        required paths for the methods called.
33
34
        Args:
35
            controller (kytos.Controller): Controller to (un)load NApps.
36
            install_path (str): Folder where NApps should be installed. If
37
                None, use the controller's configuration.
38
            enabled_path (str): Folder where enabled NApps are stored. If None,
39
                use the controller's configuration.
40
        """
41
        self._controller = controller
42
        self._config = KytosConfig().config
43
        self._kytos_api = self._config.get('kytos', 'api')
44
45
        self.user = None
46
        self.napp = None
47
        self.version = None
48
49
        # Automatically get from kytosd API when needed
50
        self.__enabled = None
51
        self.__installed = None
52
53
    @property
54
    def _enabled(self):
55
        if self.__enabled is None:
56
            self.__require_kytos_config()
57
        return self.__enabled
58
59
    @property
60
    def _installed(self):
61
        if self.__installed is None:
62
            self.__require_kytos_config()
63
        return self.__installed
64
65
    def __require_kytos_config(self):
66
        """Set path locations from kytosd API.
67
68
        It should not be called directly, but from properties that require a
69
        running kytosd instance.
70
        """
71
        if self.__enabled is None:
72
            uri = self._kytos_api + 'api/kytos/core/config/'
73
            try:
74
                options = json.loads(urllib.request.urlopen(uri).read())
75
            except urllib.error.URLError:
76
                print('Kytos is not running.')
77
                sys.exit()
78
            self.__enabled = Path(options.get('napps'))
79
            self.__installed = Path(options.get('installed_napps'))
80
81
    def set_napp(self, user, napp, version=None):
82
        """Set info about NApp.
83
84
        Args:
85
            user (str): NApps Server username.
86
            napp (str): NApp name.
87
            version (str): NApp version.
88
        """
89
        self.user = user
90
        self.napp = napp
91
        self.version = version or 'latest'
92
93
    @property
94
    def napp_id(self):
95
        """Return a Identifier of NApp."""
96
        return '/'.join((self.user, self.napp))
97
98
    @staticmethod
99
    def _get_napps(napps_dir):
100
        """List of (username, napp_name) found in ``napps_dir``."""
101
        jsons = napps_dir.glob('*/*/kytos.json')
102
        return sorted(j.parts[-3:-1] for j in jsons)
103
104
    def get_enabled(self):
105
        """Sorted list of (username, napp_name) of enabled napps."""
106
        return self._get_napps(self._enabled)
107
108
    def get_installed(self):
109
        """Sorted list of (username, napp_name) of installed napps."""
110
        return self._get_napps(self._installed)
111
112
    def is_installed(self):
113
        """Whether a NApp is installed."""
114
        return (self.user, self.napp) in self.get_installed()
115
116
    def get_disabled(self):
117
        """Sorted list of (username, napp_name) of disabled napps.
118
119
        The difference of installed and enabled.
120
        """
121
        installed = set(self.get_installed())
122
        enabled = set(self.get_enabled())
123
        return sorted(installed - enabled)
124
125
    def dependencies(self, user=None, napp=None):
126
        """Get napp_dependencies from install NApp.
127
128
        Args:
129
            user(string)  A Username.
130
            napp(string): A NApp name.
131
        Returns:
132
            napps(list): List with tuples with Username and NApp name.
133
                         e.g. [('kytos'/'of_core'), ('kytos/of_l2ls')]
134
135
        """
136
        napps = self._get_napp_key('napp_dependencies', user, napp)
137
        return [tuple(napp.split('/')) for napp in napps]
138
139
    def get_description(self, user=None, napp=None):
140
        """Return the description from kytos.json."""
141
        return self._get_napp_key('description', user, napp)
142
143
    def get_version(self, user=None, napp=None):
144
        """Return the version from kytos.json."""
145
        return self._get_napp_key('version', user, napp) or 'latest'
146
147
    def _get_napp_key(self, key, user=None, napp=None):
148
        """Return a value from kytos.json.
149
150
        Args:
151
            user (string): A Username.
152
            napp (string): A NApp name
153
            key (string): Key used to get the value within kytos.json.
154
155
        Returns:
156
            meta (object): Value stored in kytos.json.
157
158
        """
159
        if user is None:
160
            user = self.user
161
        if napp is None:
162
            napp = self.napp
163
        kytos_json = self._installed / user / napp / 'kytos.json'
164
        try:
165
            with kytos_json.open() as file_descriptor:
166
                meta = json.load(file_descriptor)
167
                return meta[key]
168
        except (FileNotFoundError, json.JSONDecodeError, KeyError):
169
            return ''
170
171
    def disable(self):
172
        """Disable a NApp if it is enabled."""
173
        enabled = self.enabled_dir()
174
        try:
175
            enabled.unlink()
176
            if self._controller is not None:
177
                self._controller.unload_napp(self.user, self.napp)
178
        except FileNotFoundError:
179
            pass  # OK, it was already disabled
180
181
    def enabled_dir(self):
182
        """Return the enabled dir from current napp."""
183
        return self._enabled / self.user / self.napp
184
185
    def installed_dir(self):
186
        """Return the installed dir from current napp."""
187
        return self._installed / self.user / self.napp
188
189
    def enable(self):
190
        """Enable a NApp if not already enabled.
191
192
        Raises:
193
            FileNotFoundError: If NApp is not installed.
194
            PermissionError: No filesystem permission to enable NApp.
195
196
        """
197
        enabled = self.enabled_dir()
198
        installed = self.installed_dir()
199
200
        if not installed.is_dir():
201
            raise FileNotFoundError('Install NApp {} first.'.format(
202
                self.napp_id))
203
        elif not enabled.exists():
204
            self._check_module(enabled.parent)
205
            try:
206
                # Create symlink
207
                enabled.symlink_to(installed)
208
                if self._controller is not None:
209
                    self._controller.load_napp(self.user, self.napp)
210
            except FileExistsError:
211
                pass  # OK, NApp was already enabled
212
            except PermissionError:
213
                raise PermissionError('Permission error on enabling NApp. Try '
214
                                      'with sudo.')
215
216
    def is_enabled(self):
217
        """Whether a NApp is enabled."""
218
        return (self.user, self.napp) in self.get_enabled()
219
220
    def uninstall(self):
221
        """Delete code inside NApp directory, if existent."""
222
        if self.is_installed():
223
            installed = self.installed_dir()
224
            if installed.is_symlink():
225
                installed.unlink()
226
            else:
227
                shutil.rmtree(str(installed))
228
229
    @staticmethod
230
    def valid_name(username):
231
        """Check the validity of the given 'name'.
232
233
        The following checks are done:
234
        - name starts with a letter
235
        - name contains only letters, numbers or underscores
236
        """
237
        return username and re.match(r'[a-zA-Z][a-zA-Z0-9_]{2,}$', username)
238
239
    @staticmethod
240
    def render_template(templates_path, template_filename, context):
241
        """Render Jinja2 template for a NApp structure."""
242
        template_env = Environment(
243
            autoescape=False, trim_blocks=False,
244
            loader=FileSystemLoader(str(templates_path)))
245
        return template_env.get_template(str(template_filename)) \
246
            .render(context)
247
248
    @staticmethod
249
    def search(pattern):
250
        """Search all server NApps matching pattern.
251
252
        Args:
253
            pattern (str): Python regular expression.
254
        """
255
        def match(napp):
256
            """Whether a NApp metadata matches the pattern."""
257
            # WARNING: This will change for future versions, when 'author' will
258
            # be removed.
259
            username = napp.get('username', napp.get('author'))
260
261
            strings = ['{}/{}'.format(username, napp.get('name')),
262
                       napp.get('description')] + napp.get('tags')
263
            return any(pattern.match(string) for string in strings)
264
265
        napps = NAppsClient().get_napps()
266
        return [napp for napp in napps if match(napp)]
267
268
    def install_local(self):
269
        """Make a symlink in install folder to a local NApp.
270
271
        Raises:
272
            FileNotFoundError: If NApp is not found.
273
274
        """
275
        folder = self._get_local_folder()
276
        installed = self.installed_dir()
277
        self._check_module(installed.parent)
278
        installed.symlink_to(folder.resolve())
279
280
    def _get_local_folder(self, root=None):
281
        """Return local NApp root folder.
282
283
        Search for kytos.json in _./_ folder and _./user/napp_.
284
285
        Args:
286
            root (pathlib.Path): Where to begin searching.
287
288
        Return:
289
            pathlib.Path: NApp root folder.
290
291
        Raises:
292
            FileNotFoundError: If there is no such local NApp.
293
294
        """
295
        if root is None:
296
            root = Path()
297
        for folders in ['.'], [self.user, self.napp]:
298
            kytos_json = root / Path(*folders) / 'kytos.json'
299
            if kytos_json.exists():
300
                with kytos_json.open() as file_descriptor:
301
                    meta = json.load(file_descriptor)
302
                    # WARNING: This will change in future versions, when
303
                    # 'author' will be removed.
304
                    username = meta.get('username', meta.get('author'))
305
                    if username == self.user and meta.get('name') == self.napp:
306
                        return kytos_json.parent
307
        raise FileNotFoundError('kytos.json not found.')
308
309
    def install_remote(self):
310
        """Download, extract and install NApp."""
311
        package, pkg_folder = None, None
312
        try:
313
            package = self._download()
314
            pkg_folder = self._extract(package)
315
            napp_folder = self._get_local_folder(pkg_folder)
316
            dst = self._installed / self.user / self.napp
317
            self._check_module(dst.parent)
318
            shutil.move(str(napp_folder), str(dst))
319
        finally:
320
            # Delete temporary files
321
            if package:
322
                Path(package).unlink()
323
            if pkg_folder and pkg_folder.exists():
324
                shutil.rmtree(str(pkg_folder))
325
326
    def _download(self):
327
        """Download NApp package from server.
328
329
        Return:
330
            str: Downloaded temp filename.
331
332
        Raises:
333
            urllib.error.HTTPError: If download is not successful.
334
335
        """
336
        repo = self._config.get('napps', 'repo')
337
        napp_id = '{}/{}-{}.napp'.format(self.user, self.napp, self.version)
338
        uri = os.path.join(repo, napp_id)
339
        return urllib.request.urlretrieve(uri)[0]
340
341
    @staticmethod
342
    def _extract(filename):
343
        """Extract package to a temporary folder.
344
345
        Return:
346
            pathlib.Path: Temp dir with package contents.
347
348
        """
349
        random_string = '{:0d}'.format(randint(0, 10**6))
350
        tmp = '/tmp/kytos-napp-' + Path(filename).stem + '-' + random_string
351
        os.mkdir(tmp)
352
        with tarfile.open(filename, 'r:xz') as tar:
353
            tar.extractall(tmp)
354
        return Path(tmp)
355
356
    @classmethod
357
    def create_napp(cls):
358
        """Bootstrap a basic NApp strucutre for you to develop your NApp.
359
360
        This will create, on the current folder, a clean structure of a NAPP,
361
        filling some contents on this structure.
362
        """
363
        base = os.environ.get('VIRTUAL_ENV', '/')
364
365
        templates_path = os.path.join(base, 'etc', 'skel', 'kytos',
366
                                      'napp-structure', 'username', 'napp')
367
        username = None
368
        napp_name = None
369
        description = None
370
        print('--------------------------------------------------------------')
371
        print('Welcome to the bootstrap process of your NApp.')
372
        print('--------------------------------------------------------------')
373
        print('In order to answer both the username and the napp name,')
374
        print('You must follow this naming rules:')
375
        print(' - name starts with a letter')
376
        print(' - name contains only letters, numbers or underscores')
377
        print(' - at least three characters')
378
        print('--------------------------------------------------------------')
379
        print('')
380
        msg = 'Please, insert your NApps Server username: '
381
        while not cls.valid_name(username):
382
            username = input(msg)
383
384
        while not cls.valid_name(napp_name):
385
            napp_name = input('Please, insert your NApp name: ')
386
387
        msg = 'Please, insert a brief description for your NApp [optional]: '
388
        description = input(msg)
389
        if not description:
390
            # pylint: disable=fixme
391
            description = '# TODO: <<<< Insert here your NApp description >>>>'
392
            # pylint: enable=fixme
393
394
        context = {'username': username, 'napp': napp_name,
395
                   'description': description}
396
397
        #: Creating the directory structure (username/napp_name)
398
        os.makedirs(username, exist_ok=True)
399
        #: Creating ``__init__.py`` files
400
        with open(os.path.join(username, '__init__.py'), 'w'):
401
            pass
402
403
        os.makedirs(os.path.join(username, napp_name))
404
        with open(os.path.join(username, napp_name, '__init__.py'), 'w'):
405
            pass
406
407
        #: Creating the other files based on the templates
408
        templates = os.listdir(templates_path)
409
        templates.remove('__init__.py')
410
        templates.remove('openapi.yml.template')
411
        for tmp in templates:
412
            fname = os.path.join(username, napp_name,
413
                                 tmp.rsplit('.template')[0])
414
            with open(fname, 'w') as file:
415
                content = cls.render_template(templates_path, tmp, context)
416
                file.write(content)
417
418
        msg = '\nCongratulations! Your NApp have been bootstrapped!\nNow you '
419
        msg += 'can go to the directory {}/{} and begin to code your NApp.'
420
        print(msg.format(username, napp_name))
421
        print('Have fun!')
422
423
    @staticmethod
424
    def _check_module(folder):
425
        """Create module folder with empty __init__.py if it doesn't exist.
426
427
        Args:
428
            folder (pathlib.Path): Module path.
429
        """
430
        if not folder.exists():
431
            folder.mkdir(parents=True, exist_ok=True, mode=0o755)
432
            (folder / '__init__.py').touch()
433
434
    @staticmethod
435
    def build_napp_package(napp_name):
436
        """Build the .napp file to be sent to the napps server.
437
438
        Args:
439
            napp_identifier (str): Identifier formatted as
440
                <username>/<napp_name>
441
442
        Return:
443
            file_payload (binary): The binary representation of the napp
444
                package that will be POSTed to the napp server.
445
446
        """
447
        ignored_extensions = ['.swp', '.pyc', '.napp']
448
        ignored_dirs = ['__pycache__']
449
        files = os.listdir()
450
        for filename in files:
451
            if os.path.isfile(filename) and '.' in filename and \
452
                    filename.rsplit('.', 1)[1] in ignored_extensions:
453
                files.remove(filename)
454
            elif os.path.isdir(filename) and filename in ignored_dirs:
455
                files.remove(filename)
456
457
        # Create the '.napp' package
458
        napp_file = tarfile.open(napp_name + '.napp', 'x:xz')
459
        for local_f in files:
460
            napp_file.add(local_f)
461
        napp_file.close()
462
463
        # Get the binary payload of the package
464
        file_payload = open(napp_name + '.napp', 'rb')
465
466
        # remove the created package from the filesystem
467
        os.remove(napp_name + '.napp')
468
469
        return file_payload
470
471
    @staticmethod
472
    def create_metadata(*args, **kwargs):  # pylint: disable=unused-argument
473
        """Generate the metadata to send the napp package."""
474
        json_filename = kwargs.get('json_filename', 'kytos.json')
475
        readme_filename = kwargs.get('readme_filename', 'README.rst')
476
        ignore_json = kwargs.get('ignore_json', False)
477
        metadata = {}
478
479
        if not ignore_json:
480
            try:
481
                with open(json_filename) as json_file:
482
                    metadata = json.load(json_file)
483
            except FileNotFoundError:
484
                print("ERROR: Could not access kytos.json file.")
485
                sys.exit(1)
486
487
        try:
488
            with open(readme_filename) as readme_file:
489
                metadata['readme'] = readme_file.read()
490
        except FileNotFoundError:
491
            metadata['readme'] = ''
492
493
        try:
494
            yaml = YAML(typ='safe')
495
            openapi_dict = yaml.load(Path('openapi.yml').open())
496
            openapi = json.dumps(openapi_dict)
497
        except FileNotFoundError:
498
            openapi = ''
499
        metadata['OpenAPI_Spec'] = openapi
500
501
        return metadata
502
503
    def upload(self, *args, **kwargs):
504
        """Create package and upload it to NApps Server.
505
506
        Raises:
507
            FileNotFoundError: If kytos.json is not found.
508
509
        """
510
        self.prepare()
511
        metadata = self.create_metadata(*args, **kwargs)
512
        package = self.build_napp_package(metadata.get('name'))
513
514
        NAppsClient().upload_napp(metadata, package)
515
516
    def delete(self):
517
        """Delete a NApp.
518
519
        Raises:
520
            requests.HTTPError: When there's a server error.
521
522
        """
523
        client = NAppsClient(self._config)
524
        client.delete(self.user, self.napp)
525
526
    @classmethod
527
    def prepare(cls):
528
        """Prepare NApp to be uploaded by creating openAPI skeleton."""
529
        if cls._ask_openapi():
530
            napp_path = Path()
531
            prefix = Path(sys.prefix)
532
            tpl_path = prefix / 'etc/skel/kytos/napp-structure/username/napp'
533
            OpenAPI(napp_path, tpl_path).render_template()
534
            print('Please, update your openapi.yml file.')
535
            sys.exit()
536
537
    @staticmethod
538
    def _ask_openapi():
539
        """Return whether we should create a (new) skeleton."""
540
        if Path('openapi.yml').exists():
541
            question = 'Override local openapi.yml with a new skeleton? (y/N) '
542
            default = False
543
        else:
544
            question = 'Do you have REST endpoints and wish to create an API' \
545
                  ' skeleton in openapi.yml? (Y/n) '
546
            default = True
547
548
        while True:
549
            answer = input(question)
550
            if answer == '':
551
                return default
552
            if answer.lower() in ['y', 'yes']:
553
                return True
554
            if answer.lower() in ['n', 'no']:
555
                return False
556
557
# pylint: enable=too-many-instance-attributes,too-many-public-methods
558