ui.builder   F
last analyzed

Complexity

Total Complexity 86

Size/Duplication

Total Lines 655
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 372
dl 0
loc 655
rs 2
c 0
b 0
f 0
wmc 86

12 Functions

Rating   Name   Duplication   Size   Complexity  
D copy_directory_tree() 0 52 12
A log() 0 4 1
B write_main() 0 32 6
A get_sails_dependencies() 0 21 4
A install_dependencies() 0 18 2
F get_components() 0 122 15
F update_frontends() 0 123 17
A get_frontend_locations() 0 36 4
B generate_component_folders() 0 34 5
C install_frontend() 0 69 10
B copy_resource_tree() 0 42 7
A rebuild_frontend() 0 37 3

How to fix   Complexity   

Complexity

Complex classes like ui.builder 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
#!/usr/bin/env python
2
# -*- coding: UTF-8 -*-
3
4
# Isomer - The distributed application framework
5
# ==============================================
6
# Copyright (C) 2011-2020 Heiko 'riot' Weinen <[email protected]> and others.
7
#
8
# This program is free software: you can redistribute it and/or modify
9
# it under the terms of the GNU Affero General Public License as published by
10
# the Free Software Foundation, either version 3 of the License, or
11
# (at your option) any later version.
12
#
13
# This program is distributed in the hope that it will be useful,
14
# but WITHOUT ANY WARRANTY; without even the implied warranty of
15
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
16
# GNU Affero General Public License for more details.
17
#
18
# You should have received a copy of the GNU Affero General Public License
19
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
20
21
"""
22
Frontend building process.
23
24
Since this involves a lot of javascript handling, it is best advised to not
25
directly use any of the functionality except `install_frontend` and maybe
26
`rebuild_frontend`.
27
"""
28
29
import json
30
import os
31
import shutil
32
from glob import glob
33
from shutil import copy
34
35
import pkg_resources
36
from isomer.logger import isolog, debug, verbose, warn, error, critical
37
from isomer.misc.path import get_path
38
from isomer.tool import run_process
39
40
41
def log(*args, **kwargs):
42
    """Log as builder emitter"""
43
    kwargs.update({"emitter": "BUILDER", "frame_ref": 2})
44
    isolog(*args, **kwargs)
45
46
47
# TODO: Move the copy resource/directory tree operations to a utility lib
48
49
def copy_directory_tree(root_src_dir: str, root_dst_dir: str, hardlink: bool = False,
50
                        move: bool = False):
51
    """Copies/links/moves a whole directory tree
52
53
    :param root_src_dir: Source filesystem location
54
    :param root_dst_dir: Target filesystem location
55
    :param hardlink: Create hardlinks instead of copying (experimental)
56
    :param move: Move whole directory
57
    """
58
59
    for src_dir, dirs, files in os.walk(root_src_dir):
60
        dst_dir = src_dir.replace(root_src_dir, root_dst_dir, 1)
61
        if not os.path.exists(dst_dir):
62
            os.makedirs(dst_dir)
63
        for file_ in files:
64
            src_file = os.path.join(src_dir, file_)
65
            dst_file = os.path.join(dst_dir, file_)
66
            try:
67
                if os.path.exists(dst_file):
68
                    if hardlink:
69
                        log("Removing destination:", dst_file, lvl=verbose)
70
                        os.remove(dst_file)
71
                    else:
72
                        log("Overwriting destination:", dst_file, lvl=verbose)
73
                else:
74
                    log("Destination not existing:", dst_file, lvl=verbose)
75
            except PermissionError as e:
76
                log("No permission to remove destination:", e, lvl=error)
77
78
            try:
79
                if hardlink:
80
                    log("Hardlinking ", src_file, dst_dir, lvl=verbose)
81
                    os.link(src_file, dst_file)
82
                elif move:
83
                    log("Moving", src_file, dst_dir)
84
                    shutil.move(src_file, dst_dir)
85
                else:
86
                    log("Copying ", src_file, dst_dir, lvl=verbose)
87
                    copy(src_file, dst_dir)
88
            except PermissionError as e:
89
                log(
90
                    " No permission to create destination %s for directory:"
91
                    % ("link" if hardlink else "copy/move"),
92
                    dst_dir,
93
                    e,
94
                    lvl=error,
95
                )
96
            except Exception as e:
97
                log("Error during copy_directory_tree operation:",
98
                    type(e), e, lvl=error)
99
100
            log("Done with tree:", root_dst_dir, lvl=verbose)
101
102
103
def copy_resource_tree(package: str, source: str, target: str):
104
    """Copies a whole resource tree
105
106
    :param package: Package object with resources
107
    :param source: Source folder inside package resources
108
    :param target: Filesystem destination
109
    """
