Browse Source

HUE-8331 [metadata] Refactor lib to allow additional pluggable backends

Romain Rigaux 7 năm trước cách đây
mục cha
commit
531190efae

+ 2 - 2
desktop/core/src/desktop/api2.py

@@ -34,8 +34,8 @@ from django.views.decorators.csrf import ensure_csrf_cookie
 from django.views.decorators.http import require_POST
 
 from metadata.conf import has_navigator
-from metadata.navigator_api import search_entities as metadata_search_entities, _highlight
-from metadata.navigator_api import search_entities_interactive as metadata_search_entities_interactive
+from metadata.catalog_api import search_entities as metadata_search_entities, _highlight
+from metadata.catalog_api import search_entities_interactive as metadata_search_entities_interactive
 from notebook.connectors.base import Notebook
 from notebook.views import upgrade_session_properties
 

+ 16 - 0
desktop/libs/metadata/src/metadata/catalog/__init__.py

@@ -0,0 +1,16 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.

+ 128 - 0
desktop/libs/metadata/src/metadata/catalog/base.py

@@ -0,0 +1,128 @@
+#!/usr/bin/env python
+# Licensed to Cloudera, Inc. under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  Cloudera, Inc. licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+from django.utils.translation import ugettext as _
+
+from desktop.lib.exceptions_renderable import PopupException
+from desktop.lib.i18n import smart_unicode
+
+
+def get_api(request, interface):
+
+  if interface == 'navigator':
+    from metadata.catalog.navigator_client import NavigatorApi
+    return NavigatorApi(user=request.user)
+  elif interface == 'dummy':
+    from metadata.catalog.dummy_client import DummyApi
+    return DummyApi(user=request.user)
+  else:
+    raise PopupException(_('Catalog connector interface not recognized: %s') % interface)
+
+
+class CatalogApiException(Exception):
+  def __init__(self, message=None):
+    self.message = message or _('No error message, please check the logs.')
+
+  def __str__(self):
+    return str(self.message)
+
+  def __unicode__(self):
+    return smart_unicode(self.message)
+
+
+class CatalogEntityDoesNotExistException(Exception):
+  def __init__(self, message=None):
+    self.message = message or _('No error message, please check the logs.')
+
+  def __str__(self):
+    return str(self.message)
+
+  def __unicode__(self):
+    return smart_unicode(self.message)
+
+
+class CatalogAuthException(Exception):
+  def __init__(self, message=None):
+    self.message = message or _('No error message, please check the logs.')
+
+  def __str__(self):
+    return str(self.message)
+
+  def __unicode__(self):
+    return smart_unicode(self.message)
+
+
+# Base API
+
+class Api(object):
+
+  def __init__(self, user=None):
+    self.user = user
+
+  # To implement
+
+  def search_entities_interactive(self, query_s=None, limit=100, **filters):
+    return {}
+
+
+  def find_entity(self, source_type, type, name, **filters):
+    return {}
+
+
+  def get_entity(self, entity_id):
+    return {}
+
+
+  def update_entity(self, entity, **metadata):
+    return {}
+
+
+  def add_tags(self, entity_id, tags):
+    return {}
+
+
+  def delete_tags(self, entity_id, tags):
+    return {}
+
+  # Common APIs
+
+  def get_database(self, name):
+    return self.find_entity(source_type='HIVE', type='DATABASE', name=name)
+
+
+  def get_table(self, database_name, table_name, is_view=False):
+    parent_path = '\/%s' % database_name
+    return self.find_entity(source_type='HIVE', type='VIEW' if is_view else 'TABLE', name=table_name, parentPath=parent_path)
+
+
+  def get_field(self, database_name, table_name, field_name):
+    parent_path = '\/%s\/%s' % (database_name, table_name)
+    return self.find_entity(source_type='HIVE', type='FIELD', name=field_name, parentPath=parent_path)
+
+
+  def get_partition(self, database_name, table_name, partition_spec):
+    raise NotImplementedError
+
+
+  def get_directory(self, path):
+    dir_name, dir_path = self._clean_path(path)
+    return self.find_entity(source_type='HDFS', type='DIRECTORY', name=dir_name, fileSystemPath=dir_path)
+
+
+  def get_file(self, path):
+    file_name, file_path = self._clean_path(path)
+    return self.find_entity(source_type='HDFS', type='FILE', name=file_name, fileSystemPath=file_path)

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 35 - 0
desktop/libs/metadata/src/metadata/catalog/dummy_client.py


+ 185 - 159
desktop/libs/metadata/src/metadata/navigator_client.py → desktop/libs/metadata/src/metadata/catalog/navigator_client.py

@@ -25,7 +25,6 @@ from itertools import islice
 from django.core.cache import cache
 from django.utils.translation import ugettext as _
 
-from desktop.lib.i18n import smart_unicode
 from desktop.lib.rest import resource
 from desktop.lib.rest.unsecure_http_client import UnsecureHttpClient
 from desktop.lib.rest.http_client import RestException
@@ -35,9 +34,9 @@ from libsentry.privilege_checker import get_checker
 from libsentry.sentry_site import get_hive_sentry_provider
 
 from metadata.conf import NAVIGATOR, get_navigator_auth_password, get_navigator_auth_username
+from metadata.catalog.base import CatalogAuthException, CatalogApiException, CatalogEntityDoesNotExistException, Api
 from metadata.metadata_sites import get_navigator_hue_server_name
 
-
 LOG = logging.getLogger(__name__)
 VERSION = 'v9'
 _JSON_CONTENT_TYPE = 'application/json'
@@ -78,40 +77,7 @@ def get_filesystem_host():
   return host
 
 
