-
Notifications
You must be signed in to change notification settings - Fork 4.4k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Decouple extensions from Flask app. #3569
Merged
Merged
Changes from all commits
Commits
Show all changes
15 commits
Select commit
Hold shift + click to select a range
1e8eabe
Decouple extensions from Flask app.
jezdez 7ebcff1
Address review feedback.
jezdez d007377
Update redash/extensions.py
4c50d42
Minor comment in requirements.
jezdez 2f1df91
Refactoring after getting feedback.
jezdez b550d51
Uncoupled bin/bundle-extensions from Flas app instance.
jezdez 29eb0a9
Load bundles in bundle script and don’t rely on Flask.
jezdez 1772899
Upgraded to importlib-metadata 0.9.
jezdez e265037
Add missing requirement.
jezdez 8919682
Fix TypeError.
jezdez 62f5309
Added requirements for bundle_extension script.
jezdez de9f6bf
Install bundles requirement file correctly.
jezdez d58595a
Decouple bundle loading code from Redash.
jezdez eb9eb3b
Install bundle requirements from requirements.txt.
jezdez 80ee335
Use circleci/node for build-docker-image step, too.
jezdez File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is to make sure npm is available in this step and is consistent with the other steps where bundles are collected.