From 1e8eabebaa01f66f03b0b1f16296df9df5575beb Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Mon, 11 Mar 2019 23:56:00 +0100 Subject: [PATCH 01/15] Decouple extensions from Flask app. This separates the extension registry from the Flask app and also introduces a separate registry for preriodic tasks. Fix #3466. --- bin/bundle-extensions | 40 ++++++++++++++----------- redash/extensions.py | 68 ++++++++++++++++++++++++++++--------------- redash/worker.py | 13 ++++----- requirements.txt | 2 ++ 4 files changed, 74 insertions(+), 49 deletions(-) diff --git a/bin/bundle-extensions b/bin/bundle-extensions index 8416aab776..9dc947c474 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -1,11 +1,10 @@ #!/usr/bin/env python import os -from subprocess import call from distutils.dir_util import copy_tree -from pkg_resources import iter_entry_points, resource_filename, resource_isdir - +import importlib_metadata +import importlib_resources # Make a directory for extensions and set it as an environment variable @@ -19,21 +18,28 @@ 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 +def is_resource_dir(module, resource): + try: + return ( + resource in importlib_resources.contents(module) + and not importlib_resources.is_resource(module, resource) + ) + except TypeError: + # module isn't a package, so can't have a subdirectory/-package + return False - content_folder = resource_filename(root_module, content_folder_relative) - # This is where we place our extensions folder. - destination = os.path.join( - EXTENSIONS_DIRECTORY, - entry_point.name) +for entry_point in importlib_metadata.entry_points().get('redash.extensions', []): + + # Check if there is a "bundle" subdirectory/-package in the + # entrypoint's module and ignoring the entrypoint if not. + module_name = entry_point.pattern.match(entry_point.value).group('module') + if not is_resource_dir(module_name, 'bundle'): + continue - copy_tree(content_folder, destination) + with importlib_resources.path(module_name, 'bundle') as bundle_dir: + # Copy content of extension bundle into extensions directory + destination = os.path.join(EXTENSIONS_DIRECTORY, entry_point.name) + print("Copying {} to {}".format(bundle_dir, destination)) + copy_tree(str(bundle_dir), destination) diff --git a/redash/extensions.py b/redash/extensions.py index dc3326bf2f..554ecbe069 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -1,30 +1,50 @@ -import os -from pkg_resources import iter_entry_points, resource_isdir, resource_listdir +from importlib_metadata import entry_points + +# The global Redash extension registry +extensions = {} + +# The periodic Celery task registry +periodic_tasks = {} def init_app(app): - """ - Load the Redash extensions for the given Redash Flask app. - """ - if not hasattr(app, 'redash_extensions'): - app.redash_extensions = {} + """Load the Redash extensions for the given Redash Flask app. - for entry_point in iter_entry_points('redash.extensions'): + The extension entry pooint 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": [] + init_extension = entry_point.load() + extensions[entry_point.name] = init_extension(app) + + +def init_periodic_tasks(app): + """Load the Redash extensions for the given Redash Flask app. + + 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 periodic_task(): + return { + 'name': 'add 2 and 2 every 10 seconds' + 'sig': add.s(2, 2), + 'schedule': 10.0, } - 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) - } + + """ + for entry_point in entry_points().get('redash.periodic_tasks', []): + app.logger.info('Loading Redash periodic tasks %s.', entry_point.name) + init_periodic_task = entry_point.load() + periodic_tasks[entry_point.name] = init_periodic_task() diff --git a/redash/worker.py b/redash/worker.py index b46db432e2..9fe432cc6c 100644 --- a/redash/worker.py +++ b/redash/worker.py @@ -9,7 +9,7 @@ from celery.schedules import crontab from celery.signals import worker_process_init -from redash import create_app, settings +from redash import create_app, extensions, settings from redash.metrics import celery as celery_metrics # noqa @@ -76,11 +76,8 @@ def init_celery_flask_app(**kwargs): app.app_context().push() -# Commented until https://github.com/getredash/redash/issues/3466 is implemented. # Hook for extensions to add periodic tasks. -# @celery.on_after_configure.connect -# def add_periodic_tasks(sender, **kwargs): -# app = safe_create_app() -# periodic_tasks = getattr(app, 'periodic_tasks', {}) -# for params in periodic_tasks.values(): -# sender.add_periodic_task(**params) +@celery.on_after_configure.connect +def add_periodic_tasks(sender, **kwargs): + for params in extensions.periodic_tasks.values(): + sender.add_periodic_task(**params) diff --git a/requirements.txt b/requirements.txt index 97067862ee..848d7da50a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,5 @@ disposable-email-domains # It is not included by default because of the GPL license conflict. # ldap3==2.2.4 gevent==1.4.0 +importlib-metadata==0.8 +importlib_resources==1.0.2 From 7ebcff1110e76a1bd59d256b8b23266f9c7e11b5 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 14 Mar 2019 09:31:42 +0100 Subject: [PATCH 02/15] Address review feedback. --- bin/bundle-extensions | 26 +++++---------- redash/extensions.py | 74 ++++++++++++++++++++++++++++--------------- redash/worker.py | 64 +++++++++++++++++++++++++++++++++---- 3 files changed, 114 insertions(+), 50 deletions(-) diff --git a/bin/bundle-extensions b/bin/bundle-extensions index 9dc947c474..a920f03558 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -1,11 +1,12 @@ #!/usr/bin/env python - import os from distutils.dir_util import copy_tree import importlib_metadata import importlib_resources +from redash.extensions import BUNDLE_DIRECTORY, resource_isdir, entry_point_module + # Make a directory for extensions and set it as an environment variable # to be picked up by webpack. @@ -19,27 +20,16 @@ if not os.path.exists(EXTENSIONS_DIRECTORY): os.environ["EXTENSIONS_DIRECTORY"] = EXTENSIONS_RELATIVE_PATH -def is_resource_dir(module, resource): - try: - return ( - resource in importlib_resources.contents(module) - and not importlib_resources.is_resource(module, resource) - ) - except TypeError: - # module isn't a package, so can't have a subdirectory/-package - return False - - for entry_point in importlib_metadata.entry_points().get('redash.extensions', []): - # Check if there is a "bundle" subdirectory/-package in the # entrypoint's module and ignoring the entrypoint if not. - module_name = entry_point.pattern.match(entry_point.value).group('module') - if not is_resource_dir(module_name, 'bundle'): + module = entry_point_module(entry_point) + if not resource_isdir(module, BUNDLE_DIRECTORY): continue - with importlib_resources.path(module_name, 'bundle') as bundle_dir: - # Copy content of extension bundle into extensions directory - destination = os.path.join(EXTENSIONS_DIRECTORY, entry_point.name) + # The destination for the bundle files with the entry point name as the subdirectory + destination = os.path.join(EXTENSIONS_DIRECTORY, entry_point.name) + # Copy the bundle directory from the module to its destination. + with importlib_resources.path(module, BUNDLE_DIRECTORY) as bundle_dir: print("Copying {} to {}".format(bundle_dir, destination)) copy_tree(str(bundle_dir), destination) diff --git a/redash/extensions.py b/redash/extensions.py index 554ecbe069..e800d10712 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -1,10 +1,37 @@ +from types import ModuleType +from importlib_resources import contents, is_resource, path from importlib_metadata import entry_points +BUNDLE_DIRECTORY = 'bundle' + # The global Redash extension registry extensions = {} -# The periodic Celery task registry -periodic_tasks = {} + +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 module_bundle_files(module): + if not resource_isdir(module, BUNDLE_DIRECTORY): + return + + with path(module, BUNDLE_DIRECTORY) as bundle_dir: + # Copy content of extension bundle into extensions directory + return list(bundle_dir.rglob("*")) def init_app(app): @@ -22,29 +49,24 @@ def extension(app): """ for entry_point in entry_points().get('redash.extensions', []): app.logger.info('Loading Redash extension %s.', entry_point.name) - init_extension = entry_point.load() - extensions[entry_point.name] = init_extension(app) + module = entry_point_module(entry_point) + # First of all, try to get a list of bundle files + extensions[entry_point.name]['resource_list'] = module_bundle_files(module) + try: + # Then try to load the entry point (import and getattr) + obj = entry_point.load() + except (ImportError, AttributeError): + # or move on + app.logger.error('Extension %s could not be found.', entry_point.name) + extensions[entry_point.name]['extension'] = None + continue -def init_periodic_tasks(app): - """Load the Redash extensions for the given Redash Flask app. - - 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 periodic_task(): - return { - 'name': 'add 2 and 2 every 10 seconds' - 'sig': add.s(2, 2), - 'schedule': 10.0, - } - - """ - for entry_point in entry_points().get('redash.periodic_tasks', []): - app.logger.info('Loading Redash periodic tasks %s.', entry_point.name) - init_periodic_task = entry_point.load() - periodic_tasks[entry_point.name] = init_periodic_task() + # Otherwise check if the loaded entry point is a module + if isinstance(obj, ModuleType): + app.logger.info('Extension %s is a module.', entry_point.name) + extensions[entry_point.name]['extension'] = obj + # or simply call the loaded entry point instead. + else: + app.logger.info('Extension %s is a callable.', entry_point.name) + extensions[entry_point.name]['extension'] = obj(app) diff --git a/redash/worker.py b/redash/worker.py index 9fe432cc6c..328875602c 100644 --- a/redash/worker.py +++ b/redash/worker.py @@ -1,5 +1,4 @@ from __future__ import absolute_import - from datetime import timedelta from random import randint @@ -8,15 +7,27 @@ from celery import Celery from celery.schedules import crontab from celery.signals import worker_process_init +from celery.utils.log import get_logger +from importlib_metadata import entry_points -from redash import create_app, extensions, settings +from redash import create_app, settings from redash.metrics import celery as celery_metrics # noqa +logger = get_logger(__name__) + +# 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. +extensions_schedule = {} + + celery = Celery('redash', broker=settings.CELERY_BROKER, include='redash.tasks') +# The internal periodic Celery tasks to automatically schedule. celery_schedule = { 'refresh_queries': { 'task': 'redash.tasks.refresh_queries', @@ -69,15 +80,56 @@ def __call__(self, *args, **kwargs): celery.Task = ContextTask -# Create Flask app after forking a new worker, to make sure no resources are shared between processes. @worker_process_init.connect def init_celery_flask_app(**kwargs): + """Create the Flask app after forking a new worker. + + This is to make sure no resources are shared between processes. + """ app = create_app() app.app_context().push() -# Hook for extensions to add periodic tasks. @celery.on_after_configure.connect def add_periodic_tasks(sender, **kwargs): - for params in extensions.periodic_tasks.values(): - sender.add_periodic_task(**params) + """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 + } + + 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: + params = entry_point.load() + # Keep a record of our periodic tasks + extensions_schedule[entry_point.name] = params + # and add it to Celery's periodic task registry, too. + sender.add_periodic_task(**params) + 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) From d0073774141864bd84acad70e062e960f5e7ffa1 Mon Sep 17 00:00:00 2001 From: Omer Lachish Date: Tue, 12 Mar 2019 09:00:49 +0100 Subject: [PATCH 03/15] Update redash/extensions.py Co-Authored-By: jezdez --- redash/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redash/extensions.py b/redash/extensions.py index e800d10712..6b412bff32 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -37,7 +37,7 @@ def module_bundle_files(module): def init_app(app): """Load the Redash extensions for the given Redash Flask app. - The extension entry pooint can return any type of value but + The extension entry point can return any type of value but must take a Flask application object. E.g.:: From 4c50d4231b8d12dc8743ea321feacb79432f002e Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 14 Mar 2019 09:36:51 +0100 Subject: [PATCH 04/15] Minor comment in requirements. --- requirements.txt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index 848d7da50a..67a0b94f9a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,5 +61,6 @@ disposable-email-domains # It is not included by default because of the GPL license conflict. # ldap3==2.2.4 gevent==1.4.0 -importlib-metadata==0.8 -importlib_resources==1.0.2 +# These can be removed when upgrading to Python 3.x +importlib-metadata==0.8 # remove when on 3.8 +importlib_resources==1.0.2 # remove when on 3.7 From 2f1df91dd868b0b7ef5960c4e69f1dafb0c6d045 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 14 Mar 2019 20:45:33 +0100 Subject: [PATCH 05/15] Refactoring after getting feedback. --- bin/bundle-extensions | 58 ++++++++++-------- redash/extensions.py | 139 ++++++++++++++++++++++++++++++++++-------- redash/worker.py | 56 +++-------------- 3 files changed, 152 insertions(+), 101 deletions(-) diff --git a/bin/bundle-extensions b/bin/bundle-extensions index a920f03558..680804c462 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -1,35 +1,43 @@ #!/usr/bin/env python +"""Copy bundle extension files to the client/app/extension directory""" import os -from distutils.dir_util import copy_tree - -import importlib_metadata -import importlib_resources - -from redash.extensions import BUNDLE_DIRECTORY, resource_isdir, entry_point_module +from pathlib2 import Path +from shutil import copy +from redash import create_app, extensions # 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 importlib_metadata.entry_points().get('redash.extensions', []): - # Check if there is a "bundle" subdirectory/-package in the - # entrypoint's module and ignoring the entrypoint if not. - module = entry_point_module(entry_point) - if not resource_isdir(module, BUNDLE_DIRECTORY): +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) + +# We need the app here for logging and to make sure the bundles have loaded. +app = create_app() + +bundles = extensions.bundles.items() +if bundles: + app.logger.info('Number of extension bundles found: %s', len(bundles)) +else: + app.logger.info('No extension bundles found.') + +for bundle_name, paths in extensions.bundles.items(): + # Shortcut in case not paths were found for the bundle + if not paths: + app.logger.info('No paths found for bundle "%s".', bundle_name) continue # The destination for the bundle files with the entry point name as the subdirectory - destination = os.path.join(EXTENSIONS_DIRECTORY, entry_point.name) + destination = Path(extensions_directory, bundle_name) + if not destination.exists(): + destination.mkdir() + # Copy the bundle directory from the module to its destination. - with importlib_resources.path(module, BUNDLE_DIRECTORY) as bundle_dir: - print("Copying {} to {}".format(bundle_dir, destination)) - copy_tree(str(bundle_dir), destination) + app.logger.info('Copying "%s" bundle to %s:', bundle_name, destination.resolve()) + for src_path in paths: + dest_path = destination / src_path.name + app.logger.info(" - {} -> {}".format(src_path, dest_path)) + copy(str(src_path), str(dest_path)) diff --git a/redash/extensions.py b/redash/extensions.py index 6b412bff32..cb370f23ba 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -1,11 +1,22 @@ -from types import ModuleType +# -*- coding: utf-8 -*- +from collections import OrderedDict as odict + from importlib_resources import contents, is_resource, path from importlib_metadata import entry_points BUNDLE_DIRECTORY = 'bundle' # The global Redash extension registry -extensions = {} +extensions = odict() + +# The global Redash bundle registry +bundles = 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() def resource_isdir(module, resource): @@ -25,16 +36,7 @@ def entry_point_module(entry_point): return entry_point.pattern.match(entry_point.value).group('module') -def module_bundle_files(module): - if not resource_isdir(module, BUNDLE_DIRECTORY): - return - - with path(module, BUNDLE_DIRECTORY) as bundle_dir: - # Copy content of extension bundle into extensions directory - return list(bundle_dir.rglob("*")) - - -def init_app(app): +def load_extensions(app): """Load the Redash extensions for the given Redash Flask app. The extension entry point can return any type of value but @@ -48,25 +50,108 @@ def extension(app): """ for entry_point in entry_points().get('redash.extensions', []): - app.logger.info('Loading Redash extension %s.', entry_point.name) - module = entry_point_module(entry_point) - # First of all, try to get a list of bundle files - extensions[entry_point.name]['resource_list'] = module_bundle_files(module) - + app.logger.info('Loading Redash extension "%s".', entry_point.name) try: # Then try to load the entry point (import and getattr) obj = entry_point.load() except (ImportError, AttributeError): # or move on - app.logger.error('Extension %s could not be found.', entry_point.name) - extensions[entry_point.name]['extension'] = None + 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_bundles(app): + """"Load bundles as defined in Redash extensions. + + 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", + ] + # ... + }, + # ... + ) + + """ + for entry_point in entry_points().get('redash.bundles', []): + app.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): + app.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("*")) + + +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: - # Otherwise check if the loaded entry point is a module - if isinstance(obj, ModuleType): - app.logger.info('Extension %s is a module.', entry_point.name) - extensions[entry_point.name]['extension'] = obj - # or simply call the loaded entry point instead. - else: - app.logger.info('Extension %s is a callable.', entry_point.name) - extensions[entry_point.name]['extension'] = obj(app) + 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 + } + + 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) + load_bundles(app) diff --git a/redash/worker.py b/redash/worker.py index 328875602c..4f3b2e4b17 100644 --- a/redash/worker.py +++ b/redash/worker.py @@ -8,20 +8,13 @@ from celery.schedules import crontab from celery.signals import worker_process_init from celery.utils.log import get_logger -from importlib_metadata import entry_points -from redash import create_app, settings +from redash import create_app, extensions, settings from redash.metrics import celery as celery_metrics # noqa logger = get_logger(__name__) -# 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. -extensions_schedule = {} - celery = Celery('redash', broker=settings.CELERY_BROKER, @@ -92,44 +85,9 @@ def init_celery_flask_app(**kwargs): @celery.on_after_configure.connect def add_periodic_tasks(sender, **kwargs): - """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 - } - - 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: - params = entry_point.load() - # Keep a record of our periodic tasks - extensions_schedule[entry_point.name] = params - # and add it to Celery's periodic task registry, too. - sender.add_periodic_task(**params) - 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) + """Load all periodic tasks from extensions and add them to Celery.""" + # Populate the redash.extensions.periodic_tasks dictionary + extensions.load_periodic_tasks(logger) + for params in extensions.periodic_tasks.values(): + # Add it to Celery's periodic task registry, too. + sender.add_periodic_task(**params) From b550d51556f7b0b569bb4ff82d3b54ae7a59d922 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 21 Mar 2019 11:26:07 +0100 Subject: [PATCH 06/15] Uncoupled bin/bundle-extensions from Flas app instance. --- bin/bundle-extensions | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/bin/bundle-extensions b/bin/bundle-extensions index 680804c462..a0f8fdb82b 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -4,7 +4,7 @@ import os from pathlib2 import Path from shutil import copy -from redash import create_app, extensions +from redash import extensions # Make a directory for extensions and set it as an environment variable # to be picked up by webpack. @@ -15,19 +15,17 @@ if not extensions_directory.exists(): extensions_directory.mkdir() os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) -# We need the app here for logging and to make sure the bundles have loaded. -app = create_app() bundles = extensions.bundles.items() if bundles: - app.logger.info('Number of extension bundles found: %s', len(bundles)) + print('Number of extension bundles found: {}'.format(len(bundles))) else: - app.logger.info('No extension bundles found.') + print('No extension bundles found.') -for bundle_name, paths in extensions.bundles.items(): +for bundle_name, paths in bundles: # Shortcut in case not paths were found for the bundle if not paths: - app.logger.info('No paths found for bundle "%s".', bundle_name) + print('No paths found for bundle "{}".'.format(bundle_name)) continue # The destination for the bundle files with the entry point name as the subdirectory @@ -36,8 +34,8 @@ for bundle_name, paths in extensions.bundles.items(): destination.mkdir() # Copy the bundle directory from the module to its destination. - app.logger.info('Copying "%s" bundle to %s:', bundle_name, destination.resolve()) + print('Copying "{}" bundle to {}:'.format(bundle_name, destination.resolve())) for src_path in paths: dest_path = destination / src_path.name - app.logger.info(" - {} -> {}".format(src_path, dest_path)) + print(" - {} -> {}".format(src_path, dest_path)) copy(str(src_path), str(dest_path)) From 29eb0a9d47213934795ff486efa46de48f499729 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 18:59:42 +0200 Subject: [PATCH 07/15] =?UTF-8?q?Load=20bundles=20in=20bundle=20script=20a?= =?UTF-8?q?nd=20don=E2=80=99t=20rely=20on=20Flask.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bin/bundle-extensions | 2 ++ redash/extensions.py | 42 ++++++++++++++++++++++++++++++------------ 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/bin/bundle-extensions b/bin/bundle-extensions index a0f8fdb82b..3338a13e35 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -15,6 +15,8 @@ if not extensions_directory.exists(): extensions_directory.mkdir() os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) +# load bundles from entry points +extensions.load_bundles() bundles = extensions.bundles.items() if bundles: diff --git a/redash/extensions.py b/redash/extensions.py index cb370f23ba..ffef57ef1e 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- +import logging from collections import OrderedDict as odict from importlib_resources import contents, is_resource, path from importlib_metadata import entry_points -BUNDLE_DIRECTORY = 'bundle' +BUNDLE_DIRECTORY = "bundle" # The global Redash extension registry extensions = odict() @@ -18,6 +19,8 @@ # after the configuration has already happened. periodic_tasks = odict() +logger = logging.getLogger(__name__) + def resource_isdir(module, resource): """Whether a given resource is a directory in the given module @@ -33,7 +36,7 @@ def resource_isdir(module, resource): 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') + return entry_point.pattern.match(entry_point.value).group("module") def load_extensions(app): @@ -49,25 +52,29 @@ def extension(app): Foobar(app) """ - for entry_point in entry_points().get('redash.extensions', []): + for entry_point in entry_points().get("redash.extensions", []): app.logger.info('Loading Redash extension "%s".', entry_point.name) try: # 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) + 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) + 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_bundles(app): +def load_bundles(): """"Load bundles as defined in Redash extensions. The bundle entry point can be defined as a dotted path to a module @@ -101,12 +108,15 @@ def load_bundles(app): ) """ - for entry_point in entry_points().get('redash.bundles', []): - app.logger.info('Loading Redash bundle "%s".', entry_point.name) + bundles.clear() + 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): - app.logger.error('Redash bundle directory "%s" could not be found.', entry_point.name) + 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("*")) @@ -143,13 +153,21 @@ def 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) + 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) + logger.error( + 'Periodic Redash task "%s" could not be found at "%s".', + entry_point.name, + entry_point.value, + ) def init_app(app): From 177289910d0e0cdd92e8c89c2061abe011c9570f Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 19:01:33 +0200 Subject: [PATCH 08/15] Upgraded to importlib-metadata 0.9. --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 67a0b94f9a..1d4dc97581 100644 --- a/requirements.txt +++ b/requirements.txt @@ -62,5 +62,5 @@ disposable-email-domains # ldap3==2.2.4 gevent==1.4.0 # These can be removed when upgrading to Python 3.x -importlib-metadata==0.8 # remove when on 3.8 +importlib-metadata==0.9 # remove when on 3.8 importlib_resources==1.0.2 # remove when on 3.7 From e265037bb25fb13c50196dd98d86af29fadce687 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 19:06:08 +0200 Subject: [PATCH 09/15] Add missing requirement. --- requirements.txt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index 1d4dc97581..3e2e0d0cbc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,4 +63,5 @@ disposable-email-domains gevent==1.4.0 # These can be removed when upgrading to Python 3.x importlib-metadata==0.9 # remove when on 3.8 -importlib_resources==1.0.2 # remove when on 3.7 +importlib_resources==1.0.2 # remove when on 3.7 +pathlib2==2.3.3 # remove when on 3.x From 8919682c56e1ab2cb750400f50386b11a650a337 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 19:14:40 +0200 Subject: [PATCH 10/15] Fix TypeError. --- redash/extensions.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/redash/extensions.py b/redash/extensions.py index ffef57ef1e..1e95263973 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -172,4 +172,4 @@ def add_two_and_two(): def init_app(app): load_extensions(app) - load_bundles(app) + load_bundles() From 62f53094df2fe4704f46b7e507ce8a5e6fcdab2f Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 19:51:20 +0200 Subject: [PATCH 11/15] Added requirements for bundle_extension script. --- .circleci/config.yml | 4 +++- requirements.txt | 6 ++---- requirements_bundles.txt | 9 +++++++++ 3 files changed, 14 insertions(+), 5 deletions(-) create mode 100644 requirements_bundles.txt diff --git a/.circleci/config.yml b/.circleci/config.yml index aa749ca4e9..4cc8df1e14 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -39,7 +39,7 @@ jobs: name: Copy Test Results command: | mkdir -p /tmp/test-results/unit-tests - docker cp tests:/app/coverage.xml ./coverage.xml + docker cp tests:/app/coverage.xml ./coverage.xml docker cp tests:/app/junit.xml /tmp/test-results/unit-tests/results.xml - store_test_results: path: /tmp/test-results @@ -61,6 +61,7 @@ jobs: steps: - checkout - run: sudo apt install python-pip + - run: sudo pip install -r requirements_bundles.txt - run: npm install - run: npm run bundle - run: npm test @@ -95,6 +96,7 @@ jobs: steps: - checkout - run: sudo apt install python-pip + - run: sudo pip install -r requirements_bundles.txt - run: npm install - run: .circleci/update_version - run: npm run bundle diff --git a/requirements.txt b/requirements.txt index 3e2e0d0cbc..0e48c22de1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,7 +61,5 @@ disposable-email-domains # It is not included by default because of the GPL license conflict. # ldap3==2.2.4 gevent==1.4.0 -# These can be removed when upgrading to Python 3.x -importlib-metadata==0.9 # remove when on 3.8 -importlib_resources==1.0.2 # remove when on 3.7 -pathlib2==2.3.3 # remove when on 3.x +# Requirements for the extension bundle loading +-r requirements_bundles.txt diff --git a/requirements_bundles.txt b/requirements_bundles.txt new file mode 100644 index 0000000000..9b871f411d --- /dev/null +++ b/requirements_bundles.txt @@ -0,0 +1,9 @@ +# These are the requirements that the extension bundle +# loading mechanism need on Python 2 and can be removed +# when moved to Python 3. +# It's automatically installed when running npm run bundle + +# These can be removed when upgrading to Python 3.x +importlib-metadata==0.9 # remove when on 3.8 +importlib_resources==1.0.2 # remove when on 3.7 +pathlib2==2.3.3 # remove when on 3.x From de9f6bfc7ebf69c078db91172f052093c817ae10 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 20:17:02 +0200 Subject: [PATCH 12/15] Install bundles requirement file correctly. --- Dockerfile | 4 ++-- requirements.txt | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 2f719909a3..c70289fe4a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,8 +14,8 @@ ARG skip_ds_deps # We first copy only the requirements file, to avoid rebuilding on every file # change. -COPY requirements.txt requirements_dev.txt requirements_all_ds.txt ./ -RUN pip install -r requirements.txt -r requirements_dev.txt +COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./ +RUN pip install -r requirements.txt -r requirements_bundles.txt -r requirements_dev.txt RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi COPY . /app diff --git a/requirements.txt b/requirements.txt index 0e48c22de1..97067862ee 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,5 +61,3 @@ disposable-email-domains # It is not included by default because of the GPL license conflict. # ldap3==2.2.4 gevent==1.4.0 -# Requirements for the extension bundle loading --r requirements_bundles.txt From d58595a6d8f21af1edd32cc0f68ffd85fdd9489b Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Thu, 18 Apr 2019 21:01:09 +0200 Subject: [PATCH 13/15] Decouple bundle loading code from Redash. --- .circleci/config.yml | 3 ++ bin/bundle-extensions | 83 ++++++++++++++++++++++++++++++++++++++++--- redash/extensions.py | 72 ------------------------------------- 3 files changed, 82 insertions(+), 76 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 4cc8df1e14..42b877a997 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -111,6 +111,9 @@ jobs: steps: - setup_remote_docker - checkout + - run: sudo apt install python-pip + - run: sudo pip install -r requirements_bundles.txt + - run: npm run bundle - run: .circleci/update_version - run: .circleci/docker_build workflows: diff --git a/bin/bundle-extensions b/bin/bundle-extensions index 3338a13e35..f597784647 100755 --- a/bin/bundle-extensions +++ b/bin/bundle-extensions @@ -1,10 +1,20 @@ #!/usr/bin/env python +# -*- coding: utf-8 -*- """Copy bundle extension files to the client/app/extension directory""" +import logging import os 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 + +# Name of the subdirectory +BUNDLE_DIRECTORY = "bundle" + +logger = logging.getLogger(__name__) -from redash import extensions # Make a directory for extensions and set it as an environment variable # to be picked up by webpack. @@ -15,10 +25,75 @@ if not extensions_directory.exists(): extensions_directory.mkdir() os.environ["EXTENSIONS_DIRECTORY"] = str(extensions_relative_path) -# load bundles from entry points -extensions.load_bundles() -bundles = extensions.bundles.items() +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. + + 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: diff --git a/redash/extensions.py b/redash/extensions.py index 1e95263973..ea4d1a41ce 100644 --- a/redash/extensions.py +++ b/redash/extensions.py @@ -2,17 +2,11 @@ import logging from collections import OrderedDict as odict -from importlib_resources import contents, is_resource, path from importlib_metadata import entry_points -BUNDLE_DIRECTORY = "bundle" - # The global Redash extension registry extensions = odict() -# The global Redash bundle registry -bundles = 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 @@ -22,23 +16,6 @@ logger = logging.getLogger(__name__) -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_extensions(app): """Load the Redash extensions for the given Redash Flask app. @@ -74,54 +51,6 @@ def extension(app): extensions[entry_point.name] = obj(app) -def load_bundles(): - """"Load bundles as defined in Redash extensions. - - 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.clear() - 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("*")) - - def load_periodic_tasks(logger): """Load the periodic tasks as defined in Redash extensions. @@ -172,4 +101,3 @@ def add_two_and_two(): def init_app(app): load_extensions(app) - load_bundles() From eb9eb3be48c97d8b8184dcfd0917b595a55dbd93 Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Wed, 22 May 2019 14:39:11 +0200 Subject: [PATCH 14/15] Install bundle requirements from requirements.txt. --- Dockerfile | 2 +- requirements.txt | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index c70289fe4a..afa6307ba5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ ARG skip_ds_deps # We first copy only the requirements file, to avoid rebuilding on every file # change. COPY requirements.txt requirements_bundles.txt requirements_dev.txt requirements_all_ds.txt ./ -RUN pip install -r requirements.txt -r requirements_bundles.txt -r requirements_dev.txt +RUN pip install -r requirements.txt -r requirements_dev.txt RUN if [ "x$skip_ds_deps" = "x" ] ; then pip install -r requirements_all_ds.txt ; else echo "Skipping pip install -r requirements_all_ds.txt" ; fi COPY . /app diff --git a/requirements.txt b/requirements.txt index 97067862ee..057a21bb71 100644 --- a/requirements.txt +++ b/requirements.txt @@ -61,3 +61,7 @@ disposable-email-domains # It is not included by default because of the GPL license conflict. # ldap3==2.2.4 gevent==1.4.0 + +# Install the dependencies of the bin/bundle-extensions script here. +# It has its own requirements file to simplify the frontend client build process +-r requirements_bundles.txt From 80ee335e5b6280266c11e357b6c4c9c5367bcdfb Mon Sep 17 00:00:00 2001 From: Jannis Leidel Date: Wed, 22 May 2019 15:16:22 +0200 Subject: [PATCH 15/15] Use circleci/node for build-docker-image step, too. --- .circleci/config.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 42b877a997..b7d7033e9e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -107,14 +107,14 @@ jobs: path: /tmp/artifacts/ build-docker-image: docker: - - image: circleci/buildpack-deps:xenial + - image: circleci/node:8 steps: - setup_remote_docker - checkout - run: sudo apt install python-pip - run: sudo pip install -r requirements_bundles.txt - - run: npm run bundle - run: .circleci/update_version + - run: npm run bundle - run: .circleci/docker_build workflows: version: 2