-class NavigatorApiException(Exception):
-  def __init__(self, message=None):
-    self.message = message or _('No error message, please check the logs.')
-
-  def __str__(self):
-    return str(self.message)
-
-  def __unicode__(self):
-    return smart_unicode(self.message)
-
-
-class EntityDoesNotExistException(Exception):
-  def __init__(self, message=None):
-    self.message = message or _('No error message, please check the logs.')
-
-  def __str__(self):
-    return str(self.message)
-
-  def __unicode__(self):
-    return smart_unicode(self.message)
-
-
-class NavigathorAuthException(Exception):
-  def __init__(self, message=None):
-    self.message = message or _('No error message, please check the logs.')
-
-  def __str__(self):
-    return str(self.message)
-
-  def __unicode__(self):
-    return smart_unicode(self.message)
-
-
-class NavigatorApi(object):
+class NavigatorApi(Api):
   """
   http://cloudera.github.io/navigator/apidocs/v3/index.html
   """
@@ -119,11 +85,12 @@ class NavigatorApi(object):
   CATALOG_NAMESPACE = '__cloudera_internal_catalog_hue'
 
   def __init__(self, user=None):
+    super(NavigatorApi, self).__init__(user)
+
     self._api_url = '%s/%s' % (NAVIGATOR.API_URL.get().strip('/'), VERSION)
     self._username = get_navigator_auth_username()
     self._password = get_navigator_auth_password()
 
-    self.user = user
     # Navigator does not support Kerberos authentication while other components usually requires it
     self._client = UnsecureHttpClient(self._api_url, logger=LOG)
     self._client.set_basic_auth(self._username, self._password)
@@ -151,85 +118,6 @@ class NavigatorApi(object):
     return default_entity_types, entity_types
 
 
-  def search_entities(self, query_s, limit=100, offset=0, raw_query=False, **filters):
-    """
-    Solr edismax query parser syntax.
-
-    :param query_s: a query string of search terms (e.g. - sales quarterly);
-      Currently the search will perform an OR boolean search for all terms (split on whitespace), against a whitelist of search_fields.
-    """
-    sources = filters.get('sources', [])
-    default_entity_types, entity_types = self._get_types_from_sources(sources)
-
-    try:
-      params = self.__params
-      if not raw_query:
-        query_s = query_s.replace('{', '\\{').replace('}', '\\}').replace('(', '\\(').replace(')', '\\)').replace('[', '\\[').replace(']', '\\]')
-
-        search_terms = [term for term in query_s.strip().split()]
-
-        query_clauses = []
-        user_filters = []
-        source_type_filter = []
-
-        for term in search_terms:
-          if ':' not in term:
-            if ('sql' in sources or 'hive' in sources or 'impala' in sources):
-              if '.' in term:
-                parent, term = term.rsplit('.', 1)
-                user_filters.append('parentPath:"/%s"' % parent.replace('.', '/'))
-            query_clauses.append(self._get_boosted_term(term))
-          else:
-            name, val = term.split(':')
-            if val:
-              if name == 'type':
-                term = '%s:%s' % (name, val.upper().strip('*'))
-                default_entity_types = entity_types # Make sure type value still makes sense for the source
-              user_filters.append(term + '*') # Manual filter allowed e.g. type:VIE* ca
-
-        filter_query = '*'
-
-        if query_clauses:
-          filter_query = 'OR'.join(['(%s)' % clause for clause in query_clauses])
-
-        user_filter_clause = 'AND '.join(['(%s)' % f for f in user_filters]) or '*'
-        source_filter_clause = 'OR'.join(['(%s:%s)' % ('type', entity_type) for entity_type in default_entity_types])
-
-        if 's3' in sources:
-          source_type_filter.append('sourceType:s3')
-        elif 'sql' in sources or 'hive' in sources or 'impala' in sources:
-          source_type_filter.append('sourceType:HIVE OR sourceType:IMPALA')
-
-        filter_query = '%s AND (%s) AND (%s)' % (filter_query, user_filter_clause, source_filter_clause)
-        if source_type_filter:
-          filter_query += ' AND (%s)' % 'OR '.join(source_type_filter)
-
-        source_ids = get_cluster_source_ids(self)
-        if source_ids:
-          filter_query = source_ids + '(' + filter_query + ')'
-      else:
-        filter_query = query_s
-
-      params += (
-        ('query', filter_query),
-        ('offset', offset),
-        ('limit', NAVIGATOR.FETCH_SIZE_SEARCH.get()),
-      )
-
-      LOG.info(params)
-      response = self._root.get('entities', headers=self.__headers, params=params)
-
-      response = list(islice(self._secure_results(response), limit)) # Apply Sentry perms
-
-      return response
-    except RestException, e:
-      LOG.error('Failed to search for entities with search query: %s' % query_s)
-      if e.code == 401:
-        raise NavigathorAuthException(_('Failed to authenticate.'))
-      else:
-        raise NavigatorApiException(e)
-
-
   def search_entities_interactive(self, query_s=None, limit=100, offset=0, facetFields=None, facetPrefix=None, facetRanges=None, filterQueries=None, firstClassEntitiesOnly=None, sources=None):
     try:
       pagination = {
@@ -237,6 +125,90 @@ class NavigatorApi(object):
         'limit': NAVIGATOR.FETCH_SIZE_SEARCH_INTERACTIVE.get(),
       }
 