110
111
    pkg = pkg_resources.Requirement.parse(package)
112
113
    log(
114
        "Copying component frontend tree for %s to %s (%s)" % (package, target, source),
115
        lvl=verbose,
116
    )
117
118
    if not os.path.exists(target):
119
        os.mkdir(target)
120
121
    for item in pkg_resources.resource_listdir(pkg, source):
122
        log("Handling resource item:", item, lvl=verbose)
123
124
        if item in ("__pycache__", "__init__.py"):
125
            continue
126
127
        target_name = os.path.join(
128
            target, source.split("frontend")[1].lstrip("/"), item
129
        )
130
        log("Would copy to:", target_name, lvl=verbose)
131
132
        if pkg_resources.resource_isdir(pkg, source + "/" + item):
133
            log("Creating subdirectory:", target_name, lvl=verbose)
134
            try:
135
                os.mkdir(target_name)
136
            except FileExistsError:
137
                log("Subdirectory already exists, ignoring", lvl=verbose)
138
139
            log("Recursing resource subdirectory:", source + "/" + item, lvl=verbose)
140
            copy_resource_tree(package, source + "/" + item, target)
141
        else:
142
            log("Copying resource file:", source + "/" + item, lvl=verbose)
143
            with open(target_name, "wb") as f:
144
                f.write(pkg_resources.resource_string(pkg, source + "/" + item))
145
146
147
def get_frontend_locations(development):
148
    """Determine the frontend target and root locations.
149
    The root is where the complete source code for the frontend will be
150
    assembled, whereas the target is its installation directory after
151
    building
152
    :param development: If True, uses the development frontend server location
153
    :return:
154
    """
155
156
    log("Checking frontend location", lvl=debug)
157
158
    if development is True:
159
        log("Using development frontend location", lvl=warn)
160
        root = os.path.realpath(
161
            os.path.dirname(os.path.realpath(__file__)) + "/../../frontend"
162
        )
163
        target = get_path("lib", "frontend-dev")
164
        if not os.path.exists(target):
165
            log("Creating development frontend folder", lvl=debug)
166
            try:
167
                os.makedirs(target)
168
            except PermissionError:
169
                log(
170
                    "Cannot create development frontend target! "
171
                    "Check permissions on",
172
                    target,
173
                )
174
                return None, None
175
    else:
176
        log("Using production frontend location", lvl=debug)
177
        root = get_path("lib", "repository/frontend")
178
        target = get_path("lib", "frontend")
179
180
    log("Frontend components located in", root, lvl=debug)
181
182
    return root, target
183
184
185
def generate_component_folders(folder):
186
    """If not existing, create the components' holding folder inside the
187
    frontend source tree
188
189
    :param folder: Target folder in the frontend's source, where frontend
190
    modules will be copied to
191
    """
192
193
    if not os.path.isdir(folder):
194
        log("Creating new components folder")
195
        os.makedirs(folder)
196
        return True
197
    else:
198
        log("Clearing components folder")
199
        for thing in os.listdir(folder):
200
            target = os.path.join(folder, thing)
201
202
            try:
203
                log("Deleting target '%s'", lvl=debug)
204
                shutil.rmtree(target)
205
            except NotADirectoryError:
206
                log("Target '%s' is a symbolical link, unlinking", lvl=debug)
207
                os.unlink(target)
208
            except PermissionError:
209
                log(
210
                    "Cannot remove data in old components folder! "
211
                    "Check permissions in",
212
                    folder,
213
                    thing,
214
                    lvl=warn,
215
                )
216
                return False
217
218
    return True
219
220
221
def get_components(frontend_root):
222
    """Iterate over all installed isomer modules to find all the isomer
223
    components frontends and their dependencies
224
    :param frontend_root: Frontend source root directory
225
    :return:
226
    """
227
228
    def inspect_entry_point(component_entry_point):
229
        """Use pkg_tools to inspect an installed module for its metadata
230
231
        :param component_entry_point: A single entrypoint for an isomer module
232
        """
233
234
        name = component_entry_point.name
235
        package = component_entry_point.dist.project_name
236
        location = component_entry_point.dist.location
237
        loaded = component_entry_point.load()
238
239
        log("Package:", package, lvl=debug)
240
241
        log(
242
            "Entry point: ",
243
            component_entry_point,
244
            name,
245
            component_entry_point.resolve().__module__,
246
            lvl=debug,
247
        )
248
        component_name = component_entry_point.resolve().__module__.split(".")[1]
249
250
        log("Loaded: ", loaded, lvl=verbose)
