forked from getredash/redash
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Decouple extensions from Flask app. (getredash#3569)
* Decouple extensions from Flask app. This separates the extension registry from the Flask app and also introduces a separate registry for preriodic tasks. Fix getredash#3466. * Address review feedback. * Update redash/extensions.py Co-Authored-By: jezdez <jannis@leidel.info> * Minor comment in requirements. * Refactoring after getting feedback. * Uncoupled bin/bundle-extensions from Flas app instance. * Load bundles in bundle script and don’t rely on Flask. * Upgraded to importlib-metadata 0.9. * Add missing requirement. * Fix TypeError. * Added requirements for bundle_extension script. * Install bundles requirement file correctly. * Decouple bundle loading code from Redash. * Install bundle requirements from requirements.txt. * Use circleci/node for build-docker-image step, too.
- Loading branch information
1 parent
97c2e15
commit cd5e2fe
Showing
7 changed files
with
244 additions
and
67 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,39 +1,118 @@ | ||
#!/usr/bin/env python | ||
|
||
# -*- coding: utf-8 -*- | ||
"""Copy bundle extension files to the client/app/extension directory""" | ||
import logging | ||
import os | ||
from subprocess import call | ||
from distutils.dir_util import copy_tree | ||
from pathlib2 import Path | ||
from shutil import copy | ||
from collections import OrderedDict as odict | ||
|
||
from importlib_metadata import entry_points | ||
from importlib_resources import contents, is_resource, path | ||
|
||
from pkg_resources import iter_entry_points, resource_filename, resource_isdir | ||
# Name of the subdirectory | ||
BUNDLE_DIRECTORY = "bundle" | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
# Make a directory for extensions and set it as an environment variable | ||
# to be picked up by webpack. | ||
EXTENSIONS_RELATIVE_PATH = os.path.join('client', 'app', 'extensions') | ||
EXTENSIONS_DIRECTORY = os.path.join( | ||
os.path.dirname(os.path.dirname(__file__)), | ||
EXTENSIONS_RELATIVE_PATH) | ||
|
||
if not os.path.exists(EXTENSIONS_DIRECTORY): | ||
os.makedirs(EXTENSIONS_DIRECTORY) | ||
os.environ["EXTENSIONS_DIRECTORY"] = EXTENSIONS_RELATIVE_PATH | ||
|
||
for entry_point in iter_entry_points('redash.extensions'): | ||
# This is where the frontend code for an extension lives | ||
# inside of its package. | ||
content_folder_relative = os.path.join( | ||
entry_point.name, 'bundle') | ||
(root_module, _) = os.path.splitext(entry_point.module_name) | ||
|
||
if not resource_isdir(root_module, content_folder_relative): | ||
continue | ||
extensions_relative_path = Path('client', 'app', 'extensions') | ||
extensions_directory = Path(__file__).parent.parent / extensions_relative_path | ||
|
||
if not extensions_directory.exists(): | ||
extensions_directory.mkdir() | ||
os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) | ||
|
||
|
||
def resource_isdir(module, resource): | ||
"""Whether a given resource is a directory in the given module | ||
https://importlib-resources.readthedocs.io/en/latest/migration.html#pkg-resources-resource-isdir | ||
""" | ||
try: | ||
return resource in contents(module) and not is_resource(module, resource) | ||
except (ImportError, TypeError): | ||
# module isn't a package, so can't have a subdirectory/-package | ||
return False | ||
|
||
|
||
def entry_point_module(entry_point): | ||
"""Returns the dotted module path for the given entry point""" | ||
return entry_point.pattern.match(entry_point.value).group("module") | ||
|
||
|
||
def load_bundles(): | ||
""""Load bundles as defined in Redash extensions. | ||
content_folder = resource_filename(root_module, content_folder_relative) | ||
The bundle entry point can be defined as a dotted path to a module | ||
or a callable, but it won't be called but just used as a means | ||
to find the files under its file system path. | ||
The name of the directory it looks for files in is "bundle". | ||
So a Python package with an extension bundle could look like this:: | ||
my_extensions/ | ||
├── __init__.py | ||
└── wide_footer | ||
├── __init__.py | ||
└── bundle | ||
├── extension.js | ||
└── styles.css | ||
and would then need to register the bundle with an entry point | ||
under the "redash.periodic_tasks" group, e.g. in your setup.py:: | ||
setup( | ||
# ... | ||
entry_points={ | ||
"redash.bundles": [ | ||
"wide_footer = my_extensions.wide_footer", | ||
] | ||
# ... | ||
}, | ||
# ... | ||
) | ||
""" | ||
bundles = odict() | ||
for entry_point in entry_points().get("redash.bundles", []): | ||
logger.info('Loading Redash bundle "%s".', entry_point.name) | ||
module = entry_point_module(entry_point) | ||
# Try to get a list of bundle files | ||
if not resource_isdir(module, BUNDLE_DIRECTORY): | ||
logger.error( | ||
'Redash bundle directory "%s" could not be found.', entry_point.name | ||
) | ||
continue | ||
with path(module, BUNDLE_DIRECTORY) as bundle_dir: | ||
bundles[entry_point.name] = list(bundle_dir.rglob("*")) | ||
|
||
return bundles | ||
|
||
|
||
bundles = load_bundles().items() | ||
if bundles: | ||
print('Number of extension bundles found: {}'.format(len(bundles))) | ||
else: | ||
print('No extension bundles found.') | ||
|
||
for bundle_name, paths in bundles: | ||
# Shortcut in case not paths were found for the bundle | ||
if not paths: | ||
print('No paths found for bundle "{}".'.format(bundle_name)) | ||
continue | ||
|
||
# This is where we place our extensions folder. | ||
destination = os.path.join( | ||
EXTENSIONS_DIRECTORY, | ||
entry_point.name) | ||
# The destination for the bundle files with the entry point name as the subdirectory | ||
destination = Path(extensions_directory, bundle_name) | ||
if not destination.exists(): | ||
destination.mkdir() | ||
|
||
copy_tree(content_folder, destination) | ||
# Copy the bundle directory from the module to its destination. | ||
print('Copying "{}" bundle to {}:'.format(bundle_name, destination.resolve())) | ||
for src_path in paths: | ||
dest_path = destination / src_path.name | ||
print(" - {} -> {}".format(src_path, dest_path)) | ||
copy(str(src_path), str(dest_path)) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,30 +1,103 @@ | ||
import os | ||
from pkg_resources import iter_entry_points, resource_isdir, resource_listdir | ||
# -*- coding: utf-8 -*- | ||
import logging | ||
from collections import OrderedDict as odict | ||
|
||
from importlib_metadata import entry_points | ||
|
||
def init_app(app): | ||
""" | ||
Load the Redash extensions for the given Redash Flask app. | ||
""" | ||
if not hasattr(app, 'redash_extensions'): | ||
app.redash_extensions = {} | ||
# The global Redash extension registry | ||
extensions = odict() | ||
|
||
# The periodic Celery tasks as provided by Redash extensions. | ||
# This is separate from the internal periodic Celery tasks in | ||
# celery_schedule since the extension task discovery phase is | ||
# after the configuration has already happened. | ||
periodic_tasks = odict() | ||
|
||
logger = logging.getLogger(__name__) | ||
|
||
|
||
def load_extensions(app): | ||
"""Load the Redash extensions for the given Redash Flask app. | ||
for entry_point in iter_entry_points('redash.extensions'): | ||
app.logger.info('Loading Redash extension %s.', entry_point.name) | ||
The extension entry point can return any type of value but | ||
must take a Flask application object. | ||
E.g.:: | ||
def extension(app): | ||
app.logger.info("Loading the Foobar extenions") | ||
Foobar(app) | ||
""" | ||
for entry_point in entry_points().get("redash.extensions", []): | ||
app.logger.info('Loading Redash extension "%s".', entry_point.name) | ||
try: | ||
extension = entry_point.load() | ||
app.redash_extensions[entry_point.name] = { | ||
"entry_function": extension(app), | ||
"resources_list": [] | ||
# Then try to load the entry point (import and getattr) | ||
obj = entry_point.load() | ||
except (ImportError, AttributeError): | ||
# or move on | ||
app.logger.error( | ||
'Redash extension "%s" could not be found.', entry_point.name | ||
) | ||
continue | ||
|
||
if not callable(obj): | ||
app.logger.error( | ||
'Redash extension "%s" is not a callable.', entry_point.name | ||
) | ||
continue | ||
|
||
# then simply call the loaded entry point. | ||
extensions[entry_point.name] = obj(app) | ||
|
||
|
||
def load_periodic_tasks(logger): | ||
"""Load the periodic tasks as defined in Redash extensions. | ||
The periodic task entry point needs to return a set of parameters | ||
that can be passed to Celery's add_periodic_task: | ||
https://docs.celeryproject.org/en/latest/userguide/periodic-tasks.html#entries | ||
E.g.:: | ||
def add_two_and_two(): | ||
return { | ||
'name': 'add 2 and 2 every 10 seconds' | ||
'sig': add.s(2, 2), | ||
'schedule': 10.0, # in seconds or a timedelta | ||
} | ||
except ImportError: | ||
app.logger.info('%s does not have a callable and will not be loaded.', entry_point.name) | ||
(root_module, _) = os.path.splitext(entry_point.module_name) | ||
content_folder_relative = os.path.join(entry_point.name, 'bundle') | ||
|
||
# If it's a frontend extension only, store a list of files in the bundle directory. | ||
if resource_isdir(root_module, content_folder_relative): | ||
app.redash_extensions[entry_point.name] = { | ||
"entry_function": None, | ||
"resources_list": resource_listdir(root_module, content_folder_relative) | ||
} | ||
and then registered with an entry point under the "redash.periodic_tasks" | ||
group, e.g. in your setup.py:: | ||
setup( | ||
# ... | ||
entry_points={ | ||
"redash.periodic_tasks": [ | ||
"add_two_and_two = calculus.addition:add_two_and_two", | ||
] | ||
# ... | ||
}, | ||
# ... | ||
) | ||
""" | ||
for entry_point in entry_points().get("redash.periodic_tasks", []): | ||
logger.info( | ||
'Loading periodic Redash tasks "%s" from "%s".', | ||
entry_point.name, | ||
entry_point.value, | ||
) | ||
try: | ||
periodic_tasks[entry_point.name] = entry_point.load() | ||
except (ImportError, AttributeError): | ||
# and move on if it couldn't load it | ||
logger.error( | ||
'Periodic Redash task "%s" could not be found at "%s".', | ||
entry_point.name, | ||
entry_point.value, | ||
) | ||
|
||
|
||
def init_app(app): | ||
load_extensions(app) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.