+      f = {
+          "outputFormat" : {
+            "type" : "dynamic"
+          },
+          "name" : {
+            "type" : "dynamic"
+          },
+          "lastModified" : {
+            "type" : "date"
+          },
+          "sourceType" : {
+            "type" : "dynamic"
+          },
+          "parentPath" : {
+            "type" : "dynamic"
+          },
+          "lastAccessed" : {
+            "type" : "date"
+          },
+          "type" : {
+            "type" : "dynamic"
+          },
+          "sourceId" : {
+            "type" : "dynamic"
+          },
+          "partitionColNames" : {
+            "type" : "dynamic"
+          },
+          "serDeName" : {
+            "type" : "dynamic"
+          },
+          "created" : {
+            "type" : "date"
+          },
+          "fileSystemPath" : {
+            "type" : "dynamic"
+          },
+          "compressed" : {
+            "type" : "bool"
+          },
+          "clusteredByColNames" : {
+            "type" : "dynamic"
+          },
+          "originalName" : {
+            "type" : "dynamic"
+          },
+          "owner" : {
+            "type" : "dynamic"
+          },
+          "extractorRunId" : {
+            "type" : "dynamic"
+          },
+          "userEntity" : {
+            "type" : "bool"
+          },
+          "sortByColNames" : {
+            "type" : "dynamic"
+          },
+          "inputFormat" : {
+            "type" : "dynamic"
+          },
+          "serDeLibName" : {
+            "type" : "dynamic"
+          },
+          "originalDescription" : {
+            "type" : "dynamic"
+          },
+          "lastModifiedBy" : {
+            "type" : "dynamic"
+          }
+        }
+
+      auto_field_facets = ["tags", "type"] + f.keys()
+      query_s = query_s.strip() + '*'
+
+      last_query_term = [term for term in query_s.split()][-1]
+
+      if last_query_term and last_query_term != '*':
+        last_query_term = last_query_term.rstrip('*')
+        (fname, fval) = last_query_term.split(':') if ':' in last_query_term else (last_query_term, '')
+        auto_field_facets = [f for f in auto_field_facets if f.startswith(fname)]
+
+      facetFields = facetFields or auto_field_facets[:5]
+
       entity_types = []
       fq_type = []
       if filterQueries is None:
@@ -304,12 +276,93 @@ class NavigatorApi(object):
     except RestException, e:
       LOG.error('Failed to search for entities with search query: %s' % json.dumps(body))
       if e.code == 401:
-        raise NavigathorAuthException(_('Failed to authenticate.'))
+        raise CatalogAuthException(_('Failed to authenticate.'))
       else:
-        raise NavigatorApiException(e.message)
+        raise CatalogApiException(e.message)
+
+
+
+  def search_entities(self, query_s, limit=100, offset=0, raw_query=False, **filters):
+    """
+    Solr edismax query parser syntax.
+
+    :param query_s: a query string of search terms (e.g. - sales quarterly);
+      Currently the search will perform an OR boolean search for all terms (split on whitespace), against a whitelist of search_fields.
+    """
+    sources = filters.get('sources', [])
+    default_entity_types, entity_types = self._get_types_from_sources(sources)
+
+    try:
+      params = self.__params
+      if not raw_query:
+        query_s = query_s.replace('{', '\\{').replace('}', '\\}').replace('(', '\\(').replace(')', '\\)').replace('[', '\\[').replace(']', '\\]')
+
+        search_terms = [term for term in query_s.strip().split()]
+
+        query_clauses = []
+        user_filters = []
+        source_type_filter = []
+
+        for term in search_terms:
+          if ':' not in term:
+            if ('sql' in sources or 'hive' in sources or 'impala' in sources):
+              if '.' in term:
+                parent, term = term.rsplit('.', 1)
+                user_filters.append('parentPath:"/%s"' % parent.replace('.', '/'))
+            query_clauses.append(self._get_boosted_term(term))
+          else:
+            name, val = term.split(':')
+            if val:
+              if name == 'type':
+                term = '%s:%s' % (name, val.upper().strip('*'))
+                default_entity_types = entity_types # Make sure type value still makes sense for the source
+              user_filters.append(term + '*') # Manual filter allowed e.g. type:VIE* ca
+
+        filter_query = '*'
+
+        if query_clauses:
+          filter_query = 'OR'.join(['(%s)' % clause for clause in query_clauses])
+
+        user_filter_clause = 'AND '.join(['(%s)' % f for f in user_filters]) or '*'
+        source_filter_clause = 'OR'.join(['(%s:%s)' % ('type', entity_type) for entity_type in default_entity_types])
+
+        if 's3' in sources:
+          source_type_filter.append('sourceType:s3')
+        elif 'sql' in sources or 'hive' in sources or 'impala' in sources:
+          source_type_filter.append('sourceType:HIVE OR sourceType:IMPALA')
+
+        filter_query = '%s AND (%s) AND (%s)' % (filter_query, user_filter_clause, source_filter_clause)
+        if source_type_filter:
+          filter_query += ' AND (%s)' % 'OR '.join(source_type_filter)
+
+        source_ids = get_cluster_source_ids(self)
+        if source_ids:
+          filter_query = source_ids + '(' + filter_query + ')'
+      else:
+        filter_query = query_s
+
+      params += (
+        ('query', filter_query),
+        ('offset', offset),
+        ('limit', NAVIGATOR.FETCH_SIZE_SEARCH.get()),
+      )
+
+      LOG.info(params)
+      response = self._root.get('entities', headers=self.__headers, params=params)
+
+      response = list(islice(self._secure_results(response), limit)) # Apply Sentry perms
+
+      return response
+    except RestException, e:
+      LOG.error('Failed to search for entities with search query: %s' % query_s)
+      if e.code == 401:
+        raise CatalogAuthException(_('Failed to authenticate.'))
+      else:
+        raise CatalogApiException(e)
 
 
   def _secure_results(self, results, checker=None):
+    # TODO: to move directly to Catalog API
     if NAVIGATOR.APPLY_SENTRY_PERMISSIONS.get():
       checker = get_checker(self.user, checker)
       action = 'SELECT'
@@ -341,7 +394,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to search for entities with search query: %s' % prefix
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def find_entity(self, source_type, type, name, **filters):
@@ -380,15 +433,15 @@ class NavigatorApi(object):
       response = self._root.get('entities', headers=self.__headers, params=params)
 
       if not response:
-        raise EntityDoesNotExistException('Could not find entity with query filters: %s' % str(query_filters))
+        raise CatalogEntityDoesNotExistException('Could not find entity with query filters: %s' % str(query_filters))
       elif len(response) > 1:
-        raise NavigatorApiException('Found more than 1 entity with query filters: %s' % str(query_filters))
+        raise CatalogApiException('Found more than 1 entity with query filters: %s' % str(query_filters))
 
       return response[0]
     except RestException, e:
       msg = 'Failed to find entity: %s' % str(e)
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def get_entity(self, entity_id):
@@ -401,7 +454,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to get entity %s: %s' % (entity_id, str(e))
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def update_entity(self, entity, **metadata):
@@ -419,11 +472,12 @@ class NavigatorApi(object):
       }
       properties.update(metadata)
       data = json.dumps(properties)