251
        component = {
252
            "location": location,
253
            "version": str(component_entry_point.dist.parsed_version),
254
            "description": loaded.__doc__,
255
            "package": package,
256
        }
257
258
        try:
259
            pkg = pkg_resources.Requirement.parse(package)
260
            log("Checking component data resources", lvl=debug)
261
            try:
262
                resources = pkg_resources.resource_listdir(pkg, "frontend")
263
            except FileNotFoundError:
264
                log("Component does not have a frontend", lvl=debug)
265
                resources = []
266
267
            if len(resources) > 0:
268
                component["frontend"] = resources
269
                component["method"] = "resources"
270
        except ModuleNotFoundError:
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable ModuleNotFoundError does not seem to be defined.
Loading history...
271
            frontend = os.path.join(location, "frontend")
272
            log("Checking component data folders ", frontend, lvl=verbose)
273
            if os.path.isdir(frontend) and frontend != frontend_root:
274
                component["frontend"] = frontend
275
                component["method"] = "folder"
276
277
        if "frontend" not in component:
278
            log(
279
                "Component without frontend directory:",
280
                component,
281
                lvl=debug,
282
            )
283
            return None, None
284
        return component_name, component
285
286
    log("Updating frontend components")
287
288
    inspected_components = {}
289
    inspected_locations = []
290
    try:
291
        from pkg_resources import iter_entry_points
292
293
        entry_point_tuple = (
294
            iter_entry_points(group="isomer.sails", name=None),
295
            iter_entry_points(group="isomer.components", name=None),
296
        )
297
298
        for iterator in entry_point_tuple:
299
            for entry_point in iterator:
300
                try:
301
                    inspectable_package = entry_point.dist.project_name
302
303
                    if inspectable_package == "isomer":
304
                        log("Not inspecting base isomer package", lvl=debug)
305
                        continue
306
307
                    inspected_name, inspected_component = inspect_entry_point(
308
                        entry_point)
309
310
                    if inspected_name is not None and \
311
                            inspected_component is not None:
312
                        location = inspected_component['location']
313
                        if location not in inspected_locations:
314
                            inspected_locations.append(location)
315
                            inspected_components[inspected_name] = inspected_component
316
                except Exception as e:
317
                    log(
318
                        "Could not inspect entrypoint: ",
319
                        e,
320
                        type(e),
321
                        entry_point,
322
                        iterator,
323
                        lvl=error,
324
                        exc=True,
325
                    )
326
327
        # frontends = iter_entry_points(group='isomer.frontend', name=None)
328
        # for entrypoint in frontends:
329
        #     name = entrypoint.name
330
        #     location = entrypoint.dist.location
331
        #
332
        #     log('Frontend entrypoint:', name, location, entrypoint, lvl=hilight)
333
334
    except Exception as e:
335
        log("Error during frontend install: ", e, type(e), lvl=error, exc=True)
336
        return False
337
338
    component_list = list(inspected_components.keys())
339
    log("Components after lookup (%i):" % len(component_list),
340
        sorted(component_list))
341
342
    return inspected_components
343
344
345
def update_frontends(frontend_components: dict, frontend_root: str, install: bool):
346
    """Installs all found entrypoints and returns the list of all required
347
    dependencies
348
349
    :param frontend_root: Frontend source root directory
350
    :param install: If true, collect installable dependencies
351
    :param frontend_components: Dictionary with component names and metadata
352
    :return:
353
    """
354
355
    def get_component_dependencies(pkg_method: str, pkg_origin: str, pkg_name: str,
356
                                   pkg_object):
357
        """Inspect components resource or requirement strings to collect
358
        their dependencies
359
360
        :param pkg_method: Method how the dependencies are stored,
361
            either 'folder' or 'resources'. Folder expects a requirements.txt
362
            with javascript dependencies, whereas resources expects them
363
            inside the setup.py of the module
364
        :param pkg_origin: Folder with the module's frontend root
365
        :param pkg_name: Name of the entrypoint
366
        :param pkg_object: Entrypoint object
367
        """
368
369
        packages = []
370
371
        if pkg_method == "folder":
372
            requirements_file = os.path.join(pkg_origin, "requirements.txt")
373
374
            if os.path.exists(requirements_file):
375
                log(
376
                    "Adding package dependencies for",
377
                    pkg_name,
378
                    lvl=debug,
379
                )
380
                with open(requirements_file, "r") as f:
381
                    for requirements_line in f.readlines():
382
                        packages.append(requirements_line.replace("\n", ""))
383
        elif pkg_method == "resources":
384
            log("Getting resources:", pkg_object, lvl=debug)
