Passed
Pull Request — master (#109)
by macartur
01:04
created

NAppsManager   F

Complexity

Total Complexity 83

Size/Duplication

Total Lines 458
Duplicated Lines 0 %

Importance

Changes 23
Bugs 3 Features 0
Metric Value
c 23
b 3
f 0
dl 0
loc 458
rs 1.5789
wmc 83

34 Methods

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