+
       return self._root.put('entities/%(identity)s' % entity, params=self.__params, data=data, contenttype=_JSON_CONTENT_TYPE, allow_redirects=True, clear_cookies=True)
     except RestException, e:
       msg = 'Failed to update entity %s: %s' % (entity['identity'], e)
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def get_cluster_source_ids(self):
@@ -436,34 +490,6 @@ class NavigatorApi(object):
     return self._root.get('entities', headers=self.__headers, params=params)
 
 
-  def get_database(self, name):
-    return self.find_entity(source_type='HIVE', type='DATABASE', name=name)
-
-
-  def get_table(self, database_name, table_name, is_view=False):
-    parent_path = '\/%s' % database_name
-    return self.find_entity(source_type='HIVE', type='VIEW' if is_view else 'TABLE', name=table_name, parentPath=parent_path)
-
-
-  def get_field(self, database_name, table_name, field_name):
-    parent_path = '\/%s\/%s' % (database_name, table_name)
-    return self.find_entity(source_type='HIVE', type='FIELD', name=field_name, parentPath=parent_path)
-
-
-  def get_partition(self, database_name, table_name, partition_spec):
-    raise NotImplementedError
-
-
-  def get_directory(self, path):
-    dir_name, dir_path = self._clean_path(path)
-    return self.find_entity(source_type='HDFS', type='DIRECTORY', name=dir_name, fileSystemPath=dir_path)
-
-
-  def get_file(self, path):
-    file_name, file_path = self._clean_path(path)
-    return self.find_entity(source_type='HDFS', type='FILE', name=file_name, fileSystemPath=file_path)
-
-
   def add_tags(self, entity_id, tags):
     entity = self.get_entity(entity_id)
     new_tags = entity['tags'] or []
@@ -518,7 +544,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to get lineage for entity ID %s: %s' % (entity_id, str(e))
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def create_namespace(self, namespace, description=None):
@@ -528,7 +554,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to create namespace: %s' % namespace
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def get_namespace(self, namespace):
@@ -537,7 +563,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to get namespace: %s' % namespace
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def create_namespace_property(self, namespace, properties):
@@ -547,7 +573,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to create namespace %s property' % namespace
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def get_namespace_properties(self, namespace):
@@ -556,7 +582,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to create namespace %s property' % namespace
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def map_namespace_property(self, clazz, properties):
@@ -566,7 +592,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to map class %s property' % clazz
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def get_model_properties_mapping(self):
@@ -575,7 +601,7 @@ class NavigatorApi(object):
     except RestException, e:
       msg = 'Failed to get models properties mappings'
       LOG.error(msg)
-      raise NavigatorApiException(e.message)
+      raise CatalogApiException(e.message)
 
 
   def _fillup_properties(self):

+ 1 - 1
desktop/libs/metadata/src/metadata/navigator_client_tests.py → desktop/libs/metadata/src/metadata/catalog/navigator_client_tests.py

@@ -31,7 +31,7 @@ from libsentry.test_privilege_checker import MockSentryApiV2
 
 from metadata.conf import NAVIGATOR
 from metadata.metadata_sites import get_navigator_hue_server_name
-from metadata.navigator_client import NavigatorApi
+from metadata.catalog.navigator_client import NavigatorApi
 
 
 LOG = logging.getLogger(__name__)

+ 89 - 142
desktop/libs/metadata/src/metadata/navigator_api.py → desktop/libs/metadata/src/metadata/catalog_api.py

@@ -33,9 +33,9 @@ from django.views.decorators.http import require_POST
 from desktop.lib.django_util import JsonResponse
 from desktop.lib.i18n import force_unicode, smart_str
 
+from metadata.catalog.base import get_api
+from metadata.catalog.navigator_client import CatalogApiException, CatalogEntityDoesNotExistException, CatalogAuthException
 from metadata.conf import has_navigator, NAVIGATOR, has_navigator_file_search
-from metadata.navigator_client import NavigatorApi, NavigatorApiException, EntityDoesNotExistException,\
-  NavigathorAuthException
 
 
 LOG = logging.getLogger(__name__)
@@ -59,13 +59,13 @@ def error_handler(view_fn):
         raise MetadataApiException('Navigator API is not configured.')
     except Http404, e:
       raise e
-    except EntityDoesNotExistException, e:
+    except CatalogEntityDoesNotExistException, e:
       response['message'] = e.message
       status = 404
-    except NavigathorAuthException, e:
+    except CatalogAuthException, e:
       response['message'] = force_unicode(e.message)
       status = 403
-    except NavigatorApiException, e:
+    except CatalogApiException, e:
       try:
         response['message'] = json.loads(e.message)
       except Exception:
@@ -79,49 +79,12 @@ def error_handler(view_fn):
   return decorator
 
 
-@error_handler
-def search_entities(request):
-  """
-  For displaying results.
-  """
-  api = NavigatorApi(request.user)
-
-  query_s = json.loads(request.POST.get('query_s', ''))
-  query_s = smart_str(query_s)
-
-  offset = request.POST.get('offset', 0)
-  limit = int(request.POST.get('limit', 100))
-  raw_query = request.POST.get('raw_query', False)
-  sources = json.loads(request.POST.get('sources') or '[]')
-  if sources and not has_navigator_file_search(request.user):
-    sources = ['sql']
-
-  query_s = query_s.strip() or '*'
-
-  entities = api.search_entities(query_s, limit=limit, offset=offset, raw_query=raw_query, sources=sources)
-
-  if not raw_query:
-    _augment_highlighting(query_s, entities)
-
-  response = {
-    'entities': entities,
-    'count': len(entities),
-    'offset': offset,
-    'limit': limit,
-    'query_s': query_s,
-    'status': 0
-  }
-
-  return JsonResponse(response)
-
-
 @error_handler
 def search_entities_interactive(request):
   """
   For search autocomplete.
   """
