Datasource URL extension.#6
Conversation
c6734b3 to
8f02c38
Compare
|
Note: for the python portion of the extension, a new method, |
|
Note: This still needs other data source URLs to be added. |
8f02c38 to
73affaf
Compare
|
Also note: this is a port of mozilla/redash@75228b2 |
jezdez
left a comment
There was a problem hiding this comment.
Some minor nits, but also two bigger things regarding the extension API design that I only now understand and a no to monkey-patches for this use case since there is a more scalable way.
| *.pyc | ||
| .DS_Store | ||
| .pytest_cache/ | ||
| .DS_Store |
| for filename in filenames: | ||
| if filename.endswith(".js") or filename.endswith(".html"): | ||
| matches.append(os.path.join(root, filename).split(PREFIX, 1)[1]) | ||
| return matches |
There was a problem hiding this comment.
Instead of manually building this here, we can use a different feature of setuptools: https://python-packaging.readthedocs.io/en/latest/non-code-files.html
Basically passing include_package_data=True to the setup() function is enough and then specifying the actual non-Python files in the MANIFEST.in file (read: file manifest template). It supports globs and recursive inclusion, for our case:
include AUTHORS.rst CHANGELOG.rst README.rst
recursive-include src/redash_stmo *.html *.js
| @@ -0,0 +1,59 @@ | |||
| from redash.models import DataSource | |||
There was a problem hiding this comment.
Please stay consistent with the 4 character indentation.
| "vertica": ( | ||
| "https://my.vertica.com/docs/8.0.x/HTML/index.htm#Authoring/" | ||
| "ConceptsGuide/Other/SQLOverview.htm%3FTocPath%3DSQL" | ||
| "%2520Reference%2520Manual%7C_____1") |
There was a problem hiding this comment.
Linebreak after the string please.
| 'datasource_link = redash_stmo.datasource_link:datasource_link' | ||
| ], | ||
| 'webpack.bundles': [ | ||
| 'datasource_link = redash_stmo.js_extensions:datasource_link', |
There was a problem hiding this comment.
So looking at this I noticed a problem I hadn't seen when I reviewed the extension API code into our fork, that the need to provide config values for extension_directory and frontend_content isn't actually really there and looks like redundant. Both values can be calculated from the entrypoint object that is returned when we load the redash.extensions entrypoints.
extension_directory-- is the filesystem path of the packageredash_stmo.datasource_linkfrontend_content-- has no need to be defined individually per extension and should instead be consistent for every extension
So I believe we should simplify the structure of an extension further:
An extension is a Python module or package which may or may not have a bundle subdirectory, and is defined in the redash.extensions entrypoint.
The code that loads the bundles in bundle-extensions should calculate the original source of the extension frontend files from the entry in the redash.extensions entrypoint and use the name of the loaded entrypoint (e.g. datasource_link) as the subdirectory in client/app/extensions.
That basically would remove the need for the webpack.bundles entrypoint again and consolidate frontend and backend into one location.
There was a problem hiding this comment.
What about when an extension is frontend only with no python portion? Would we then have an empty python file/function for it to be loaded here: https://github.com/mozilla/redash/blob/master/redash/extensions.py?
Alternatively, extensions.py could be modified to skip over empty extensions.
I think this is originally why I had a separate entry, webpack.bundles here. Since not all frontend extensions will have a backend portion.
There was a problem hiding this comment.
For pure-frontend extensions the process would still be having a package with its name, e.g.
src
└── redash_stmo
├── __init__.py
├── dockerflow.py
├── health.py
├── whimsycorn
│ ├── __init__.py
│ └── bundle
│ │ ├── index.js
│ │ ├── style.css
│ │ └── template.html
├── query_runner
│ ├── __init__.py
│ └── activedata.py
└── settings.py
That would allow us:
a) use the Python import system to look for the path of the packages
b) provide a location of extra extension metadata later on (e.g. verbose name, description text etc)
Skipping over extensions by checking if the loaded entrypoint is a callable would make sense. IIRC the Entrypoint object has some good APIs to see what it is.
E.g. the syntax for entrypoints only requires a modulename (read: dotted path to a module or package): https://github.com/pypa/setuptools/blob/01fd232d95ad51f784de53c0993a7b91868d269d/pkg_resources/__init__.py#L2361-L2368
You can use the Entrypoint.resolve function which will try to import it and raise an ImportError if it fails when you skip those that don't actually have an entrypoint provided.
So an entrypoint list in setup.py could look like this (where the whimsycorn extension just happens to not need a callable):
entrypoints={
'redash.extensions': [
'dockerflow = redash_stmo.dockerflow:dockerflow',
'datasource_health = redash_stmo.health:datasource_health',
'datasource_link = redash_stmo.datasource_link:datasource_link',
'whimsycorn = redash_stmo.whimsy_corn',
],
}In the bundle-extensions script you'll repeat some of the logic of course since you'll need to check if the bundle directory exists in the extension directory.
| @@ -0,0 +1,59 @@ | |||
| from redash.models import DataSource | |||
| from redash.query_runner import BaseQueryRunner, query_runners | |||
| from redash.query_runner import get_configuration_schema_for_query_runner_type | |||
There was a problem hiding this comment.
You should use parentheses to combine those two imports in one import call.
| "title": "Documentation URL", | ||
| "default": runner_class.default_doc_url}) | ||
|
|
||
| DataSource.to_dict = datasource_to_dict |
There was a problem hiding this comment.
So a few things, adding the configuration property to the runner_class makes sense, although the code is missing so I'm not sure, but the other part where it monkey-patches multiple classes to pass a few strings to a template is not a good idea maintenance wise.
I suggest to instead create a new API endpoint /api/data_sources/<data_source_id>/link like so (and do the route registration inside the datasource_link entrypoint callback):
from redash.handlers.api import api
class DataSourceLinkResource(BaseResource):
def get(self, data_source_id):
data_source = get_object_or_404(
DataSource.get_by_id_and_org,
data_source_id,
self.current_org,
)
return {
'type_name': data_source.query_runner.name()
'doc_url': data_source.options.get('doc_url', None)
}
api.add_org_resource(DataSourceLinkResource, '/api/data_sources/<data_source_id>/link')Then in the frontend code we simply fetch the data from that endpoint and pass it to the template when the site is loaded. That way we separate the concerns and don't risk overlaps with other extensions in the future that also want to add stuff to the query editor page. FWIW I'm not worried about the extra call to the server since we're not really dealing with a ton of traffic.
There was a problem hiding this comment.
Good point! I did not consider an API call as an alternative here, but I like it. Thank you!
To clarify, add_configuration_property() would be implemented in BaseQueryRunner and it would look something like this:
class BaseQueryRunner(object):
....
configuration_properties = None
....
@classmethod
def add_configuration_property(cls, property, value):
if cls.configuration_properties is None:
raise NotImplementedError()
cls.configuration_properties[property] = value
And every class that inherits from BaseQueryRunner that wants to add a URL to its config needs to have a configuration_properties field. Does that sound reasonable?
There was a problem hiding this comment.
Yep, that sounds totally reasonable! Thank you :)
| @@ -0,0 +1,2 @@ | |||
| <a ng-if="dataSource.options.doc_url != '' && dataSource.options.doc_url" ng-href={{dataSource.options.doc_url}}>{{dataSource.type_name}} documentation</a> | |||
| <span ng-if="dataSource.options.doc_url == '' || !dataSource.options.doc_url">{{ dataSource.type_name }} documentation</span> No newline at end of file | |||
There was a problem hiding this comment.
This needs a tiny bit of update given the difference in what the datasource link API endpoint returns.
| function controller($scope, $route) { | ||
| const dataSources = $route.current.locals.dataSources; | ||
| const query = $route.current.locals.query; | ||
| $scope.dataSource = dataSources.find(ds => ds.id === query.data_source_id); |
There was a problem hiding this comment.
I understand that we use Angular's resource thing here to get the data from the API, and I'm not sure how to write the actual API call to the new datasource link API endpoint. But since we'll be doing React in the future, might as well not lean too much on Angular IMO.
In fact, don't you want to try writing this in React instead? Allen could help you get up to speed how to do that and the React setup is already in Redash enabled.
b6e703e to
9dce79d
Compare
|
|
||
| # After api.init_app() is called, api.app should be set by Flask (but it's not) so that | ||
| # further calls to add_resource() are handled immediately for the given app. | ||
| api.app = app |
There was a problem hiding this comment.
Is this an issue in Redash or something that Flask-Restful does wrong?
There was a problem hiding this comment.
This is an issue in Flask-Restful. There are a few things going on that led me to adding this line.
tl;dr: Flask-Restful does not play well with the Redash setup of multiple workers each having a different instance of a flask app.
-
Inside
create_app()inredash/__init__.py, we cannot movehandlers.init_app(app)(which callsapi.init_app(app)) afterextensions.init_extensions(app)because the datasource_health extension overwrites thestatus_apiendpoint after it’s created. If extensions initialize first then an attempt is made in Redash to addstatus_apiand it crashes. So we keep the init order so redash-stmo can override endpoints that already exist. -
Unlike
api = Api(app)(https://github.com/flask-restful/flask-restful/blob/master/flask_restful/__init__.py#L92),api.init_app()does not setapi.appwhen it's called (https://github.com/flask-restful/flask-restful/blob/master/flask_restful/__init__.py#L196). This results in any further calls toapi.add_resource()afterapi.init_app()to actually have no effect. It results in appending toapi.resources(https://github.com/flask-restful/flask-restful/blob/master/flask_restful/__init__.py#L384) but no call toregister_view() -
Even if we did manage to work around this issue in
1., this would not resolve this issue. A Flask app is created in each worker withcreate_app(). If an extension tries to doapi.add_org_resource(), the same resource will be added toapi.resourcesfrom multiple workers. Now whenapi.init_app(app)is called, there is an exception thrown that there are multiple view functions with the same name.
The crux of the problem is that api.resources is shared among multiple apps as a temporary cache until api.init_app(app) is called so that resources can be attached to a given app. Since we have a different app for each worker, and each worker is adding the same resource in an extension, this model doesn't quite work.
By setting api.app = app we ensure that the resource is added to the correct app and there is no duplication by caching in api.resources.
9dce79d to
b0c1ac7
Compare
| }; | ||
| } | ||
|
|
||
| _loadURLData() { |
There was a problem hiding this comment.
Any reason to have this prefixed with an underscore?
There was a problem hiding this comment.
This was just to indicate it's not a built-in React function like the rest. I can remove it if it's more confusing than useful. It doesn't necessarily seem to be a common practice, I've just seen it in a couple of places.
There was a problem hiding this comment.
I don't have a strong opinion either way, just noticed and wondered about the best practice in modern JS is.
| return {}; | ||
| }) | ||
| .then((json) => { | ||
| const { type_name, doc_url } = json.message; |
There was a problem hiding this comment.
Forgive my noobness with modern JS, wouldn't this code fail in case the fetch call returns an empty object because for some error happening server side?
I mean, json.message would not exist and then the variable unpacking would fail. Does this require more defensive code?
There was a problem hiding this comment.
Good catch. I will fix this prior to merging, thanks!
b0c1ac7 to
06e107c
Compare

No description provided.