385
            resource = pkg_resources.Requirement.parse(pkg_object)
386
            if pkg_resources.resource_exists(
387
                resource, "frontend/requirements.txt"
388
            ):
389
                resource_string = pkg_resources.resource_string(
390
                    resource, "frontend/requirements.txt"
391
                )
392
393
                # TODO: Not sure if decoding to ascii is a smart
394
                #  idea for npm package names.
395
                for resource_line in (
396
                    resource_string.decode("ascii").rstrip("\n").split("\n")
397
                ):
398
                    log("Resource string:", resource_line, lvl=debug)
399
                    packages.append(resource_line.replace("\n", ""))
400
401
        return packages
402
403
    def install_frontend_data(pkg_object, pkg_name: str):
404
        """Gather all frontend components' data files and while inspecting
405
        the components, collect their dependencies, as well, if frontend
406
        installation has been requested
407
408
        :param pkg_object: Setuptools entrypoint descriptor
409
        :param pkg_name: Name of the entrypoint
410
        """
411
412
        origin = pkg_object["frontend"]
413
        method = pkg_object["method"]
414
        package_object = pkg_object.get("package", None)
415
        target = os.path.join(frontend_root, "src", "components", pkg_name)
416
        target = os.path.normpath(target)
417
418
        if install:
419
            module_dependencies = get_component_dependencies(
420
                method, origin, pkg_name,
421
                package_object
422
            )
423
        else:
424
            module_dependencies = []
425
426
        log("Copying:", origin, target, lvl=debug)
427
        if method == "folder":
428
            copy_directory_tree(origin, target)
429
        elif method == "resources":
430
            copy_resource_tree(package_object, "frontend", target)
431
432
        for module_filename in glob(target + "/*.module.js"):
433
            module_name = os.path.basename(module_filename).split(".module.js")[
434
                0
435
            ]
436
            module_line = (
437
                u"import {s} from './components/{p}/{s}.module';\n"
438
                u"modules.push({s});\n".format(s=module_name, p=pkg_name)
439
            )
440
441
            yield module_dependencies, module_line, module_name
442
443
    log("Checking unique frontend locations: ", frontend_components, lvl=debug,
444
        pretty=True)
445
446
    importable_modules = []
447
    dependency_packages = []
448
    modules = []  # For checking if we already got it
449
450
    for package_name, package_component in frontend_components.items():
451
        if "frontend" in package_component:
452
            for dependencies, import_line, module in install_frontend_data(package_component, package_name):
453
                if module not in modules:
454
                    modules += module
455
                    if len(dependencies) > 0:
456
                        dependency_packages += dependencies
457
                    importable_modules.append(import_line)
458
459
        else:
460
            log("Module without frontend:", package_name, package_component,
461
                lvl=debug)
462
463
    log("Dependencies:", dependency_packages, "Component Imports:",
464
        importable_modules, pretty=True,
465
        lvl=debug)
466
467
    return dependency_packages, importable_modules
468
469
470
def get_sails_dependencies(root):
471
    """Get all core user interface (sails) dependencies
472
473
    :param root: Frontend source root directory
474
    """
475
476
    packages = []
477
478
    with open(os.path.join(root, 'package.json'), 'r') as f:
479
        package_json = json.load(f)
480
481
    log('Adding deployment packages', lvl=verbose)
482
    for package, version in package_json['dependencies'].items():
483
        packages.append("@".join([package, version]))
484
485
    log('Adding development packages', lvl=verbose)
486
    for package, version in package_json['devDependencies'].items():
487
        packages.append("@".join([package, version]))
488
489
    log('Found %i isomer base dependencies' % len(packages), lvl=debug)
490
    return packages
491
492
493
def install_dependencies(dependency_list: list, frontend_root: str):
494
    """Instruct npm to install a list of all dependencies
495
496
    :param frontend_root: Frontend source root directory
497
    :param dependency_list: List of javascript dependency packages
498
    """
499
500
    log("Installing dependencies:", dependency_list, lvl=debug)
501
    command_line = ["npm", "install", "--no-save"] + dependency_list
502
503
    log("Using npm in:", frontend_root, lvl=debug)
504
    success, installer = run_process(frontend_root, command_line)
505
506
    if success:
507
        log("Frontend installing done.", lvl=debug)
508
    else:
509
        log("Could not install dependencies:", installer)
510
        return False
511
512
513
def write_main(importable_modules: list, root: str):
514
    """With the gathered importable modules, populate the main frontend
515
    loader and write it to the frontend's root
516
517
    :param importable_modules: List of importable javascript module files
518
    :param root: Frontend source root directory
519
    """
520
521
    log("Writing main frontend loader", lvl=debug)
522
    with open(os.path.join(root, "src", "main.tpl.js"), "r") as f:
523
        main = "".join(f.readlines())
524
525
    parts = main.split("/* COMPONENT SECTION */")
526
    if len(parts) != 3:
527
        log("Frontend loader seems damaged! Please check!", lvl=critical)
528
        return False
529
530
    try:
531
        with open(os.path.join(root, "src", "main.js"), "w") as f:
532
            f.write(parts[0])
533
            f.write("/* COMPONENT SECTION:BEGIN */\n")
534
            for line in importable_modules:
535
                f.write(line)
536
            f.write("/* COMPONENT SECTION:END */\n")
537
            f.write(parts[2])
538
    except Exception as write_exception:
539
        log(
540
            "Error during frontend package info writing. Check " "permissions! ",
541
            write_exception,
542
            lvl=error,
543
        )
544
        return False
545
546
547
def rebuild_frontend(root: str, target: str, build_type: str):
548
    """Instruct npm to rebuild the frontend
549
550
    :param root: Frontend source root directory
551
    :param target: frontend build target installation directory
552
    :param build_type: Type of frontend build, either 'dist' or 'build'
553
    :return:
554
555
    """
556
557
    log("Starting frontend build.", lvl=warn)
558
559
    # TODO: Switch to i.t.run_process
560
    log("Using npm in:", root, lvl=debug)
561
    command = ["npm", "run", build_type]
562
    success, builder_output = run_process(root, command)
563
564
    if success is False:
565
        log("Error during frontend build:", builder_output, lvl=error)
566
        return False
567
568
    log("Frontend build done: ", builder_output, lvl=debug)
569
570
    try:
571
        copy_directory_tree(
572
            os.path.join(root, build_type), target, hardlink=False
573
        )
574
        copy_directory_tree(
575
            os.path.join(root, "assets"),
576
            os.path.join(target, "assets"),
577
            hardlink=False,
578
        )
579
    except PermissionError:
580
        log("No permission to change:", target, lvl=error)
581
        return False
582
583
    log("Frontend deployed")
584
585
586
def install_frontend(
587
    force_rebuild: bool = False,
588
    install: bool = True,
589
    development: bool = False,
590
    build_type: str = "dist",
591
):
592
    """Builds and installs the frontend.
593
594
    The process works like this:
595
596
    * Find the frontend locations (source root and target)
597
    * Generate the target component folders to copy modules' frontend sources to
598
    * Gather all component meta data
599
    * Collect all dependencies (when installing them is desired) and their module
600
      imports
601
    * If desired, install all dependencies
602
    * Write the frontend main loader with all module entrypoints
603
    * Run npm build `BUILD_TYPE` and copy all resulting files to the frontend target
604
      folder
605
606
    :param force_rebuild: Trigger a rebuild of the sources.
607
    :param install: Trigger installation of the frontend's dependencies
608
    :param development: Use development frontend server locations
609
    :param build_type: Type of frontend build, either 'dist' or 'build'
610
    """
611
612
    frontend_root, frontend_target = get_frontend_locations(development)
613
614
    if frontend_root is None or frontend_target is None:
615
        log("Cannot determine either frontend root or target",
616
            lvl=error)
617
        return False
618
619
    component_folder = os.path.join(frontend_root, "src", "components")
620
    if not generate_component_folders(component_folder):
621
        log("Cannot generate component folders",
622
            lvl=error)
623
        return False
624
625
    components = get_components(frontend_root)
626
    if components is False:
627
        log("Could not get components", lvl=error)
628
        return False
629
630
    installation_packages, imports = update_frontends(
631
        components, frontend_root, install
632
    )
633
634
    if install:
635
        installation_packages += get_sails_dependencies(frontend_root)
636
        installed = install_dependencies(installation_packages, frontend_root)
637
        if installed is False:
638
            log("Could not install dependencies", lvl=error)
639
            return False
640
641
    wrote_main = write_main(imports, frontend_root)
642
643
    if wrote_main is False:
644
        log("Could not write frontend loader", lvl=error)
645
        return False
646
647
    if force_rebuild:
648
        rebuilt = rebuild_frontend(frontend_root, frontend_target, build_type)
649
        if rebuilt is False:
650
            log("Frontend build failed", lvl=error)
651
            return False
652
653
    log("Done: Install Frontend")
654
    return True
655
656
    # TODO: We have to find a way to detect if we need to rebuild (and
657
    #  possibly wipe) stuff. This maybe the case, when a frontend
658
    #  module has been updated/added/removed.
659