-  api = NavigatorApi(request.user)
-
+  interface = request.POST.get('interface', 'navigator')
   query_s = json.loads(request.POST.get('query_s', ''))
   prefix = request.POST.get('prefix')
   offset = request.POST.get('offset', 0)
@@ -129,96 +92,16 @@ def search_entities_interactive(request):
   field_facets = json.loads(request.POST.get('field_facets') or '[]')
   sources = json.loads(request.POST.get('sources') or '[]')
 
+  api = get_api(request=request, interface=interface)
+
   if sources and not has_navigator_file_search(request.user):
     sources = ['sql']
 
-  f = {
-      "outputFormat" : {
-        "type" : "dynamic"
-      },
-      "name" : {
-        "type" : "dynamic"
-      },
-      "lastModified" : {
-        "type" : "date"
-      },
-      "sourceType" : {
-        "type" : "dynamic"
-      },
-      "parentPath" : {
-        "type" : "dynamic"
-      },
-      "lastAccessed" : {
-        "type" : "date"
-      },
-      "type" : {
-        "type" : "dynamic"
-      },
-      "sourceId" : {
-        "type" : "dynamic"
-      },
-      "partitionColNames" : {
-        "type" : "dynamic"
-      },
-      "serDeName" : {
-        "type" : "dynamic"
-      },
-      "created" : {
-        "type" : "date"
-      },
-      "fileSystemPath" : {
-        "type" : "dynamic"
-      },
-      "compressed" : {
-        "type" : "bool"
-      },
-      "clusteredByColNames" : {
-        "type" : "dynamic"
-      },
-      "originalName" : {
-        "type" : "dynamic"
-      },
-      "owner" : {
-        "type" : "dynamic"
-      },
-      "extractorRunId" : {
-        "type" : "dynamic"
-      },
-      "userEntity" : {
-        "type" : "bool"
-      },
-      "sortByColNames" : {
-        "type" : "dynamic"
-      },
-      "inputFormat" : {
-        "type" : "dynamic"
-      },
-      "serDeLibName" : {
-        "type" : "dynamic"
-      },
-      "originalDescription" : {
-        "type" : "dynamic"
-      },
-      "lastModifiedBy" : {
-        "type" : "dynamic"
-      }
-    }
-
-  auto_field_facets = ["tags", "type"] + f.keys()
-  query_s = query_s.strip() + '*'
-
-  last_query_term = [term for term in query_s.split()][-1]
-
-  if last_query_term and last_query_term != '*':
-    last_query_term = last_query_term.rstrip('*')
-    (fname, fval) = last_query_term.split(':') if ':' in last_query_term else (last_query_term, '')
-    auto_field_facets = [f for f in auto_field_facets if f.startswith(fname)]
-
   response = api.search_entities_interactive(
       query_s=query_s,
       limit=limit,
       offset=offset,
-      facetFields=field_facets or auto_field_facets[:5],
+      facetFields=field_facets,
       facetPrefix=prefix,
       facetRanges=None,
       firstClassEntitiesOnly=None,
@@ -243,6 +126,44 @@ def search_entities_interactive(request):
   return JsonResponse(response)
 
 
+#  Not used currently.
+@error_handler
+def search_entities(request):
+  """
+  For displaying results.
+  """
+  interface = request.POST.get('interface', 'navigator')
+  query_s = json.loads(request.POST.get('query_s', ''))
+  query_s = smart_str(query_s)
+
+  offset = request.POST.get('offset', 0)
+  limit = int(request.POST.get('limit', 100))
+  raw_query = request.POST.get('raw_query', False)
+  sources = json.loads(request.POST.get('sources') or '[]')
+  if sources and not has_navigator_file_search(request.user):
+    sources = ['sql']
+
+  query_s = query_s.strip() or '*'
+
+  api = get_api(request=request, interface=interface)
+
+  entities = api.search_entities(query_s, limit=limit, offset=offset, raw_query=raw_query, sources=sources)
+
+  if not raw_query:
+    _augment_highlighting(query_s, entities)
+
+  response = {
+    'entities': entities,
+    'count': len(entities),
+    'offset': offset,
+    'limit': limit,
+    'query_s': query_s,
+    'status': 0
+  }
+
+  return JsonResponse(response)
+
+
 def _augment_highlighting(query_s, records):
   fs = {}
   ts = []
@@ -301,12 +222,13 @@ def _highlight_tags(record, term):
 
 @error_handler
 def list_tags(request):
-  api = NavigatorApi(request.user)
-
+  interface = request.POST.get('interface', 'navigator')
   prefix = request.POST.get('prefix')
   offset = request.POST.get('offset', 0)
   limit = request.POST.get('limit', 25)
 
+  api = get_api(request=request, interface=interface)
+
   data = api.search_entities_interactive(facetFields=['tags'], facetPrefix=prefix, limit=limit, offset=offset)
 
   response = {
@@ -321,14 +243,15 @@ def list_tags(request):
 def find_entity(request):
   response = {'status': -1}
 
-  api = NavigatorApi(request.user)
-
+  interface = request.GET.get('interface', 'navigator')
   entity_type = request.GET.get('type', '')
   database = request.GET.get('database', '')
   table = request.GET.get('table', '')
   name = request.GET.get('name', '')
   path = request.GET.get('path', '')
 
+  api = get_api(request=request, interface=interface)
+
   if not entity_type:
     raise MetadataApiException("find_entity requires a type value, e.g. - 'database', 'table', 'file'")
 
@@ -368,9 +291,11 @@ def find_entity(request):
 def suggest(request):
   response = {'status': -1}
 
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   prefix = request.POST.get('prefix')
 
+  api = get_api(request=request, interface=interface)
+
   suggest = api.suggest(prefix)
 
   response['suggest'] = suggest
@@ -383,9 +308,11 @@ def suggest(request):
 def get_entity(request):
   response = {'status': -1}
 
-  api = NavigatorApi(request.user)
+  interface = request.GET.get('interface', 'navigator')
   entity_id = request.GET.get('id')
 
+  api = get_api(request=request, interface=interface)
+
   if not entity_id:
     raise MetadataApiException("get_entity requires an 'id' parameter")
 
@@ -400,10 +327,12 @@ def get_entity(request):
 @require_POST
 @error_handler
 def add_tags(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   entity_id = json.loads(request.POST.get('id', '""'))
   tags = json.loads(request.POST.get('tags', "[]"))
 
+  api = get_api(request=request, interface=interface)
+
   is_allowed = request.user.has_hue_permission(action='write', app='metadata')
 
   request.audit = {
@@ -425,10 +354,12 @@ def add_tags(request):
 @require_POST
 @error_handler
 def delete_tags(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   entity_id = json.loads(request.POST.get('id', '""'))
   tags = json.loads(request.POST.get('tags', '[]'))
 
+  api = get_api(request=request, interface=interface)
+
   is_allowed = request.user.has_hue_permission(action='write', app='metadata')
 
   request.audit = {
@@ -450,12 +381,14 @@ def delete_tags(request):
 @require_POST
 @error_handler
 def update_properties(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   entity_id = json.loads(request.POST.get('id', '""'))
   properties = json.loads(request.POST.get('properties', '{}')) # Entity properties
   modified_custom_metadata = json.loads(request.POST.get('modifiedCustomMetadata', '{}')) # Aka "Custom Metadata"
   deleted_custom_metadata_keys = json.loads(request.POST.get('deletedCustomMetadataKeys', '[]'))
 
+  api = get_api(request=request, interface=interface)
+
   is_allowed = request.user.has_hue_permission(action='write', app='metadata')
 
   request.audit = {
@@ -481,10 +414,12 @@ def update_properties(request):
 def delete_metadata_properties(request):
   response = {'status': -1}
 
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   entity_id = json.loads(request.POST.get('id', '""'))
   keys = json.loads(request.POST.get('keys', '[]'))
 
+  api = get_api(request=request, interface=interface)
+
   is_allowed = request.user.has_hue_permission(action='write', app='metadata')
 
   request.audit = {
@@ -506,9 +441,11 @@ def delete_metadata_properties(request):
 def get_lineage(request):
   response = {'status': -1, 'inputs': [], 'source_query': '', 'target_queries': [], 'targets': []}
 
-  api = NavigatorApi(request.user)
+  interface = request.GET.get('interface', 'navigator')
   entity_id = request.GET.get('id')
 
+  api = get_api(request=request, interface=interface)
+
   if not entity_id:
     raise MetadataApiException("get_lineage requires an 'id' parameter")
 
@@ -536,10 +473,12 @@ def get_lineage(request):
 
 @error_handler
 def create_namespace(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   namespace = request.POST.get('namespace')
   description = request.POST.get('description')
 
+  api = get_api(request=request, interface=interface)
+
   request.audit = {
     'allowed': request.user.has_hue_permission(action='write', app='metadata'),
     'operation': 'NAVIGATOR_CREATE_NAMESPACE',
@@ -553,9 +492,11 @@ def create_namespace(request):
 
 @error_handler
 def get_namespace(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   namespace = request.POST.get('namespace')
 
+  api = get_api(request=request, interface=interface)
+
   namespace = api.get_namespace(namespace)
 
   return JsonResponse(namespace)
@@ -576,10 +517,12 @@ def create_namespace_property(request):
   "type" : "TEXT",
   "createdDate" : "2018-04-02T22:36:19.001Z"
 }"""
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   namespace = request.POST.get('namespace')
   properties = json.loads(request.POST.get('properties', '{}'))
 
+  api = get_api(request=request, interface=interface)
+
   namespace = api.create_namespace_property(namespace, properties)
 
   return JsonResponse(namespace)
@@ -592,10 +535,12 @@ def map_namespace_property(request):
   namespace: "huecatalog",
   name: "relatedEntities"
   }"""
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
   clazz = request.POST.get('class')
   properties = json.loads(request.POST.get('properties', '[]'))
 
+  api = get_api(request=request, interface=interface)
+
   namespace = api.map_namespace_property(clazz=clazz, properties=properties)
 
   return JsonResponse(namespace)
@@ -603,7 +548,9 @@ def map_namespace_property(request):
 
 @error_handler
 def get_model_properties_mapping(request):
-  api = NavigatorApi(request.user)
+  interface = request.POST.get('interface', 'navigator')
+
+  api = get_api(request=request, interface=interface)
 
   namespace = api.get_model_properties_mapping()
 

+ 2 - 2
desktop/libs/metadata/src/metadata/navigator_tests.py → desktop/libs/metadata/src/metadata/catalog_tests.py

@@ -32,8 +32,8 @@ from hadoop.pseudo_hdfs4 import is_live_cluster
 
 from metadata import conf
 from metadata.conf import has_navigator, NAVIGATOR, get_navigator_auth_password, get_navigator_auth_username
-from metadata.navigator_api import _augment_highlighting
-from metadata.navigator_client import NavigatorApi
+from metadata.catalog_api import _augment_highlighting
+from metadata.catalog.navigator_client import NavigatorApi
 
 
 LOG = logging.getLogger(__name__)

+ 3 - 3
desktop/libs/metadata/src/metadata/manager_api.py

@@ -35,7 +35,7 @@ from desktop.lib.django_util import JsonResponse
 from desktop.lib.i18n import force_unicode
 
 from metadata.conf import has_navigator
-from metadata.navigator_client import NavigatorApiException
+from metadata.catalog.navigator_client import CatalogApiException
 
 
 LOG = logging.getLogger(__name__)
@@ -52,8 +52,8 @@ def error_handler(view_fn):
       if has_navigator(args[0].user): # TODO
         return view_fn(*args, **kwargs)
       else:
-        raise NavigatorApiException('Navigator API is not configured.')
-    except NavigatorApiException, e:
+        raise CatalogApiException('Navigator API is not configured.')
+    except CatalogApiException, e:
       try:
         response['message'] = json.loads(e.message)
       except Exception:

+ 27 - 13
desktop/libs/metadata/src/metadata/urls.py

@@ -16,24 +16,38 @@
 # limitations under the License.
 
 from django.conf.urls import url
-from metadata import navigator_api as metadata_navigator_api
+from metadata import catalog_api as metadata_catalog_api
 from metadata import optimizer_api as metadata_optimizer_api
 from metadata import workload_analytics_api as metadata_workload_analytics_api
 from metadata import manager_api as metadata_manager_api
 
-# Navigator API
+# Catalog
 urlpatterns = [
-  url(r'^api/navigator/search_entities/?$', metadata_navigator_api.search_entities, name='search_entities'),
-  url(r'^api/navigator/search_entities_interactive/?$', metadata_navigator_api.search_entities_interactive, name='search_entities_interactive'),
-  url(r'^api/navigator/find_entity/?$', metadata_navigator_api.find_entity, name='find_entity'),
-  url(r'^api/navigator/get_entity/?$', metadata_navigator_api.get_entity, name='get_entity'),
-  url(r'^api/navigator/add_tags/?$', metadata_navigator_api.add_tags, name='add_tags'),
-  url(r'^api/navigator/delete_tags/?$', metadata_navigator_api.delete_tags, name='delete_tags'),
-  url(r'^api/navigator/list_tags/?$', metadata_navigator_api.list_tags, name='list_tags'),
-  url(r'^api/navigator/suggest/?$', metadata_navigator_api.suggest, name='suggest'),
-  url(r'^api/navigator/update_properties/?$', metadata_navigator_api.update_properties, name='update_properties'),
-  url(r'^api/navigator/delete_metadata_properties/?$', metadata_navigator_api.delete_metadata_properties, name='delete_metadata_properties'),
-  url(r'^api/navigator/lineage/?$', metadata_navigator_api.get_lineage, name='get_lineage'),
+  url(r'^api/catalog/search_entities/?$', metadata_catalog_api.search_entities, name='search_entities'),
+  url(r'^api/catalog/search_entities_interactive/?$', metadata_catalog_api.search_entities_interactive, name='search_entities_interactive'),
+  url(r'^api/catalog/find_entity/?$', metadata_catalog_api.find_entity, name='find_entity'),
+  url(r'^api/catalog/get_entity/?$', metadata_catalog_api.get_entity, name='get_entity'),
+  url(r'^api/catalog/add_tags/?$', metadata_catalog_api.add_tags, name='add_tags'),
+  url(r'^api/catalog/delete_tags/?$', metadata_catalog_api.delete_tags, name='delete_tags'),
+  url(r'^api/catalog/list_tags/?$', metadata_catalog_api.list_tags, name='list_tags'),
+  url(r'^api/catalog/suggest/?$', metadata_catalog_api.suggest, name='suggest'),
+  url(r'^api/catalog/update_properties/?$', metadata_catalog_api.update_properties, name='update_properties'),
+  url(r'^api/catalog/delete_metadata_properties/?$', metadata_catalog_api.delete_metadata_properties, name='delete_metadata_properties'),
+  url(r'^api/catalog/lineage/?$', metadata_catalog_api.get_lineage, name='get_lineage'),
+]
+# Navigator API (deprecated, renamed to Catalog)
+urlpatterns += [
+  url(r'^api/navigator/search_entities/?$', metadata_catalog_api.search_entities, name='search_entities'),
+  url(r'^api/navigator/search_entities_interactive/?$', metadata_catalog_api.search_entities_interactive, name='search_entities_interactive'),
+  url(r'^api/navigator/find_entity/?$', metadata_catalog_api.find_entity, name='find_entity'),
+  url(r'^api/navigator/get_entity/?$', metadata_catalog_api.get_entity, name='get_entity'),
+  url(r'^api/navigator/add_tags/?$', metadata_catalog_api.add_tags, name='add_tags'),
+  url(r'^api/navigator/delete_tags/?$', metadata_catalog_api.delete_tags, name='delete_tags'),
+  url(r'^api/navigator/list_tags/?$', metadata_catalog_api.list_tags, name='list_tags'),
+  url(r'^api/navigator/suggest/?$', metadata_catalog_api.suggest, name='suggest'),
+  url(r'^api/navigator/update_properties/?$', metadata_catalog_api.update_properties, name='update_properties'),
+  url(r'^api/navigator/delete_metadata_properties/?$', metadata_catalog_api.delete_metadata_properties, name='delete_metadata_properties'),
+  url(r'^api/navigator/lineage/?$', metadata_catalog_api.get_lineage, name='get_lineage'),
 ]
 
 # Optimizer API

+ 88 - 38
docs/sdk/sdk.md

@@ -119,25 +119,12 @@ Here is an example on how the Job Browser can list:
 Here is an example on how the File Browser can list HDFS, S3 files and now [ADLS](https://issues.cloudera.org/browse/HUE-7248).
 
 
-# Hue shell
+# Hue CLI
 
 * [Hue API: Execute some builtin or shell commands](http://gethue.com/hue-api-execute-some-builtin-commands/).
 * [How to manage the Hue database with the shell](http://gethue.com/how-to-manage-the-hue-database-with-the-shell/).
 
 
-# Metadata
-
-The [metadata API](https://github.com/cloudera/hue/tree/master/desktop/libs/metadata).
-
-## Data Catalog
-
-Read more about [Search and Tagging here](https://blog.cloudera.com/blog/2017/05/new-in-cloudera-enterprise-5-11-hue-data-search-and-tagging/).
-
-## Optimization
-
-Read more about the [Query Assistant with Navigator Optimizer Integration
-](https://blog.cloudera.com/blog/2017/08/new-in-cloudera-enterprise-5-12-hue-4-interface-and-query-assistant/).
-
 # New application
 
 Building a brand new application is more work but is ideal for creating a custom solution.
@@ -983,12 +970,70 @@ How to create a new locale for an app::
 
 # API
 
-## Metadata
+## Metadata Catalog
+
+The [metadata API](https://github.com/cloudera/hue/tree/master/desktop/libs/metadata) is powering [Search and Tagging here](http://gethue.com/improved-sql-exploration-in-hue-4-3/) and the [Query Assistant with Navigator Optimizer Integration](http://gethue.com/hue-4-sql-editor-improvements/).
+
+The backends is pluggable by providing alternative [client interfaces](https://github.com/cloudera/hue/tree/master/desktop/libs/metadata/catalog):
+
+* navigator (default)
+* dummy
+
+### Searching for entities
+
+<pre>
+     $.post("/metadata/api/catalog/search_entities_interactive/", {
+        query_s: ko.mapping.toJSON("*sample"),
+        sources: ko.mapping.toJSON(["sql", "hdfs", "s3"]),
+        field_facets: ko.mapping.toJSON([]),
+        limit: 10
+      }, function(data) {
+        console.log(ko.mapping.toJSON(data));
+      });
+</pre>
+
+
+### Searching for entities with the dummy backend
+
+<pre>
+     $.post("/metadata/api/catalog/search_entities_interactive/", {
+        query_s: ko.mapping.toJSON("*sample"),
+        interface: "dummy"
+      }, function(data) {
+        console.log(ko.mapping.toJSON(data));
+      });
+</pre>
+
+
+### Adding a tag with the dummy backend
+
+<pre>
+     $.post("/metadata/api/catalog/add_tags/", {
+        id: "22",
+        tags: ko.mapping.toJSON(["usage"]),
+        interface: "dummy"
+      }, function(data) {
+        console.log(ko.mapping.toJSON(data));
+      });
+</pre>
+
 
 ### Deleting a key/value property
 
 <pre>
-     $.post("/metadata/api/navigator/delete_metadata_properties/", {
+     $.post("/metadata/api/catalog/delete_metadata_properties/", {
+        "id": "32",
+        "keys": ko.mapping.toJSON(["project", "steward"])
+      }, function(data) {
+        console.log(ko.mapping.toJSON(data));
+      });
+</pre>
+
+
+### Deleting a key/value property
+
+<pre>
+     $.post("/metadata/api/catalog/delete_metadata_properties/", {
         "id": "32",
         "keys": ko.mapping.toJSON(["project", "steward"])
       }, function(data) {
@@ -1000,7 +1045,7 @@ How to create a new locale for an app::
 ### Getting the model mapping of custom metadata
 
 <pre>
-     $.get("/metadata/api/navigator/models/properties/mappings/", function(data) {
+     $.get("/metadata/api/catalog/models/properties/mappings/", function(data) {
         console.log(ko.mapping.toJSON(data));
       });
 </pre>
@@ -1009,7 +1054,7 @@ How to create a new locale for an app::
 ### Getting a namespace
 
 <pre>
-     $.post("/metadata/api/navigator/namespace/", {
+     $.post("/metadata/api/catalog/namespace/", {
         namespace: 'huecatalog'
       }, function(data) {
         console.log(ko.mapping.toJSON(data));
@@ -1020,7 +1065,7 @@ How to create a new locale for an app::
 ### Creating a namespace
 
 <pre>
-     $.post("/metadata/api/navigator/namespace/create/", {
+     $.post("/metadata/api/catalog/namespace/create/", {
         "namespace": "huecatalog",
         "description": "my desc"
       }, function(data) {
@@ -1032,7 +1077,7 @@ How to create a new locale for an app::
 ### Creating a namespace property
 
 <pre>
-     $.post("/metadata/api/navigator/namespace/property/create/", {
+     $.post("/metadata/api/catalog/namespace/property/create/", {
         "namespace": "huecatalog",
         "properties": ko.mapping.toJSON({
           "name" : "relatedEntities2",
@@ -1052,7 +1097,7 @@ How to create a new locale for an app::
 ### Map a namespace property to a class entity
 
 <pre>
-     $.post("/metadata/api/navigator/namespace/property/map/", {
+     $.post("/metadata/api/catalog/namespace/property/map/", {
         "class": "hv_view",
         "properties": ko.mapping.toJSON([{
            namespace: "huecatalog",
@@ -1068,21 +1113,18 @@ How to create a new locale for an app::
 ## The short story
 
 Install the mini cluster (only once):
-```
-./tools/jenkins/jenkins.sh slow
-```
+
+    ./tools/jenkins/jenkins.sh slow
 
 Run all the tests:
-```
-build/env/bin/hue test all
-```
+
+    build/env/bin/hue test all
 
 Or just some parts of the tests, e.g.:
-```
-build/env/bin/hue test specific impala
-build/env/bin/hue test specific impala.tests:TestMockedImpala
-build/env/bin/hue test specific impala.tests:TestMockedImpala.test_basic_flow
-```
+
+    build/env/bin/hue test specific impala
+    build/env/bin/hue test specific impala.tests:TestMockedImpala
+    build/env/bin/hue test specific impala.tests:TestMockedImpala.test_basic_flow
 
 Jasmine tests (from your browser):
 
@@ -1105,26 +1147,34 @@ See apps/hello/src/hello/hello_test.py for an example.
 ### Helpful command-line tricks
 
 To run tests that do not depend on Hadoop, use:
-  build/env/bin/hue test fast
+
+    build/env/bin/hue test fast
 
 To run all tests, use:
-  build/env/bin/hue test all
+
+    build/env/bin/hue test all
 
 To run only tests of a particular app, use:
-  build/env/bin/hue test specific <app>
+
+    build/env/bin/hue test specific <app>
+
 E.g.
   build/env/bin/hue test specific filebrowser
 
 To run a specific test, use:
-  build/env/bin/hue test specific <test_func>
+
+    build/env/bin/hue test specific <test_func>
+
 E.g.
   build/env/bin/hue test specific useradmin.tests:test_user_admin
 
 Start up pdb on test failures:
-  build/env/bin/hue test <args> --pdb --pdb-failure -s
+
+    build/env/bin/hue test <args> --pdb --pdb-failure -s
 
 Point to an Impalad and trigger the Impala tests:
-  build/env/bin/hue test impala impalad-01.gethue.com
+
+    build/env/bin/hue test impala impalad-01.gethue.com
 
 
 ### Run the Jasmine tests

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác