Example #1
0
def _executeAction(controllerName, action, session):
	method = session[Constants.HTTP_METHOD];
	
	
	if method == Constants.HTTP_METHOD_GET:
		params = read_request_vars(fp=session[Constants.GET_REQUEST_CONTENT], environ=session, keep_blank_values=True);
	elif method == Constants.HTTP_METHOD_POST:
		params = read_request_vars(fp=session[Constants.POST_REQUEST_CONTENT], environ=session, keep_blank_values=True);
	else:
		raise BadRequestException('Wrong method provided!');
	
	try:
		controllerModule = imp.load_source(controllerName, Constants.CONTROLLERS_FOLDER + controllerName + ".py");
		controller = getattr(controllerModule, controllerName)(params);
		getattr(controller, action)();
	
		loader = FileSystemLoader(Constants.VIEWS_FOLDER + "/" + controllerName);
		env = Environment(loader=loader);
		template = env.select_template([action + '.html', action + '.xml', action + '.json']);
		page = template.render(controller.__dict__);
		headers = [('Content-type', guess_type(template.name)[0])];
	
	except TemplateNotFound:
		raise BadRequestException('Unable to find a view');
	except IOError:
		raise BadRequestException('Unable to find the controller');
		
	return Constants.STATUS_OK, headers, page.encode('utf-8');		
Example #2
0
def build_pages(base_directory):
    print "Building pages in %s" % base_directory
    root_files = glob(os.path.join(base_directory, '*.markdown'))
    pages_built = 0
    if os.path.isdir(os.path.join(base_directory, TEMPLATES_DIRECTORY)):
        print "Found local template directory"
        template_env = Environment(loader=FileSystemLoader(os.path.join(base_directory, TEMPLATES_DIRECTORY)))
    else:
        print "No Template directory found, using default"
        template_env = Environment(loader=PackageLoader('gh-build', 'templates'))
    
    for filename in root_files:
        try:
            template = template_env.get_template(ROOT_TEMPLATE_NAME)
        except TemplateNotFound:
            print "root.html template missing in local directory, falling back to default"
            template_env = Environment(loader=PackageLoader('gh-build', 'templates'))
            template = template_env.get_template(ROOT_TEMPLATE_NAME)
        
        build_page(filename, base_directory, template)
        pages_built += 1
        
    pages_files = glob(os.path.join(base_directory, 'pages', '*.markdown'))
    
    for filename in pages_files:
        build_page(filename, base_directory, template_env.select_template([PAGE_TEMPLATE_NAME, ROOT_TEMPLATE_NAME]))
        pages_built += 1
        
    print "%s pages built" % pages_built
Example #3
0
def _executeAction(controllerName, action, session):
    method = session[Constants.HTTP_METHOD]

    if method == Constants.HTTP_METHOD_GET:
        params = read_request_vars(fp=session[Constants.GET_REQUEST_CONTENT],
                                   environ=session,
                                   keep_blank_values=True)
    elif method == Constants.HTTP_METHOD_POST:
        params = read_request_vars(fp=session[Constants.POST_REQUEST_CONTENT],
                                   environ=session,
                                   keep_blank_values=True)
    else:
        raise BadRequestException('Wrong method provided!')

    try:
        controllerModule = imp.load_source(
            controllerName,
            Constants.CONTROLLERS_FOLDER + controllerName + ".py")
        controller = getattr(controllerModule, controllerName)(params)
        getattr(controller, action)()

        loader = FileSystemLoader(Constants.VIEWS_FOLDER + "/" +
                                  controllerName)
        env = Environment(loader=loader)
        template = env.select_template(
            [action + '.html', action + '.xml', action + '.json'])
        page = template.render(controller.__dict__)
        headers = [('Content-type', guess_type(template.name)[0])]

    except TemplateNotFound:
        raise BadRequestException('Unable to find a view')
    except IOError:
        raise BadRequestException('Unable to find the controller')

    return Constants.STATUS_OK, headers, page.encode('utf-8')
Example #4
0
def render_j2(data, output_file='index.md'):
    env = Environment(loader=PackageLoader(__name__, '.'),
                      autoescape=select_autoescape([]))
    env.filters['linkify'] = linkify
    templ = env.select_template(['index.j2'])
    return templ.render(data=data.items(),
                        timestamp=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
Example #5
0
        def wrapped_f(*args, **kwargs):
            params = f(*args, **kwargs)
            jinja = Environment(
                loader=FileSystemLoader('webserver/templates'),
                extensions=['jinja2.ext.with_']
            )
            jinja.filters['humanize'] = arrow_humanize

            if not filename:
                raise cherrypy.HTTPError(500, "Template not defined")
            else:
                filenames = [filename + '.tlp']  # ..scope royals vs local bastards

            if '__template' in params:
                if isinstance(params['__template'], basestring):
                    filenames.insert(0, params['__template'] + '.tlp')
                else:
                    # Don't use +=, we are leaving it *after* __template for precedence
                    filenames = [s + '.tlp' for s in params['__template']] + filenames

            try:
                t = jinja.select_template(filenames)
            except TemplateNotFound:
                raise cherrypy.HTTPError(404, "Template not found")

            t.globals['timestamp'] = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
            t.globals['session'] = cherrypy.session

            return t.render(**params)
Example #6
0
class Renderer:
    """Class for jinja2 template lookup and rendering."""

    def __init__(self, settings):
        self.env = Environment()
        self.env.filters.update(filters)

        loader = FileSystemLoader(settings["templates"])
        self.env.loader = loader

    def render(self, out, templates, args):
        """Select the first available template and render to `out`."""

        template = self.lookup(templates)
        text = template.render(**args)

        os.makedirs(os.path.dirname(out), exist_ok=True)
        with open(out, "w") as _file:
            _file.write(text)

        return text

    def lookup(self, templates):
        """Lookup a template from the template directory.

        Templates are selected based on `templates` list
        order. Available templates extensions are `html` and
        `xml. `html` files take template selection precendence over
        `xml` files.

        """

        exts = [".html", ".xml"]

        template_files = []
        for f, ext in itertools.product(templates, exts):
            if f is not None:
                template_files.append(str(f) + ext)

        return self.env.select_template(template_files)

    @staticmethod
    def render_from_string(string, args=None):
        """Render a string template with access to custom filters."""

        env = Environment()
        env.filters.update(filters)

        template = env.from_string(string)
        text = template.render(**args) if args else template.render()

        return text
Example #7
0
class TemplateHelper(object):
    def __init__(self, loader):
        self._env = Environment(loader=loader)

    def get_template(self, name):
        filename = '{}.jinja'.format(name)
        return _Template(self._env.get_template(filename))

    def get_customizable_template(self, name, custom_part):
        filename = '{}.jinja'.format(name)
        filename_custom = '{}-{}.jinja'.format(name, custom_part)
        return _Template(self._env.select_template([filename_custom,
                                                    filename]))

    def get_legacy_contexts_conf(self):
        # TODO return contextsconf as an OrderedRawConf like previously...
        pass
Example #8
0
def _render_template(distro, loader, **kwargs):
    env = Environment(
        loader=loader,
        trim_blocks=True,
        lstrip_blocks=True,
    )
    env.filters['dedent'] = textwrap.dedent
    template_name = 'sysprep-{0}.j2'.format(distro)
    template = env.select_template([template_name, 'sysprep-base.j2'])
    sysprep_content = template.render(guestfs_ver=_guestfs_version(), **kwargs)
    with tempfile.NamedTemporaryFile(mode='w', delete=False) as sysprep_file:
        sysprep_file.write('# {0}\n'.format(template.name))
        sysprep_file.write(sysprep_content)

    LOGGER.debug(('Generated sysprep template '
                  'at {0}:\n{1}').format(sysprep_file.name, sysprep_content))
    return sysprep_file.name
Example #9
0
File: sysprep.py Project: nirs/lago
def _render_template(distro, loader, **kwargs):
    env = Environment(
        loader=loader,
        trim_blocks=True,
        lstrip_blocks=True,
    )
    env.filters['dedent'] = textwrap.dedent
    template_name = 'sysprep-{0}.j2'.format(distro)
    template = env.select_template([template_name, 'sysprep-base.j2'])
    sysprep_content = template.render(guestfs_ver=_guestfs_version(), **kwargs)
    with tempfile.NamedTemporaryFile(delete=False) as sysprep_file:
        sysprep_file.write('# {0}\n'.format(template.name))
        sysprep_file.write(sysprep_content)

    LOGGER.debug(
        ('Generated sysprep template '
         'at {0}:\n{1}').format(sysprep_file.name, sysprep_content)
    )
    return sysprep_file.name
Example #10
0
class render_jinja:
    """Rendering interface to Jinja2 Templates

    Example:

        render= render_jinja('templates')
        render.hello(name='jinja2')
        """
    def __init__(self, *a, **kwargs):
        extensions = kwargs.pop('extensions', [])
        globals = kwargs.pop('globals', {})

        from jinja2 import Environment, FileSystemLoader
        self._lookup = Environment(loader=FileSystemLoader(*a, **kwargs),
                                   extensions=extensions)
        self._lookup.globals.update(globals)

    def __getattr__(self, name):
        paths = [name + '.' + ext for ext in ['html', 'xml']]
        t = self._lookup.select_template(paths)
        return t.render
Example #11
0
def render_apache_tomcat_dockerfiles(data,
                                     config,
                                     update_all_versions=False,
                                     force_update=False):
    env = Environment(loader=FileSystemLoader(os.path.abspath('templates')))
    repository_name = config['repository_name']
    base_repositories = config['base_repositories']
    template_files = config['templates']
    registries = config.get('registries')
    common_files = config.get('common_files')

    versions = data['versions'].keys()

    if update_all_versions:
        versions_to_update = versions
    else:
        versions_to_update = filter_latest_versions(
            versions,
            version_constraints=config.get('version_constraints'),
            normalize_version=normalize_version_to_semver)

    base_repository_info_list = get_base_repository_info(config)

    for version in versions_to_update:
        version_files = data['versions'][version]

        for base_repository_info in base_repository_info_list:

            base_repository_full_repo = base_repository_info['full_repo']

            base_repository_tag_groups = base_repository_info['tag_groups']
            base_repository_name = base_repository_info['name']

            for base_repository_tag_group in base_repository_tag_groups:
                base_repository_main_tag = base_repository_tag_group[0]
                base_image_name = base_repository_full_repo + ':' + base_repository_main_tag

                dockerfile_context = os.path.join(
                    os.getcwd(), version,
                    base_repository_name + base_repository_main_tag)

                tags = [version]
                for base_repository_tag in base_repository_tag_group:
                    tags.append(version + '-' + base_repository_name +
                                base_repository_tag)

                version_info = semver.parse_version_info(
                    normalize_version_to_semver(version))

                base_os = re.compile(
                    'centos|alpine|ubuntu|debian|fedora|rhel').search(
                        base_repository_name +
                        base_repository_main_tag).group(0)

                render_data = {
                    'base_image_name': base_image_name,
                    'base_os': base_os,
                    'base_repository_name': base_repository_name,
                    'config': config,
                    'files': version_files,
                    'registries': registries,
                    'repository_name': repository_name,
                    'tags': tags,
                    'version': version,
                    'version_info': version_info,
                }

                pprint(render_data)
                for template_file in template_files:
                    template_filenames = [
                        template_file + '.' + base_os + '.j2',
                        template_file + '.j2'
                    ]

                    template = env.select_template(template_filenames)

                    template_render_path = os.path.join(
                        dockerfile_context, template_file)
                    if not os.path.exists(
                            template_render_path) or force_update:
                        write_file(template_render_path,
                                   template.render(render_data))
                        print 'Rendered template: ' + template_render_path

                for common_file in common_files:
                    common_file_path = os.path.join(dockerfile_context,
                                                    common_file)
                    if not os.path.exists(common_file_path) or force_update:
                        shutil.copy2(common_file, common_file_path)
                        print 'Copied file: ' + common_file_path
Example #12
0
class Datasette:
    # Message constants:
    INFO = 1
    WARNING = 2
    ERROR = 3

    def __init__(
        self,
        files,
        immutables=None,
        cache_headers=True,
        cors=False,
        inspect_data=None,
        metadata=None,
        sqlite_extensions=None,
        template_dir=None,
        plugins_dir=None,
        static_mounts=None,
        memory=False,
        config=None,
        secret=None,
        version_note=None,
        config_dir=None,
    ):
        assert config_dir is None or isinstance(
            config_dir, Path), "config_dir= should be a pathlib.Path"
        self._secret = secret or secrets.token_hex(32)
        self.files = tuple(files) + tuple(immutables or [])
        if config_dir:
            self.files += tuple([str(p) for p in config_dir.glob("*.db")])
        if (config_dir and (config_dir / "inspect-data.json").exists()
                and not inspect_data):
            inspect_data = json.load((config_dir / "inspect-data.json").open())
            if immutables is None:
                immutable_filenames = [
                    i["file"] for i in inspect_data.values()
                ]
                immutables = [
                    f for f in self.files
                    if Path(f).name in immutable_filenames
                ]
        self.inspect_data = inspect_data
        self.immutables = set(immutables or [])
        if not self.files:
            self.files = [MEMORY]
        elif memory:
            self.files = (MEMORY, ) + self.files
        self.databases = collections.OrderedDict()
        for file in self.files:
            path = file
            is_memory = False
            if file is MEMORY:
                path = None
                is_memory = True
            is_mutable = path not in self.immutables
            db = Database(self,
                          path,
                          is_mutable=is_mutable,
                          is_memory=is_memory)
            if db.name in self.databases:
                raise Exception("Multiple files with same stem: {}".format(
                    db.name))
            self.add_database(db.name, db)
        self.cache_headers = cache_headers
        self.cors = cors
        metadata_files = []
        if config_dir:
            metadata_files = [
                config_dir / filename
                for filename in ("metadata.json", "metadata.yaml",
                                 "metadata.yml")
                if (config_dir / filename).exists()
            ]
        if config_dir and metadata_files and not metadata:
            with metadata_files[0].open() as fp:
                metadata = parse_metadata(fp.read())
        self._metadata = metadata or {}
        self.sqlite_functions = []
        self.sqlite_extensions = sqlite_extensions or []
        if config_dir and (config_dir /
                           "templates").is_dir() and not template_dir:
            template_dir = str((config_dir / "templates").resolve())
        self.template_dir = template_dir
        if config_dir and (config_dir /
                           "plugins").is_dir() and not plugins_dir:
            plugins_dir = str((config_dir / "plugins").resolve())
        self.plugins_dir = plugins_dir
        if config_dir and (config_dir /
                           "static").is_dir() and not static_mounts:
            static_mounts = [("static", str(
                (config_dir / "static").resolve()))]
        self.static_mounts = static_mounts or []
        if config_dir and (config_dir / "config.json").exists() and not config:
            config = json.load((config_dir / "config.json").open())
        self._config = dict(DEFAULT_CONFIG, **(config or {}))
        self.renderers = {
        }  # File extension -> (renderer, can_render) functions
        self.version_note = version_note
        self.executor = futures.ThreadPoolExecutor(
            max_workers=self.config("num_sql_threads"))
        self.max_returned_rows = self.config("max_returned_rows")
        self.sql_time_limit_ms = self.config("sql_time_limit_ms")
        self.page_size = self.config("default_page_size")
        # Execute plugins in constructor, to ensure they are available
        # when the rest of `datasette inspect` executes
        if self.plugins_dir:
            for filename in os.listdir(self.plugins_dir):
                filepath = os.path.join(self.plugins_dir, filename)
                mod = module_from_path(filepath, name=filename)
                try:
                    pm.register(mod)
                except ValueError:
                    # Plugin already registered
                    pass

        # Configure Jinja
        default_templates = str(app_root / "datasette" / "templates")
        template_paths = []
        if self.template_dir:
            template_paths.append(self.template_dir)
        plugin_template_paths = [
            plugin["templates_path"] for plugin in get_plugins()
            if plugin["templates_path"]
        ]
        template_paths.extend(plugin_template_paths)
        template_paths.append(default_templates)
        template_loader = ChoiceLoader([
            FileSystemLoader(template_paths),
            # Support {% extends "default:table.html" %}:
            PrefixLoader({"default": FileSystemLoader(default_templates)},
                         delimiter=":"),
        ])
        self.jinja_env = Environment(loader=template_loader,
                                     autoescape=True,
                                     enable_async=True)
        self.jinja_env.filters["escape_css_string"] = escape_css_string
        self.jinja_env.filters[
            "quote_plus"] = lambda u: urllib.parse.quote_plus(u)
        self.jinja_env.filters["escape_sqlite"] = escape_sqlite
        self.jinja_env.filters["to_css_class"] = to_css_class
        # pylint: disable=no-member
        pm.hook.prepare_jinja2_environment(env=self.jinja_env)

        self._register_renderers()
        self._permission_checks = collections.deque(maxlen=200)
        self._root_token = secrets.token_hex(32)

    async def invoke_startup(self):
        for hook in pm.hook.startup(datasette=self):
            if callable(hook):
                hook = hook()
            if asyncio.iscoroutine(hook):
                hook = await hook

    def sign(self, value, namespace="default"):
        return URLSafeSerializer(self._secret, namespace).dumps(value)

    def unsign(self, signed, namespace="default"):
        return URLSafeSerializer(self._secret, namespace).loads(signed)

    def get_database(self, name=None):
        if name is None:
            return next(iter(self.databases.values()))
        return self.databases[name]

    def add_database(self, name, db):
        self.databases[name] = db

    def remove_database(self, name):
        self.databases.pop(name)

    def config(self, key):
        return self._config.get(key, None)

    def config_dict(self):
        # Returns a fully resolved config dictionary, useful for templates
        return {
            option.name: self.config(option.name)
            for option in CONFIG_OPTIONS
        }

    def metadata(self, key=None, database=None, table=None, fallback=True):
        """
        Looks up metadata, cascading backwards from specified level.
        Returns None if metadata value is not found.
        """
        assert not (
            database is None and table is not None
        ), "Cannot call metadata() with table= specified but not database="
        databases = self._metadata.get("databases") or {}
        search_list = []
        if database is not None:
            search_list.append(databases.get(database) or {})
        if table is not None:
            table_metadata = ((databases.get(database) or {}).get("tables")
                              or {}).get(table) or {}
            search_list.insert(0, table_metadata)
        search_list.append(self._metadata)
        if not fallback:
            # No fallback allowed, so just use the first one in the list
            search_list = search_list[:1]
        if key is not None:
            for item in search_list:
                if key in item:
                    return item[key]
            return None
        else:
            # Return the merged list
            m = {}
            for item in search_list:
                m.update(item)
            return m

    def plugin_config(self,
                      plugin_name,
                      database=None,
                      table=None,
                      fallback=True):
        "Return config for plugin, falling back from specified database/table"
        plugins = self.metadata("plugins",
                                database=database,
                                table=table,
                                fallback=fallback)
        if plugins is None:
            return None
        plugin_config = plugins.get(plugin_name)
        # Resolve any $file and $env keys
        plugin_config = resolve_env_secrets(plugin_config, os.environ)
        return plugin_config

    def app_css_hash(self):
        if not hasattr(self, "_app_css_hash"):
            self._app_css_hash = hashlib.sha1(
                open(os.path.join(str(app_root),
                                  "datasette/static/app.css")).read().encode(
                                      "utf8")).hexdigest()[:6]
        return self._app_css_hash

    async def get_canned_queries(self, database_name, actor):
        queries = self.metadata(
            "queries", database=database_name, fallback=False) or {}
        for more_queries in pm.hook.canned_queries(
                datasette=self,
                database=database_name,
                actor=actor,
        ):
            if callable(more_queries):
                more_queries = more_queries()
            if asyncio.iscoroutine(more_queries):
                more_queries = await more_queries
            queries.update(more_queries or {})
        # Fix any {"name": "select ..."} queries to be {"name": {"sql": "select ..."}}
        for key in queries:
            if not isinstance(queries[key], dict):
                queries[key] = {"sql": queries[key]}
            # Also make sure "name" is available:
            queries[key]["name"] = key
        return queries

    async def get_canned_query(self, database_name, query_name, actor):
        queries = await self.get_canned_queries(database_name, actor)
        query = queries.get(query_name)
        if query:
            return query

    def update_with_inherited_metadata(self, metadata):
        # Fills in source/license with defaults, if available
        metadata.update({
            "source":
            metadata.get("source") or self.metadata("source"),
            "source_url":
            metadata.get("source_url") or self.metadata("source_url"),
            "license":
            metadata.get("license") or self.metadata("license"),
            "license_url":
            metadata.get("license_url") or self.metadata("license_url"),
            "about":
            metadata.get("about") or self.metadata("about"),
            "about_url":
            metadata.get("about_url") or self.metadata("about_url"),
        })

    def _prepare_connection(self, conn, database):
        conn.row_factory = sqlite3.Row
        conn.text_factory = lambda x: str(x, "utf-8", "replace")
        for name, num_args, func in self.sqlite_functions:
            conn.create_function(name, num_args, func)
        if self.sqlite_extensions:
            conn.enable_load_extension(True)
            for extension in self.sqlite_extensions:
                conn.execute("SELECT load_extension('{}')".format(extension))
        if self.config("cache_size_kb"):
            conn.execute("PRAGMA cache_size=-{}".format(
                self.config("cache_size_kb")))
        # pylint: disable=no-member
        pm.hook.prepare_connection(conn=conn,
                                   database=database,
                                   datasette=self)

    def add_message(self, request, message, type=INFO):
        if not hasattr(request, "_messages"):
            request._messages = []
            request._messages_should_clear = False
        request._messages.append((message, type))

    def _write_messages_to_response(self, request, response):
        if getattr(request, "_messages", None):
            # Set those messages
            response.set_cookie("ds_messages",
                                self.sign(request._messages, "messages"))
        elif getattr(request, "_messages_should_clear", False):
            response.set_cookie("ds_messages", "", expires=0, max_age=0)

    def _show_messages(self, request):
        if getattr(request, "_messages", None):
            request._messages_should_clear = True
            messages = request._messages
            request._messages = []
            return messages
        else:
            return []

    async def permission_allowed(self,
                                 actor,
                                 action,
                                 resource=None,
                                 default=False):
        "Check permissions using the permissions_allowed plugin hook"
        result = None
        for check in pm.hook.permission_allowed(
                datasette=self,
                actor=actor,
                action=action,
                resource=resource,
        ):
            if callable(check):
                check = check()
            if asyncio.iscoroutine(check):
                check = await check
            if check is not None:
                result = check
        used_default = False
        if result is None:
            result = default
            used_default = True
        self._permission_checks.append({
            "when":
            datetime.datetime.utcnow().isoformat(),
            "actor":
            actor,
            "action":
            action,
            "resource":
            resource,
            "used_default":
            used_default,
            "result":
            result,
        })
        return result

    async def execute(
        self,
        db_name,
        sql,
        params=None,
        truncate=False,
        custom_time_limit=None,
        page_size=None,
        log_sql_errors=True,
    ):
        return await self.databases[db_name].execute(
            sql,
            params=params,
            truncate=truncate,
            custom_time_limit=custom_time_limit,
            page_size=page_size,
            log_sql_errors=log_sql_errors,
        )

    async def expand_foreign_keys(self, database, table, column, values):
        "Returns dict mapping (column, value) -> label"
        labeled_fks = {}
        db = self.databases[database]
        foreign_keys = await db.foreign_keys_for_table(table)
        # Find the foreign_key for this column
        try:
            fk = [
                foreign_key for foreign_key in foreign_keys
                if foreign_key["column"] == column
            ][0]
        except IndexError:
            return {}
        label_column = await db.label_column_for_table(fk["other_table"])
        if not label_column:
            return {(fk["column"], value): str(value) for value in values}
        labeled_fks = {}
        sql = """
            select {other_column}, {label_column}
            from {other_table}
            where {other_column} in ({placeholders})
        """.format(
            other_column=escape_sqlite(fk["other_column"]),
            label_column=escape_sqlite(label_column),
            other_table=escape_sqlite(fk["other_table"]),
            placeholders=", ".join(["?"] * len(set(values))),
        )
        try:
            results = await self.execute(database, sql, list(set(values)))
        except QueryInterrupted:
            pass
        else:
            for id, value in results:
                labeled_fks[(fk["column"], id)] = value
        return labeled_fks

    def absolute_url(self, request, path):
        url = urllib.parse.urljoin(request.url, path)
        if url.startswith("http://") and self.config("force_https_urls"):
            url = "https://" + url[len("http://"):]
        return url

    def _register_custom_units(self):
        "Register any custom units defined in the metadata.json with Pint"
        for unit in self.metadata("custom_units") or []:
            ureg.define(unit)

    def _connected_databases(self):
        return [{
            "name": d.name,
            "path": d.path,
            "size": d.size,
            "is_mutable": d.is_mutable,
            "is_memory": d.is_memory,
            "hash": d.hash,
        } for d in sorted(self.databases.values(), key=lambda d: d.name)]

    def _versions(self):
        conn = sqlite3.connect(":memory:")
        self._prepare_connection(conn, ":memory:")
        sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
        sqlite_extensions = {}
        for extension, testsql, hasversion in (
            ("json1", "SELECT json('{}')", False),
            ("spatialite", "SELECT spatialite_version()", True),
        ):
            try:
                result = conn.execute(testsql)
                if hasversion:
                    sqlite_extensions[extension] = result.fetchone()[0]
                else:
                    sqlite_extensions[extension] = None
            except Exception:
                pass
        # Figure out supported FTS versions
        fts_versions = []
        for fts in ("FTS5", "FTS4", "FTS3"):
            try:
                conn.execute(
                    "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(
                        fts=fts))
                fts_versions.append(fts)
            except sqlite3.OperationalError:
                continue
        datasette_version = {"version": __version__}
        if self.version_note:
            datasette_version["note"] = self.version_note
        return {
            "python": {
                "version": ".".join(map(str, sys.version_info[:3])),
                "full": sys.version,
            },
            "datasette": datasette_version,
            "asgi": "3.0",
            "uvicorn": uvicorn.__version__,
            "sqlite": {
                "version":
                sqlite_version,
                "fts_versions":
                fts_versions,
                "extensions":
                sqlite_extensions,
                "compile_options": [
                    r[0] for r in conn.execute(
                        "pragma compile_options;").fetchall()
                ],
            },
        }

    def _plugins(self, request=None, all=False):
        ps = list(get_plugins())
        should_show_all = False
        if request is not None:
            should_show_all = request.args.get("all")
        else:
            should_show_all = all
        if not should_show_all:
            ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
        return [{
            "name": p["name"],
            "static": p["static_path"] is not None,
            "templates": p["templates_path"] is not None,
            "version": p.get("version"),
            "hooks": p["hooks"],
        } for p in ps]

    def _threads(self):
        threads = list(threading.enumerate())
        d = {
            "num_threads":
            len(threads),
            "threads": [{
                "name": t.name,
                "ident": t.ident,
                "daemon": t.daemon
            } for t in threads],
        }
        # Only available in Python 3.7+
        if hasattr(asyncio, "all_tasks"):
            tasks = asyncio.all_tasks()
            d.update({
                "num_tasks": len(tasks),
                "tasks": [_cleaner_task_str(t) for t in tasks],
            })
        return d

    def _actor(self, request):
        return {"actor": request.actor}

    def table_metadata(self, database, table):
        "Fetch table-specific metadata."
        return ((self.metadata("databases")
                 or {}).get(database, {}).get("tables", {}).get(table, {}))

    def _register_renderers(self):
        """ Register output renderers which output data in custom formats. """
        # Built-in renderers
        self.renderers["json"] = (json_renderer, lambda: True)

        # Hooks
        hook_renderers = []
        # pylint: disable=no-member
        for hook in pm.hook.register_output_renderer(datasette=self):
            if type(hook) == list:
                hook_renderers += hook
            else:
                hook_renderers.append(hook)

        for renderer in hook_renderers:
            self.renderers[renderer["extension"]] = (
                # It used to be called "callback" - remove this in Datasette 1.0
                renderer.get("render") or renderer["callback"],
                renderer.get("can_render") or (lambda: True),
            )

    async def render_template(self,
                              templates,
                              context=None,
                              request=None,
                              view_name=None):
        context = context or {}
        if isinstance(templates, Template):
            template = templates
        else:
            if isinstance(templates, str):
                templates = [templates]
            template = self.jinja_env.select_template(templates)
        body_scripts = []
        # pylint: disable=no-member
        for script in pm.hook.extra_body_script(
                template=template.name,
                database=context.get("database"),
                table=context.get("table"),
                view_name=view_name,
                datasette=self,
        ):
            body_scripts.append(Markup(script))

        extra_template_vars = {}
        # pylint: disable=no-member
        for extra_vars in pm.hook.extra_template_vars(
                template=template.name,
                database=context.get("database"),
                table=context.get("table"),
                view_name=view_name,
                request=request,
                datasette=self,
        ):
            if callable(extra_vars):
                extra_vars = extra_vars()
            if asyncio.iscoroutine(extra_vars):
                extra_vars = await extra_vars
            assert isinstance(extra_vars,
                              dict), "extra_vars is of type {}".format(
                                  type(extra_vars))
            extra_template_vars.update(extra_vars)

        template_context = {
            **context,
            **{
                "app_css_hash":
                self.app_css_hash(),
                "zip":
                zip,
                "body_scripts":
                body_scripts,
                "format_bytes":
                format_bytes,
                "show_messages":
                lambda: self._show_messages(request),
                "extra_css_urls":
                self._asset_urls("extra_css_urls", template, context),
                "extra_js_urls":
                self._asset_urls("extra_js_urls", template, context),
                "base_url":
                self.config("base_url"),
                "csrftoken":
                request.scope["csrftoken"] if request else lambda: "",
            },
            **extra_template_vars,
        }
        if request and request.args.get("_context") and self.config(
                "template_debug"):
            return "<pre>{}</pre>".format(
                jinja2.escape(
                    json.dumps(template_context, default=repr, indent=4)))

        return await template.render_async(template_context)

    def _asset_urls(self, key, template, context):
        # Flatten list-of-lists from plugins:
        seen_urls = set()
        for url_or_dict in itertools.chain(
                itertools.chain.from_iterable(
                    getattr(pm.hook, key)(
                        template=template.name,
                        database=context.get("database"),
                        table=context.get("table"),
                        datasette=self,
                    )),
            (self.metadata(key) or []),
        ):
            if isinstance(url_or_dict, dict):
                url = url_or_dict["url"]
                sri = url_or_dict.get("sri")
            else:
                url = url_or_dict
                sri = None
            if url in seen_urls:
                continue
            seen_urls.add(url)
            if sri:
                yield {"url": url, "sri": sri}
            else:
                yield {"url": url}

    def app(self):
        "Returns an ASGI app function that serves the whole of Datasette"
        routes = []

        for routes_to_add in pm.hook.register_routes():
            for regex, view_fn in routes_to_add:
                routes.append((regex, wrap_view(view_fn, self)))

        def add_route(view, regex):
            routes.append((regex, view))

        # Generate a regex snippet to match all registered renderer file extensions
        renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())

        add_route(IndexView.as_view(self), r"/(?P<as_format>(\.jsono?)?$)")
        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
        add_route(favicon, "/favicon.ico")

        add_route(asgi_static(app_root / "datasette" / "static"),
                  r"/-/static/(?P<path>.*)$")
        for path, dirname in self.static_mounts:
            add_route(asgi_static(dirname), r"/" + path + "/(?P<path>.*)$")

        # Mount any plugin static/ directories
        for plugin in get_plugins():
            if plugin["static_path"]:
                add_route(
                    asgi_static(plugin["static_path"]),
                    "/-/static-plugins/{}/(?P<path>.*)$".format(
                        plugin["name"]),
                )
                # Support underscores in name in addition to hyphens, see https://github.com/simonw/datasette/issues/611
                add_route(
                    asgi_static(plugin["static_path"]),
                    "/-/static-plugins/{}/(?P<path>.*)$".format(
                        plugin["name"].replace("-", "_")),
                )
        add_route(
            JsonDataView.as_view(self, "metadata.json",
                                 lambda: self._metadata),
            r"/-/metadata(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self, "versions.json", self._versions),
            r"/-/versions(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self,
                                 "plugins.json",
                                 self._plugins,
                                 needs_request=True),
            r"/-/plugins(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self, "config.json", lambda: self._config),
            r"/-/config(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self, "threads.json", self._threads),
            r"/-/threads(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self, "databases.json",
                                 self._connected_databases),
            r"/-/databases(?P<as_format>(\.json)?)$",
        )
        add_route(
            JsonDataView.as_view(self,
                                 "actor.json",
                                 self._actor,
                                 needs_request=True),
            r"/-/actor(?P<as_format>(\.json)?)$",
        )
        add_route(
            AuthTokenView.as_view(self),
            r"/-/auth-token$",
        )
        add_route(
            LogoutView.as_view(self),
            r"/-/logout$",
        )
        add_route(
            PermissionsDebugView.as_view(self),
            r"/-/permissions$",
        )
        add_route(
            MessagesDebugView.as_view(self),
            r"/-/messages$",
        )
        add_route(
            PatternPortfolioView.as_view(self),
            r"/-/patterns$",
        )
        add_route(DatabaseDownload.as_view(self),
                  r"/(?P<db_name>[^/]+?)(?P<as_db>\.db)$")
        add_route(
            DatabaseView.as_view(self),
            r"/(?P<db_name>[^/]+?)(?P<as_format>" + renderer_regex +
            r"|.jsono|\.csv)?$",
        )
        add_route(
            TableView.as_view(self),
            r"/(?P<db_name>[^/]+)/(?P<table_and_format>[^/]+?$)",
        )
        add_route(
            RowView.as_view(self),
            r"/(?P<db_name>[^/]+)/(?P<table>[^/]+?)/(?P<pk_path>[^/]+?)(?P<as_format>"
            + renderer_regex + r")?$",
        )
        self._register_custom_units()

        async def setup_db():
            # First time server starts up, calculate table counts for immutable databases
            for dbname, database in self.databases.items():
                if not database.is_mutable:
                    await database.table_counts(limit=60 * 60 * 1000)

        asgi = AsgiLifespan(
            AsgiTracer(
                asgi_csrf.asgi_csrf(
                    DatasetteRouter(self, routes),
                    signing_secret=self._secret,
                    cookie_name="ds_csrftoken",
                )),
            on_startup=setup_db,
        )
        for wrapper in pm.hook.asgi_wrapper(datasette=self):
            asgi = wrapper(asgi)
        return asgi
Example #13
0
class Datasette:
    def __init__(
        self,
        files,
        immutables=None,
        cache_headers=True,
        cors=False,
        inspect_data=None,
        metadata=None,
        sqlite_extensions=None,
        template_dir=None,
        plugins_dir=None,
        static_mounts=None,
        memory=False,
        config=None,
        version_note=None,
    ):
        immutables = immutables or []
        self.files = tuple(files) + tuple(immutables)
        self.immutables = set(immutables)
        if not self.files:
            self.files = [MEMORY]
        elif memory:
            self.files = (MEMORY, ) + self.files
        self.databases = {}
        self.inspect_data = inspect_data
        for file in self.files:
            path = file
            is_memory = False
            if file is MEMORY:
                path = None
                is_memory = True
            is_mutable = path not in self.immutables
            db = ConnectedDatabase(self,
                                   path,
                                   is_mutable=is_mutable,
                                   is_memory=is_memory)
            if db.name in self.databases:
                raise Exception("Multiple files with same stem: {}".format(
                    db.name))
            self.databases[db.name] = db
        self.cache_headers = cache_headers
        self.cors = cors
        self._metadata = metadata or {}
        self.sqlite_functions = []
        self.sqlite_extensions = sqlite_extensions or []
        self.template_dir = template_dir
        self.plugins_dir = plugins_dir
        self.static_mounts = static_mounts or []
        self._config = dict(DEFAULT_CONFIG, **(config or {}))
        self.renderers = {}  # File extension -> renderer function
        self.version_note = version_note
        self.executor = futures.ThreadPoolExecutor(
            max_workers=self.config("num_sql_threads"))
        self.max_returned_rows = self.config("max_returned_rows")
        self.sql_time_limit_ms = self.config("sql_time_limit_ms")
        self.page_size = self.config("default_page_size")
        # Execute plugins in constructor, to ensure they are available
        # when the rest of `datasette inspect` executes
        if self.plugins_dir:
            for filename in os.listdir(self.plugins_dir):
                filepath = os.path.join(self.plugins_dir, filename)
                mod = module_from_path(filepath, name=filename)
                try:
                    pm.register(mod)
                except ValueError:
                    # Plugin already registered
                    pass

    async def run_sanity_checks(self):
        # Only one check right now, for Spatialite
        for database_name, database in self.databases.items():
            # Run pragma_info on every table
            for table in await database.table_names():
                try:
                    await self.execute(
                        database_name,
                        "PRAGMA table_info({});".format(escape_sqlite(table)),
                    )
                except sqlite3.OperationalError as e:
                    if e.args[0] == "no such module: VirtualSpatialIndex":
                        raise click.UsageError(
                            "It looks like you're trying to load a SpatiaLite"
                            " database without first loading the SpatiaLite module."
                            "\n\nRead more: https://datasette.readthedocs.io/en/latest/spatialite.html"
                        )
                    else:
                        raise

    def config(self, key):
        return self._config.get(key, None)

    def config_dict(self):
        # Returns a fully resolved config dictionary, useful for templates
        return {
            option.name: self.config(option.name)
            for option in CONFIG_OPTIONS
        }

    def metadata(self, key=None, database=None, table=None, fallback=True):
        """
        Looks up metadata, cascading backwards from specified level.
        Returns None if metadata value is not found.
        """
        assert not (
            database is None and table is not None
        ), "Cannot call metadata() with table= specified but not database="
        databases = self._metadata.get("databases") or {}
        search_list = []
        if database is not None:
            search_list.append(databases.get(database) or {})
        if table is not None:
            table_metadata = ((databases.get(database) or {}).get("tables")
                              or {}).get(table) or {}
            search_list.insert(0, table_metadata)
        search_list.append(self._metadata)
        if not fallback:
            # No fallback allowed, so just use the first one in the list
            search_list = search_list[:1]
        if key is not None:
            for item in search_list:
                if key in item:
                    return item[key]
            return None
        else:
            # Return the merged list
            m = {}
            for item in search_list:
                m.update(item)
            return m

    def plugin_config(self,
                      plugin_name,
                      database=None,
                      table=None,
                      fallback=True):
        "Return config for plugin, falling back from specified database/table"
        plugins = self.metadata("plugins",
                                database=database,
                                table=table,
                                fallback=fallback)
        if plugins is None:
            return None
        return plugins.get(plugin_name)

    def app_css_hash(self):
        if not hasattr(self, "_app_css_hash"):
            self._app_css_hash = hashlib.sha1(
                open(os.path.join(str(app_root),
                                  "datasette/static/app.css")).read().encode(
                                      "utf8")).hexdigest()[:6]
        return self._app_css_hash

    def get_canned_queries(self, database_name):
        queries = self.metadata(
            "queries", database=database_name, fallback=False) or {}
        names = queries.keys()
        return [self.get_canned_query(database_name, name) for name in names]

    def get_canned_query(self, database_name, query_name):
        queries = self.metadata(
            "queries", database=database_name, fallback=False) or {}
        query = queries.get(query_name)
        if query:
            if not isinstance(query, dict):
                query = {"sql": query}
            query["name"] = query_name
            return query

    async def get_table_definition(self, database_name, table, type_="table"):
        table_definition_rows = list(await self.execute(
            database_name,
            "select sql from sqlite_master where name = :n and type=:t",
            {
                "n": table,
                "t": type_
            },
        ))
        if not table_definition_rows:
            return None
        return table_definition_rows[0][0]

    def get_view_definition(self, database_name, view):
        return self.get_table_definition(database_name, view, "view")

    def update_with_inherited_metadata(self, metadata):
        # Fills in source/license with defaults, if available
        metadata.update({
            "source":
            metadata.get("source") or self.metadata("source"),
            "source_url":
            metadata.get("source_url") or self.metadata("source_url"),
            "license":
            metadata.get("license") or self.metadata("license"),
            "license_url":
            metadata.get("license_url") or self.metadata("license_url"),
            "about":
            metadata.get("about") or self.metadata("about"),
            "about_url":
            metadata.get("about_url") or self.metadata("about_url"),
        })

    def prepare_connection(self, conn):
        conn.row_factory = sqlite3.Row
        conn.text_factory = lambda x: str(x, "utf-8", "replace")
        for name, num_args, func in self.sqlite_functions:
            conn.create_function(name, num_args, func)
        if self.sqlite_extensions:
            conn.enable_load_extension(True)
            for extension in self.sqlite_extensions:
                conn.execute("SELECT load_extension('{}')".format(extension))
        if self.config("cache_size_kb"):
            conn.execute("PRAGMA cache_size=-{}".format(
                self.config("cache_size_kb")))
        # pylint: disable=no-member
        pm.hook.prepare_connection(conn=conn)

    async def table_exists(self, database, table):
        results = await self.execute(
            database,
            "select 1 from sqlite_master where type='table' and name=?",
            params=(table, ),
        )
        return bool(results.rows)

    async def expand_foreign_keys(self, database, table, column, values):
        "Returns dict mapping (column, value) -> label"
        labeled_fks = {}
        foreign_keys = await self.foreign_keys_for_table(database, table)
        # Find the foreign_key for this column
        try:
            fk = [
                foreign_key for foreign_key in foreign_keys
                if foreign_key["column"] == column
            ][0]
        except IndexError:
            return {}
        label_column = await self.label_column_for_table(
            database, fk["other_table"])
        if not label_column:
            return {(fk["column"], value): str(value) for value in values}
        labeled_fks = {}
        sql = """
            select {other_column}, {label_column}
            from {other_table}
            where {other_column} in ({placeholders})
        """.format(
            other_column=escape_sqlite(fk["other_column"]),
            label_column=escape_sqlite(label_column),
            other_table=escape_sqlite(fk["other_table"]),
            placeholders=", ".join(["?"] * len(set(values))),
        )
        try:
            results = await self.execute(database, sql, list(set(values)))
        except InterruptedError:
            pass
        else:
            for id, value in results:
                labeled_fks[(fk["column"], id)] = value
        return labeled_fks

    def absolute_url(self, request, path):
        url = urllib.parse.urljoin(request.url, path)
        if url.startswith("http://") and self.config("force_https_urls"):
            url = "https://" + url[len("http://"):]
        return url

    def register_custom_units(self):
        "Register any custom units defined in the metadata.json with Pint"
        for unit in self.metadata("custom_units") or []:
            ureg.define(unit)

    def connected_databases(self):
        return [{
            "name": d.name,
            "path": d.path,
            "size": d.size,
            "is_mutable": d.is_mutable,
            "is_memory": d.is_memory,
            "hash": d.hash,
        } for d in sorted(self.databases.values(), key=lambda d: d.name)]

    def versions(self):
        conn = sqlite3.connect(":memory:")
        self.prepare_connection(conn)
        sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
        sqlite_extensions = {}
        for extension, testsql, hasversion in (
            ("json1", "SELECT json('{}')", False),
            ("spatialite", "SELECT spatialite_version()", True),
        ):
            try:
                result = conn.execute(testsql)
                if hasversion:
                    sqlite_extensions[extension] = result.fetchone()[0]
                else:
                    sqlite_extensions[extension] = None
            except Exception:
                pass
        # Figure out supported FTS versions
        fts_versions = []
        for fts in ("FTS5", "FTS4", "FTS3"):
            try:
                conn.execute(
                    "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(
                        fts=fts))
                fts_versions.append(fts)
            except sqlite3.OperationalError:
                continue
        datasette_version = {"version": __version__}
        if self.version_note:
            datasette_version["note"] = self.version_note
        return {
            "python": {
                "version": ".".join(map(str, sys.version_info[:3])),
                "full": sys.version,
            },
            "datasette": datasette_version,
            "sqlite": {
                "version":
                sqlite_version,
                "fts_versions":
                fts_versions,
                "extensions":
                sqlite_extensions,
                "compile_options": [
                    r[0] for r in conn.execute(
                        "pragma compile_options;").fetchall()
                ],
            },
        }

    def plugins(self, show_all=False):
        ps = list(get_plugins(pm))
        if not show_all:
            ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
        return [{
            "name": p["name"],
            "static": p["static_path"] is not None,
            "templates": p["templates_path"] is not None,
            "version": p.get("version"),
        } for p in ps]

    def table_metadata(self, database, table):
        "Fetch table-specific metadata."
        return ((self.metadata("databases")
                 or {}).get(database, {}).get("tables", {}).get(table, {}))

    async def table_columns(self, db_name, table):
        return await self.execute_against_connection_in_thread(
            db_name, lambda conn: table_columns(conn, table))

    async def foreign_keys_for_table(self, database, table):
        return await self.execute_against_connection_in_thread(
            database, lambda conn: get_outbound_foreign_keys(conn, table))

    async def label_column_for_table(self, db_name, table):
        explicit_label_column = self.table_metadata(db_name,
                                                    table).get("label_column")
        if explicit_label_column:
            return explicit_label_column
        # If a table has two columns, one of which is ID, then label_column is the other one
        column_names = await self.table_columns(db_name, table)
        if column_names and len(column_names) == 2 and "id" in column_names:
            return [c for c in column_names if c != "id"][0]
        # Couldn't find a label:
        return None

    async def execute_against_connection_in_thread(self, db_name, fn):
        def in_thread():
            conn = getattr(connections, db_name, None)
            if not conn:
                db = self.databases[db_name]
                if db.is_memory:
                    conn = sqlite3.connect(":memory:")
                else:
                    # mode=ro or immutable=1?
                    if db.is_mutable:
                        qs = "mode=ro"
                    else:
                        qs = "immutable=1"
                    conn = sqlite3.connect(
                        "file:{}?{}".format(db.path, qs),
                        uri=True,
                        check_same_thread=False,
                    )
                self.prepare_connection(conn)
                setattr(connections, db_name, conn)
            return fn(conn)

        return await asyncio.get_event_loop().run_in_executor(
            self.executor, in_thread)

    async def execute(
        self,
        db_name,
        sql,
        params=None,
        truncate=False,
        custom_time_limit=None,
        page_size=None,
        log_sql_errors=True,
    ):
        """Executes sql against db_name in a thread"""
        page_size = page_size or self.page_size

        def sql_operation_in_thread(conn):
            time_limit_ms = self.sql_time_limit_ms
            if custom_time_limit and custom_time_limit < time_limit_ms:
                time_limit_ms = custom_time_limit

            with sqlite_timelimit(conn, time_limit_ms):
                try:
                    cursor = conn.cursor()
                    cursor.execute(sql, params or {})
                    max_returned_rows = self.max_returned_rows
                    if max_returned_rows == page_size:
                        max_returned_rows += 1
                    if max_returned_rows and truncate:
                        rows = cursor.fetchmany(max_returned_rows + 1)
                        truncated = len(rows) > max_returned_rows
                        rows = rows[:max_returned_rows]
                    else:
                        rows = cursor.fetchall()
                        truncated = False
                except sqlite3.OperationalError as e:
                    if e.args == ("interrupted", ):
                        raise InterruptedError(e, sql, params)
                    if log_sql_errors:
                        print(
                            "ERROR: conn={}, sql = {}, params = {}: {}".format(
                                conn, repr(sql), params, e))
                    raise

            if truncate:
                return Results(rows, truncated, cursor.description)

            else:
                return Results(rows, False, cursor.description)

        with trace("sql", database=db_name, sql=sql.strip(), params=params):
            results = await self.execute_against_connection_in_thread(
                db_name, sql_operation_in_thread)
        return results

    def register_renderers(self):
        """ Register output renderers which output data in custom formats. """
        # Built-in renderers
        self.renderers["json"] = json_renderer

        # Hooks
        hook_renderers = []
        for hook in pm.hook.register_output_renderer(datasette=self):
            if type(hook) == list:
                hook_renderers += hook
            else:
                hook_renderers.append(hook)

        for renderer in hook_renderers:
            self.renderers[renderer["extension"]] = renderer["callback"]

    def app(self):
        class TracingSanic(Sanic):
            async def handle_request(self, request, write_callback,
                                     stream_callback):
                if request.args.get("_trace"):
                    request["traces"] = []
                    request["trace_start"] = time.time()
                    with capture_traces(request["traces"]):
                        await super().handle_request(request, write_callback,
                                                     stream_callback)
                else:
                    await super().handle_request(request, write_callback,
                                                 stream_callback)

        app = TracingSanic(__name__)
        default_templates = str(app_root / "datasette" / "templates")
        template_paths = []
        if self.template_dir:
            template_paths.append(self.template_dir)
        template_paths.extend([
            plugin["templates_path"] for plugin in get_plugins(pm)
            if plugin["templates_path"]
        ])
        template_paths.append(default_templates)
        template_loader = ChoiceLoader([
            FileSystemLoader(template_paths),
            # Support {% extends "default:table.html" %}:
            PrefixLoader({"default": FileSystemLoader(default_templates)},
                         delimiter=":"),
        ])
        self.jinja_env = Environment(loader=template_loader, autoescape=True)
        self.jinja_env.filters["escape_css_string"] = escape_css_string
        self.jinja_env.filters[
            "quote_plus"] = lambda u: urllib.parse.quote_plus(u)
        self.jinja_env.filters["escape_sqlite"] = escape_sqlite
        self.jinja_env.filters["to_css_class"] = to_css_class
        # pylint: disable=no-member
        pm.hook.prepare_jinja2_environment(env=self.jinja_env)

        self.register_renderers()
        # Generate a regex snippet to match all registered renderer file extensions
        renderer_regex = "|".join(r"\." + key for key in self.renderers.keys())

        app.add_route(IndexView.as_view(self), r"/<as_format:(\.jsono?)?$>")
        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
        app.add_route(favicon, "/favicon.ico")
        app.static("/-/static/", str(app_root / "datasette" / "static"))
        for path, dirname in self.static_mounts:
            app.static(path, dirname)
        # Mount any plugin static/ directories
        for plugin in get_plugins(pm):
            if plugin["static_path"]:
                modpath = "/-/static-plugins/{}/".format(plugin["name"])
                app.static(modpath, plugin["static_path"])
        app.add_route(
            JsonDataView.as_view(self, "metadata.json",
                                 lambda: self._metadata),
            r"/-/metadata<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "versions.json", self.versions),
            r"/-/versions<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "plugins.json", self.plugins),
            r"/-/plugins<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "config.json", lambda: self._config),
            r"/-/config<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "databases.json",
                                 self.connected_databases),
            r"/-/databases<as_format:(\.json)?$>",
        )
        app.add_route(DatabaseDownload.as_view(self),
                      r"/<db_name:[^/]+?><as_db:(\.db)$>")
        app.add_route(
            DatabaseView.as_view(self),
            r"/<db_name:[^/]+?><as_format:(" + renderer_regex +
            r"|.jsono|\.csv)?$>",
        )
        app.add_route(TableView.as_view(self),
                      r"/<db_name:[^/]+>/<table_and_format:[^/]+?$>")
        app.add_route(
            RowView.as_view(self),
            r"/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_format:(" +
            renderer_regex + r")?$>",
        )
        self.register_custom_units()

        # On 404 with a trailing slash redirect to path without that slash:
        # pylint: disable=unused-variable
        @app.middleware("response")
        def redirect_on_404_with_trailing_slash(request, original_response):
            if original_response.status == 404 and request.path.endswith("/"):
                path = request.path.rstrip("/")
                if request.query_string:
                    path = "{}?{}".format(path, request.query_string)
                return response.redirect(path)

        @app.middleware("response")
        async def add_traces_to_response(request, response):
            if request.get("traces") is None:
                return
            traces = request["traces"]
            trace_info = {
                "request_duration_ms":
                1000 * (time.time() - request["trace_start"]),
                "sum_trace_duration_ms":
                sum(t["duration_ms"] for t in traces),
                "num_traces":
                len(traces),
                "traces":
                traces,
            }
            if "text/html" in response.content_type and b"</body>" in response.body:
                extra = json.dumps(trace_info, indent=2)
                extra_html = "<pre>{}</pre></body>".format(extra).encode(
                    "utf8")
                response.body = response.body.replace(b"</body>", extra_html)
            elif "json" in response.content_type and response.body.startswith(
                    b"{"):
                data = json.loads(response.body.decode("utf8"))
                if "_trace" not in data:
                    data["_trace"] = trace_info
                    response.body = json.dumps(data).encode("utf8")

        @app.exception(Exception)
        def on_exception(request, exception):
            title = None
            help = None
            if isinstance(exception, NotFound):
                status = 404
                info = {}
                message = exception.args[0]
            elif isinstance(exception, InvalidUsage):
                status = 405
                info = {}
                message = exception.args[0]
            elif isinstance(exception, DatasetteError):
                status = exception.status
                info = exception.error_dict
                message = exception.message
                if exception.messagge_is_html:
                    message = Markup(message)
                title = exception.title
            else:
                status = 500
                info = {}
                message = str(exception)
                traceback.print_exc()
            templates = ["500.html"]
            if status != 500:
                templates = ["{}.html".format(status)] + templates
            info.update({
                "ok": False,
                "error": message,
                "status": status,
                "title": title
            })
            if request is not None and request.path.split("?")[0].endswith(
                    ".json"):
                r = response.json(info, status=status)

            else:
                template = self.jinja_env.select_template(templates)
                r = response.html(template.render(info), status=status)
            if self.cors:
                r.headers["Access-Control-Allow-Origin"] = "*"
            return r

        # First time server starts up, calculate table counts for immutable databases
        @app.listener("before_server_start")
        async def setup_db(app, loop):
            for dbname, database in self.databases.items():
                if not database.is_mutable:
                    await database.table_counts(limit=60 * 60 * 1000)

        return app
Example #14
0
class AbstractGenerator(ABC, metaclass=GeneratorMeta):
    """
    Abstract base class for code generators based on Jinja2 template engine.

    :param schema: the source or the instance of the XSD schema.
    :param searchpath: additional search path for custom templates. \
    If provided the search path has priority over searchpaths defined \
    in generator class.
    :param types_map: a dictionary with custom mapping for XSD types.
    """
    formal_language = None
    """The formal language associated to the code generator (eg. Python)."""

    searchpaths = None
    """
    Directory paths for searching templates, specified with a list or a tuple.
    Each path must be provided as relative from the directory of the module
    where the class is defined. Extends the searchpath defined in base classes.
    """

    builtin_types = {
        'anyType': '',
        'anySimpleType': '',
    }
    """
    Translation map for XSD builtin types. Updates the builtin_types
    defined in base classes.
    """
    def __init__(self, schema, searchpath=None, types_map=None):
        if isinstance(schema, xmlschema.XMLSchemaBase):
            self.schema = schema
        else:
            self.schema = xmlschema.XMLSchema11(schema)

        file_loaders = []
        if searchpath:
            file_loaders.append(FileSystemLoader(searchpath))
        if self.searchpaths:
            file_loaders.extend(
                FileSystemLoader(str(path))
                for path in reversed(self.searchpaths))
        if not file_loaders:
            raise ValueError("no search paths defined!")
        loader = ChoiceLoader(
            file_loaders) if len(file_loaders) > 1 else file_loaders[0]

        self.types_map = self.builtin_types.copy()
        if types_map:
            if not self.schema.target_namespace:
                self.types_map.update(types_map)
            else:
                ns_part = '{%s}' % self.schema.target_namespace
                self.types_map.update(
                    (ns_part + k, v) for k, v in types_map.items())

        self.filters = {}
        self.tests = {}
        for name in filter(lambda x: callable(getattr(self, x)), dir(self)):
            method = getattr(self, name)
            if inspect.isfunction(method):
                # static methods
                if getattr(method, 'is_filter', False):
                    self.filters[name] = method
                elif getattr(method, 'is_test', False):
                    self.tests[name] = method
            elif inspect.isroutine(method) and hasattr(method, '__func__'):
                # class and instance methods
                if getattr(method.__func__, 'is_filter', False):
                    self.filters[name] = method
                elif getattr(method.__func__, 'is_test', False):
                    self.tests[name] = method

        type_mapping_filter = '{}_type'.format(
            self.formal_language).lower().replace(' ', '_')
        if type_mapping_filter not in self.filters:
            self.filters[type_mapping_filter] = self.map_type

        self._env = Environment(loader=loader)
        self._env.filters.update(self.filters)
        self._env.tests.update(self.tests)

    def __repr__(self):
        if self.schema.url:
            return '%s(schema=%r)' % (self.__class__.__name__,
                                      self.schema.name)
        return '%s(namespace=%r)' % (self.__class__.__name__,
                                     self.schema.target_namespace)

    def list_templates(self, extensions=None, filter_func=None):
        return self._env.list_templates(extensions, filter_func)

    def matching_templates(self, name):
        return self._env.list_templates(filter_func=lambda x: fnmatch(x, name))

    def get_template(self, name, parent=None, global_vars=None):
        return self._env.get_template(name, parent, global_vars)

    def select_template(self, names, parent=None, global_vars=None):
        return self._env.select_template(names, parent, global_vars)

    def render(self, names, parent=None, global_vars=None):
        if isinstance(names, str):
            names = [names]
        elif not all(isinstance(x, str) for x in names):
            raise TypeError("'names' argument must contain only strings!")

        results = []
        for name in names:
            try:
                template = self._env.get_template(name, parent, global_vars)
            except TemplateNotFound as err:
                logger.debug("name %r: %s", name, str(err))
            except TemplateAssertionError as err:
                logger.warning("template %r: %s", name, str(err))
            else:
                results.append(template.render(schema=self.schema))
        return results

    def render_to_files(self,
                        names,
                        parent=None,
                        global_vars=None,
                        output_dir='.',
                        force=False):
        if isinstance(names, str):
            names = [names]
        elif not all(isinstance(x, str) for x in names):
            raise TypeError("'names' argument must contain only strings!")

        template_names = []
        for name in names:
            if is_shell_wildcard(name):
                template_names.extend(self.matching_templates(name))
            else:
                template_names.append(name)

        output_dir = Path(output_dir)
        rendered = []

        for name in template_names:
            try:
                template = self._env.get_template(name, parent, global_vars)
            except TemplateNotFound as err:
                logger.debug("name %r: %s", name, str(err))
            except TemplateAssertionError as err:
                logger.warning("template %r: %s", name, str(err))
            else:
                output_file = output_dir.joinpath(
                    Path(name).name).with_suffix('')
                if not force and output_file.exists():
                    continue

                result = template.render(schema=self.schema)
                logger.info("write file %r", str(output_file))
                with open(output_file, 'w') as fp:
                    fp.write(result)
                rendered.append(template.filename)

        return rendered

    def map_type(self, obj):
        """
        Maps an XSD type to a type declaration of the target language.
        This method is registered as filter with a name dependant from
        the language name (eg. c_type).

        :param obj: an XSD type or another type-related declaration as \
        an attribute or an element.
        :return: an empty string for non-XSD objects.
        """
        if isinstance(obj, XsdType):
            xsd_type = obj
        elif isinstance(obj, (XsdAttribute, XsdElement)):
            xsd_type = obj.type
        else:
            return ''

        try:
            return self.types_map[xsd_type.name]
        except KeyError:
            try:
                return self.types_map[xsd_type.base_type.name]
            except (KeyError, AttributeError):
                if xsd_type.is_complex():
                    return self.types_map[xsd_qname('anyType')]
                else:
                    return self.types_map[xsd_qname('anySimpleType')]

    @staticmethod
    @filter_method
    def name(obj, unnamed='none'):
        """
        Get the unqualified name of the provided object. Invalid
        chars for identifiers are replaced by an underscore.

        :param obj: an XSD object or a named object or a string.
        :param unnamed: value for unnamed objects. Defaults to 'none'.
        :return: str
        """
        try:
            name = obj.local_name
        except AttributeError:
            try:
                obj = obj.name
            except AttributeError:
                pass

            if not isinstance(obj, str):
                return unnamed

            try:
                if obj[0] == '{':
                    _, name = obj.split('}')
                elif ':' in obj:
                    prefix, name = obj.split(':')
                    if NCNAME_PATTERN.match(prefix) is None:
                        return ''
                else:
                    name = obj
            except (IndexError, ValueError):
                return ''
        else:
            if not isinstance(name, str):
                return ''

        if NCNAME_PATTERN.match(name) is None:
            return unnamed
        return name.replace('.', '_').replace('-', '_')

    @filter_method
    def qname(self, obj, unnamed='none', sep='__'):
        """
        Get the QName of the provided object. Invalid chars for
        identifiers are replaced by an underscore.

        :param obj: an XSD object or a named object or a string.
        :param unnamed: value for unnamed objects. Defaults to 'none'.
        :param sep: the replacement for colon. Defaults to double underscore.
        :return: str
        """
        try:
            qname = obj.prefixed_name
        except AttributeError:
            try:
                obj = obj.name
            except AttributeError:
                pass

            if not isinstance(obj, str):
                return unnamed

            try:
                if obj[0] == '{':
                    namespace, local_name = obj.split('}')
                    for prefix, uri in self.schema.namespaces.items():
                        if uri == namespace:
                            qname = '%s:%s' % (uri, local_name)
                            break
                    else:
                        qname = local_name
                else:
                    qname = obj
            except (IndexError, ValueError):
                return unnamed

        if not qname or QNAME_PATTERN.match(qname) is None:
            return unnamed
        return qname.replace('.', '_').replace('-', '_').replace(':', sep)

    @staticmethod
    @filter_method
    def namespace(obj):
        try:
            namespace = obj.target_namespace
        except AttributeError:
            try:
                obj = obj.name
            except AttributeError:
                pass

            try:
                if not isinstance(obj, str) or obj[0] != '{':
                    return ''
                namespace, _ = obj.split('}')
            except (IndexError, ValueError):
                return ''
        else:
            if not isinstance(namespace, str):
                return ''
        return namespace

    @staticmethod
    @filter_method
    def type_name(obj, suffix=None, unnamed='none'):
        """
        Get the unqualified name of the XSD type. Invalid
        chars for identifiers are replaced by an underscore.

        :param obj: an instance of (XsdType|XsdAttribute|XsdElement).
        :param suffix: force a suffix. For default removes '_type' or 'Type' suffixes.
        :param unnamed: value for unnamed XSD types. Defaults to 'none'.
        :return: str
        """
        if isinstance(obj, XsdType):
            name = obj.local_name or unnamed
        elif isinstance(obj, (XsdElement, XsdAttribute)):
            name = obj.type.local_name or unnamed
        else:
            name = unnamed

        if not name or NCNAME_PATTERN.match(name) is None:
            name = unnamed

        if name.endswith('Type'):
            name = name[:-4]
        elif name.endswith('_type'):
            name = name[:-5]

        if suffix:
            name = '{}{}'.format(name, suffix)

        return name.replace('.', '_').replace('-', '_')

    @staticmethod
    @filter_method
    def type_qname(obj, suffix=None, unnamed='none', sep='__'):
        """
        Get the unqualified name of the XSD type. Invalid
        chars for identifiers are replaced by an underscore.

        :param obj: an instance of (XsdType|XsdAttribute|XsdElement).
        :param suffix: force a suffix. For default removes '_type' or 'Type' suffixes.
        :param unnamed: value for unnamed XSD types. Defaults to 'none'.
        :param sep: the replacement for colon. Defaults to double underscore.
        :return: str
        """
        if isinstance(obj, XsdType):
            qname = obj.prefixed_name or unnamed
        elif isinstance(obj, (XsdElement, XsdAttribute)):
            qname = obj.type.prefixed_name or unnamed
        else:
            qname = unnamed

        if not qname or QNAME_PATTERN.match(qname) is None:
            qname = unnamed

        if qname.endswith('Type'):
            qname = qname[:-4]
        elif qname.endswith('_type'):
            qname = qname[:-5]

        if suffix:
            qname = '{}{}'.format(qname, suffix)

        return qname.replace('.', '_').replace('-', '_').replace(':', sep)

    @staticmethod
    @filter_method
    def sort_types(xsd_types, accept_circularity=False):
        """
        Returns a sorted sequence of XSD types. Sorted types can be used to build code declarations.

        :param xsd_types: a sequence with XSD types.
        :param accept_circularity: if set to `True` circularities are accepted. Defaults to `False`.
        :return: a list with ordered types.
        """
        if not isinstance(xsd_types, (list, tuple)):
            try:
                xsd_types = list(xsd_types.values())
            except AttributeError:
                pass

        assert all(isinstance(x, XsdType) for x in xsd_types)
        ordered_types = [x for x in xsd_types if x.is_simple()]
        ordered_types.extend(x for x in xsd_types
                             if x.is_complex() and x.has_simple_content())
        unordered = {
            x: []
            for x in xsd_types
            if x.is_complex() and not x.has_simple_content()
        }

        for xsd_type in unordered:
            for e in xsd_type.content_type.iter_elements():
                if e.type in unordered:
                    unordered[xsd_type].append(e.type)

        while unordered:
            deleted = 0
            for xsd_type in xsd_types:
                if xsd_type in unordered:
                    if not unordered[xsd_type]:
                        del unordered[xsd_type]
                        ordered_types.append(xsd_type)
                        deleted += 1

            for xsd_type in unordered:
                unordered[xsd_type] = [
                    x for x in unordered[xsd_type] if x in unordered
                ]

            if not deleted:
                if not accept_circularity:
                    raise ValueError("Circularity found between {!r}".format(
                        list(unordered)))
                ordered_types.extend(list(unordered))
                break

        assert len(xsd_types) == len(ordered_types)
        return ordered_types

    def is_derived(self, xsd_type, *names, derivation=None):
        for type_name in names:
            if not isinstance(type_name, str) or not type_name:
                continue
            elif type_name[0] == '{':
                if xsd_type.is_derived(self.schema.maps.types[type_name],
                                       derivation):
                    return True
            elif ':' in type_name:
                try:
                    other = self.schema.resolve_qname(type_name)
                except xmlschema.XMLSchemaException:
                    continue
                else:
                    if xsd_type.is_derived(other, derivation):
                        return True
            else:
                if xsd_type.is_derived(self.schema.types[type_name],
                                       derivation):
                    return True

        return False

    @test_method
    def derivation(self, xsd_type, *names):
        return self.is_derived(xsd_type, *names)

    @test_method
    def extension(self, xsd_type, *names):
        return self.is_derived(xsd_type, *names, derivation='extension')

    @test_method
    def restriction(self, xsd_type, *names):
        return self.is_derived(xsd_type, *names, derivation='restriction')

    @staticmethod
    @test_method
    def multi_sequence(xsd_type):
        if xsd_type.has_simple_content():
            return False
        return any(e.is_multiple()
                   for e in xsd_type.content_type.iter_elements())
Example #15
0
class Renderer(object):
    """Renders a template."""
    def __init__(self, path=None, loader=None, filters=None):
        """Constructs a new Renderer object.

        Either path or loader has to be specified.

        Keyword arguments:
        path -- list or str which represents template locations
        loader -- a jinja2 template loader instance (default: None)
        filters -- dict containing filters (default: {})

        """
        if (path is None and loader is None
                or path is not None and loader is not None):
            raise ValueError('Either specify path oder loader')
        if path is not None:
            loader = FileSystemLoader(path)
        self._env = Environment(loader=loader)
        self._env.add_extension('jinja2.ext.do')
        self._add_filters(filters)

    def _add_filters(self, filters):
        """Adds new filters to the jinja2 environment.

        filters is a dict of filters.

        """
        self._env.filters['dateformat'] = dateformat
        self._env.filters.update(filters or {})

    def _custom_template_names(self, template):
        """Returns a list of custom template names.

        template is the name of the original template.

        """
        splitted = template.rsplit('/', 1)
        name = 'custom_' + splitted[-1]
        ret = [name]
        if len(splitted) == 2:
            ret.append(splitted[0] + '/' + name)
        return ret

    def render_only(self, template, *args, **kwargs):
        """Renders template template.

        The rendered template is returned (no data will be written
        or printed).
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        names = self._custom_template_names(template)
        names.append(template)
        tmpl = self._env.select_template(names)
        return tmpl.render(*args, **kwargs)

    def _render(self, template, out, *args, **kwargs):
        """Renders template template.

        out is a file or file-like object to which the rendered
        template should be written to.
        *args and **kwargs are passed to the render_only method.

        """
        text = self.render_only(template, *args, **kwargs)
        try:
            out.write(text)
        except UnicodeEncodeError:
            text = text.encode('utf-8')
            out.write(text)

    def render(self, template, *args, **kwargs):
        """Renders template template.

        Writes the rendered template to sys.stdout.
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        self._render(template, sys.stdout, *args, **kwargs)

    def render_text(self, text, *args, **kwargs):
        """Renders text.

        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        global TEXT_TEMPLATE
        self.render(TEXT_TEMPLATE, text=text, *args, **kwargs)

    def render_error(self, template, *args, **kwargs):
        """Renders template template.

        Writes the rendered template to sys.stderr.
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        self._render(template, sys.stderr, *args, **kwargs)
Example #16
0
class Datasette:
    def __init__(
        self,
        files,
        num_threads=3,
        cache_headers=True,
        page_size=100,
        max_returned_rows=1000,
        sql_time_limit_ms=1000,
        cors=False,
        inspect_data=None,
        metadata=None,
        sqlite_extensions=None,
        template_dir=None,
        plugins_dir=None,
        static_mounts=None,
    ):
        self.files = files
        self.num_threads = num_threads
        self.executor = futures.ThreadPoolExecutor(max_workers=num_threads)
        self.cache_headers = cache_headers
        self.page_size = page_size
        self.max_returned_rows = max_returned_rows
        self.sql_time_limit_ms = sql_time_limit_ms
        self.cors = cors
        self._inspect = inspect_data
        self.metadata = metadata or {}
        self.sqlite_functions = []
        self.sqlite_extensions = sqlite_extensions or []
        self.template_dir = template_dir
        self.plugins_dir = plugins_dir
        self.static_mounts = static_mounts or []
        # Execute plugins in constructor, to ensure they are available
        # when the rest of `datasette inspect` executes
        if self.plugins_dir:
            for filename in os.listdir(self.plugins_dir):
                filepath = os.path.join(self.plugins_dir, filename)
                mod = module_from_path(filepath, name=filename)
                try:
                    pm.register(mod)
                except ValueError:
                    # Plugin already registered
                    pass

    def app_css_hash(self):
        if not hasattr(self, "_app_css_hash"):
            self._app_css_hash = hashlib.sha1(
                open(os.path.join(str(app_root),
                                  "datasette/static/app.css")).read().encode(
                                      "utf8")).hexdigest()[:6]
        return self._app_css_hash

    def get_canned_query(self, database_name, query_name):
        query = self.metadata.get("databases",
                                  {}).get(database_name,
                                          {}).get("queries",
                                                  {}).get(query_name)
        if query:
            return {"name": query_name, "sql": query}

    def asset_urls(self, key):
        urls_or_dicts = (self.metadata.get(key) or [])
        # Flatten list-of-lists from plugins:
        urls_or_dicts += list(
            itertools.chain.from_iterable(getattr(pm.hook, key)()))
        for url_or_dict in urls_or_dicts:
            if isinstance(url_or_dict, dict):
                yield {
                    "url": url_or_dict["url"],
                    "sri": url_or_dict.get("sri")
                }

            else:
                yield {"url": url_or_dict}

    def extra_css_urls(self):
        return self.asset_urls("extra_css_urls")

    def extra_js_urls(self):
        return self.asset_urls("extra_js_urls")

    def update_with_inherited_metadata(self, metadata):
        # Fills in source/license with defaults, if available
        metadata.update({
            "source":
            metadata.get("source") or self.metadata.get("source"),
            "source_url":
            metadata.get("source_url") or self.metadata.get("source_url"),
            "license":
            metadata.get("license") or self.metadata.get("license"),
            "license_url":
            metadata.get("license_url") or self.metadata.get("license_url"),
        })

    def prepare_connection(self, conn):
        conn.row_factory = sqlite3.Row
        conn.text_factory = lambda x: str(x, "utf-8", "replace")
        for name, num_args, func in self.sqlite_functions:
            conn.create_function(name, num_args, func)
        if self.sqlite_extensions:
            conn.enable_load_extension(True)
            for extension in self.sqlite_extensions:
                conn.execute("SELECT load_extension('{}')".format(extension))
        pm.hook.prepare_connection(conn=conn)

    def inspect(self):
        if not self._inspect:
            self._inspect = {}
            for filename in self.files:
                path = Path(filename)
                name = path.stem
                if name in self._inspect:
                    raise Exception("Multiple files with same stem %s" % name)

                # Calculate hash, efficiently
                m = hashlib.sha256()
                with path.open("rb") as fp:
                    while True:
                        data = fp.read(HASH_BLOCK_SIZE)
                        if not data:
                            break

                        m.update(data)
                # List tables and their row counts
                database_metadata = self.metadata.get("databases",
                                                      {}).get(name, {})
                tables = {}
                views = []
                with sqlite3.connect("file:{}?immutable=1".format(path),
                                     uri=True) as conn:
                    self.prepare_connection(conn)
                    table_names = [
                        r["name"] for r in conn.execute(
                            'select * from sqlite_master where type="table"')
                    ]
                    views = [
                        v[0] for v in conn.execute(
                            'select name from sqlite_master where type = "view"'
                        )
                    ]
                    for table in table_names:
                        try:
                            count = conn.execute(
                                "select count(*) from {}".format(
                                    escape_sqlite(table))).fetchone()[0]
                        except sqlite3.OperationalError:
                            # This can happen when running against a FTS virtual tables
                            # e.g. "select count(*) from some_fts;"
                            count = 0
                        # Does this table have a FTS table?
                        fts_table = detect_fts(conn, table)

                        # Figure out primary keys
                        table_info_rows = [
                            row
                            for row in conn.execute('PRAGMA table_info("{}")'.
                                                    format(table)).fetchall()
                            if row[-1]
                        ]
                        table_info_rows.sort(key=lambda row: row[-1])
                        primary_keys = [str(r[1]) for r in table_info_rows]
                        label_column = None
                        # If table has two columns, one of which is ID, then label_column is the other one
                        column_names = [
                            r[1] for r in conn.execute(
                                "PRAGMA table_info({});".format(
                                    escape_sqlite(table))).fetchall()
                        ]
                        if (column_names and len(column_names) == 2
                                and "id" in column_names):
                            label_column = [
                                c for c in column_names if c != "id"
                            ][0]
                        table_metadata = database_metadata.get("tables",
                                                               {}).get(
                                                                   table, {})
                        tables[table] = {
                            "name": table,
                            "columns": column_names,
                            "primary_keys": primary_keys,
                            "count": count,
                            "label_column": label_column,
                            "hidden": table_metadata.get("hidden") or False,
                            "fts_table": fts_table,
                        }

                    foreign_keys = get_all_foreign_keys(conn)
                    for table, info in foreign_keys.items():
                        tables[table]["foreign_keys"] = info

                    # Mark tables 'hidden' if they relate to FTS virtual tables
                    hidden_tables = [
                        r["name"] for r in conn.execute("""
                                select name from sqlite_master
                                where rootpage = 0
                                and sql like '%VIRTUAL TABLE%USING FTS%'
                            """)
                    ]

                    if detect_spatialite(conn):
                        # Also hide Spatialite internal tables
                        hidden_tables += [
                            "ElementaryGeometries",
                            "SpatialIndex",
                            "geometry_columns",
                            "spatial_ref_sys",
                            "spatialite_history",
                            "sql_statements_log",
                            "sqlite_sequence",
                            "views_geometry_columns",
                            "virts_geometry_columns",
                        ] + [
                            r["name"] for r in conn.execute("""
                                    select name from sqlite_master
                                    where name like "idx_%"
                                    and type = "table"
                                """)
                        ]

                    for t in tables.keys():
                        for hidden_table in hidden_tables:
                            if t == hidden_table or t.startswith(hidden_table):
                                tables[t]["hidden"] = True
                                continue

                self._inspect[name] = {
                    "hash": m.hexdigest(),
                    "file": str(path),
                    "tables": tables,
                    "views": views,
                }
        return self._inspect

    def register_custom_units(self):
        "Register any custom units defined in the metadata.json with Pint"
        for unit in self.metadata.get("custom_units", []):
            ureg.define(unit)

    def versions(self):
        conn = sqlite3.connect(":memory:")
        self.prepare_connection(conn)
        sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
        sqlite_extensions = {}
        for extension, testsql, hasversion in (
            ("json1", "SELECT json('{}')", False),
            ("spatialite", "SELECT spatialite_version()", True),
        ):
            try:
                result = conn.execute(testsql)
                if hasversion:
                    sqlite_extensions[extension] = result.fetchone()[0]
                else:
                    sqlite_extensions[extension] = None
            except Exception as e:
                pass
        # Figure out supported FTS versions
        fts_versions = []
        for fts in ("FTS5", "FTS4", "FTS3"):
            try:
                conn.execute(
                    "CREATE VIRTUAL TABLE v{fts} USING {fts} (t TEXT)".format(
                        fts=fts))
                fts_versions.append(fts)
            except sqlite3.OperationalError:
                continue

        return {
            "python": {
                "version": ".".join(map(str, sys.version_info[:3])),
                "full": sys.version
            },
            "datasette": {
                "version": __version__
            },
            "sqlite": {
                "version": sqlite_version,
                "fts_versions": fts_versions,
                "extensions": sqlite_extensions,
            },
        }

    def plugins(self):
        return [{
            "name": p["name"],
            "static": p["static_path"] is not None,
            "templates": p["templates_path"] is not None,
            "version": p.get("version"),
        } for p in get_plugins(pm)]

    def app(self):
        app = Sanic(__name__)
        default_templates = str(app_root / "datasette" / "templates")
        template_paths = []
        if self.template_dir:
            template_paths.append(self.template_dir)
        template_paths.extend([
            plugin["templates_path"] for plugin in get_plugins(pm)
            if plugin["templates_path"]
        ])
        template_paths.append(default_templates)
        template_loader = ChoiceLoader([
            FileSystemLoader(template_paths),
            # Support {% extends "default:table.html" %}:
            PrefixLoader({"default": FileSystemLoader(default_templates)},
                         delimiter=":"),
        ])
        self.jinja_env = Environment(loader=template_loader, autoescape=True)
        self.jinja_env.filters["escape_css_string"] = escape_css_string
        self.jinja_env.filters[
            "quote_plus"] = lambda u: urllib.parse.quote_plus(u)
        self.jinja_env.filters["escape_sqlite"] = escape_sqlite
        self.jinja_env.filters["to_css_class"] = to_css_class
        pm.hook.prepare_jinja2_environment(env=self.jinja_env)
        app.add_route(IndexView.as_view(self), "/<as_json:(\.jsono?)?$>")
        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
        app.add_route(favicon, "/favicon.ico")
        app.static("/-/static/", str(app_root / "datasette" / "static"))
        for path, dirname in self.static_mounts:
            app.static(path, dirname)
        # Mount any plugin static/ directories
        for plugin in get_plugins(pm):
            if plugin["static_path"]:
                modpath = "/-/static-plugins/{}/".format(plugin["name"])
                app.static(modpath, plugin["static_path"])
        app.add_route(
            JsonDataView.as_view(self, "inspect.json", self.inspect),
            "/-/inspect<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "metadata.json", lambda: self.metadata),
            "/-/metadata<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "versions.json", self.versions),
            "/-/versions<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "plugins.json", self.plugins),
            "/-/plugins<as_json:(\.json)?$>",
        )
        app.add_route(DatabaseView.as_view(self),
                      "/<db_name:[^/\.]+?><as_json:(\.jsono?)?$>")
        app.add_route(DatabaseDownload.as_view(self),
                      "/<db_name:[^/]+?><as_db:(\.db)$>")
        app.add_route(
            TableView.as_view(self),
            "/<db_name:[^/]+>/<table:[^/]+?><as_json:(\.jsono?)?$>",
        )
        app.add_route(
            RowView.as_view(self),
            "/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_json:(\.jsono?)?$>",
        )

        self.register_custom_units()

        @app.exception(Exception)
        def on_exception(request, exception):
            title = None
            if isinstance(exception, NotFound):
                status = 404
                info = {}
                message = exception.args[0]
            elif isinstance(exception, InvalidUsage):
                status = 405
                info = {}
                message = exception.args[0]
            elif isinstance(exception, DatasetteError):
                status = exception.status
                info = exception.error_dict
                message = exception.message
                title = exception.title
            else:
                status = 500
                info = {}
                message = str(exception)
                traceback.print_exc()
            templates = ["500.html"]
            if status != 500:
                templates = ["{}.html".format(status)] + templates
            info.update({
                "ok": False,
                "error": message,
                "status": status,
                "title": title
            })
            if request.path.split("?")[0].endswith(".json"):
                return response.json(info, status=status)

            else:
                template = self.jinja_env.select_template(templates)
                return response.html(template.render(info), status=status)

        return app
Example #17
0
class Datasette:
    def __init__(
        self,
        files,
        cache_headers=True,
        cors=False,
        inspect_data=None,
        metadata=None,
        sqlite_extensions=None,
        template_dir=None,
        plugins_dir=None,
        static_mounts=None,
        memory=False,
        config=None,
        version_note=None,
    ):
        self.files = files
        if not self.files:
            self.files = [MEMORY]
        elif memory:
            self.files = (MEMORY, ) + self.files
        self.cache_headers = cache_headers
        self.cors = cors
        self._inspect = inspect_data
        self._metadata = metadata or {}
        self.sqlite_functions = []
        self.sqlite_extensions = sqlite_extensions or []
        self.template_dir = template_dir
        self.plugins_dir = plugins_dir
        self.static_mounts = static_mounts or []
        self._config = dict(DEFAULT_CONFIG, **(config or {}))
        self.version_note = version_note
        self.executor = futures.ThreadPoolExecutor(
            max_workers=self.config("num_sql_threads"))
        self.max_returned_rows = self.config("max_returned_rows")
        self.sql_time_limit_ms = self.config("sql_time_limit_ms")
        self.page_size = self.config("default_page_size")
        # Execute plugins in constructor, to ensure they are available
        # when the rest of `datasette inspect` executes
        if self.plugins_dir:
            for filename in os.listdir(self.plugins_dir):
                filepath = os.path.join(self.plugins_dir, filename)
                mod = module_from_path(filepath, name=filename)
                try:
                    pm.register(mod)
                except ValueError:
                    # Plugin already registered
                    pass

    def config(self, key):
        return self._config.get(key, None)

    def config_dict(self):
        # Returns a fully resolved config dictionary, useful for templates
        return {
            option.name: self.config(option.name)
            for option in CONFIG_OPTIONS
        }

    def metadata(self, key=None, database=None, table=None, fallback=True):
        """
        Looks up metadata, cascading backwards from specified level.
        Returns None if metadata value is not found.
        """
        assert not (database is None and table is not None), \
            "Cannot call metadata() with table= specified but not database="
        databases = self._metadata.get("databases") or {}
        search_list = []
        if database is not None:
            search_list.append(databases.get(database) or {})
        if table is not None:
            table_metadata = ((databases.get(database) or {}).get("tables")
                              or {}).get(table) or {}
            search_list.insert(0, table_metadata)
        search_list.append(self._metadata)
        if not fallback:
            # No fallback allowed, so just use the first one in the list
            search_list = search_list[:1]
        if key is not None:
            for item in search_list:
                if key in item:
                    return item[key]
            return None
        else:
            # Return the merged list
            m = {}
            for item in search_list:
                m.update(item)
            return m

    def plugin_config(self,
                      plugin_name,
                      database=None,
                      table=None,
                      fallback=True):
        "Return config for plugin, falling back from specified database/table"
        plugins = self.metadata("plugins",
                                database=database,
                                table=table,
                                fallback=fallback)
        if plugins is None:
            return None
        return plugins.get(plugin_name)

    def app_css_hash(self):
        if not hasattr(self, "_app_css_hash"):
            self._app_css_hash = hashlib.sha1(
                open(os.path.join(str(app_root),
                                  "datasette/static/app.css")).read().encode(
                                      "utf8")).hexdigest()[:6]
        return self._app_css_hash

    def get_canned_queries(self, database_name):
        queries = self.metadata(
            "queries", database=database_name, fallback=False) or {}
        names = queries.keys()
        return [self.get_canned_query(database_name, name) for name in names]

    def get_canned_query(self, database_name, query_name):
        queries = self.metadata(
            "queries", database=database_name, fallback=False) or {}
        query = queries.get(query_name)
        if query:
            if not isinstance(query, dict):
                query = {"sql": query}
            query["name"] = query_name
            return query

    async def get_table_definition(self, database_name, table, type_="table"):
        table_definition_rows = list(await self.execute(
            database_name,
            'select sql from sqlite_master where name = :n and type=:t',
            {
                "n": table,
                "t": type_
            },
        ))
        if not table_definition_rows:
            return None
        return table_definition_rows[0][0]

    def get_view_definition(self, database_name, view):
        return self.get_table_definition(database_name, view, 'view')

    def update_with_inherited_metadata(self, metadata):
        # Fills in source/license with defaults, if available
        metadata.update({
            "source":
            metadata.get("source") or self.metadata("source"),
            "source_url":
            metadata.get("source_url") or self.metadata("source_url"),
            "license":
            metadata.get("license") or self.metadata("license"),
            "license_url":
            metadata.get("license_url") or self.metadata("license_url"),
            "about":
            metadata.get("about") or self.metadata("about"),
            "about_url":
            metadata.get("about_url") or self.metadata("about_url"),
        })

    def prepare_connection(self, conn):
        conn.row_factory = sqlite3.Row
        conn.text_factory = lambda x: str(x, "utf-8", "replace")
        for name, num_args, func in self.sqlite_functions:
            conn.create_function(name, num_args, func)
        if self.sqlite_extensions:
            conn.enable_load_extension(True)
            for extension in self.sqlite_extensions:
                conn.execute("SELECT load_extension('{}')".format(extension))
        if self.config("cache_size_kb"):
            conn.execute('PRAGMA cache_size=-{}'.format(
                self.config("cache_size_kb")))
        pm.hook.prepare_connection(conn=conn)

    def table_exists(self, database, table):
        return table in self.inspect().get(database, {}).get("tables")

    def inspect(self):
        " Inspect the database and return a dictionary of table metadata "
        if self._inspect:
            return self._inspect

        self._inspect = {}
        for filename in self.files:
            if filename is MEMORY:
                self._inspect[":memory:"] = {
                    "hash": "000",
                    "file": ":memory:",
                    "size": 0,
                    "views": {},
                    "tables": {},
                }
            else:
                path = Path(filename)
                name = path.stem
                if name in self._inspect:
                    raise Exception("Multiple files with same stem %s" % name)
                try:
                    with sqlite3.connect("file:{}?immutable=1".format(path),
                                         uri=True) as conn:
                        self.prepare_connection(conn)
                        self._inspect[name] = {
                            "hash":
                            inspect_hash(path),
                            "file":
                            str(path),
                            "size":
                            path.stat().st_size,
                            "views":
                            inspect_views(conn),
                            "tables":
                            inspect_tables(conn, (self.metadata("databases")
                                                  or {}).get(name, {}))
                        }
                except sqlite3.OperationalError as e:
                    if (e.args[0] == 'no such module: VirtualSpatialIndex'):
                        raise click.UsageError(
                            "It looks like you're trying to load a SpatiaLite"
                            " database without first loading the SpatiaLite module."
                            "\n\nRead more: https://datasette.readthedocs.io/en/latest/spatialite.html"
                        )
                    else:
                        raise
        return self._inspect

    def register_custom_units(self):
        "Register any custom units defined in the metadata.json with Pint"
        for unit in self.metadata("custom_units") or []:
            ureg.define(unit)

    def versions(self):
        conn = sqlite3.connect(":memory:")
        self.prepare_connection(conn)
        sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
        sqlite_extensions = {}
        for extension, testsql, hasversion in (
            ("json1", "SELECT json('{}')", False),
            ("spatialite", "SELECT spatialite_version()", True),
        ):
            try:
                result = conn.execute(testsql)
                if hasversion:
                    sqlite_extensions[extension] = result.fetchone()[0]
                else:
                    sqlite_extensions[extension] = None
            except Exception as e:
                pass
        # Figure out supported FTS versions
        fts_versions = []
        for fts in ("FTS5", "FTS4", "FTS3"):
            try:
                conn.execute(
                    "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(
                        fts=fts))
                fts_versions.append(fts)
            except sqlite3.OperationalError:
                continue
        datasette_version = {"version": __version__}
        if self.version_note:
            datasette_version["note"] = self.version_note
        return {
            "python": {
                "version": ".".join(map(str, sys.version_info[:3])),
                "full": sys.version
            },
            "datasette": datasette_version,
            "sqlite": {
                "version":
                sqlite_version,
                "fts_versions":
                fts_versions,
                "extensions":
                sqlite_extensions,
                "compile_options": [
                    r[0] for r in conn.execute(
                        "pragma compile_options;").fetchall()
                ],
            },
        }

    def plugins(self, show_all=False):
        ps = list(get_plugins(pm))
        if not show_all:
            ps = [p for p in ps if p["name"] not in DEFAULT_PLUGINS]
        return [{
            "name": p["name"],
            "static": p["static_path"] is not None,
            "templates": p["templates_path"] is not None,
            "version": p.get("version"),
        } for p in ps]

    async def execute(
        self,
        db_name,
        sql,
        params=None,
        truncate=False,
        custom_time_limit=None,
        page_size=None,
    ):
        """Executes sql against db_name in a thread"""
        page_size = page_size or self.page_size

        def sql_operation_in_thread():
            conn = getattr(connections, db_name, None)
            if not conn:
                info = self.inspect()[db_name]
                if info["file"] == ":memory:":
                    conn = sqlite3.connect(":memory:")
                else:
                    conn = sqlite3.connect(
                        "file:{}?immutable=1".format(info["file"]),
                        uri=True,
                        check_same_thread=False,
                    )
                self.prepare_connection(conn)
                setattr(connections, db_name, conn)

            time_limit_ms = self.sql_time_limit_ms
            if custom_time_limit and custom_time_limit < time_limit_ms:
                time_limit_ms = custom_time_limit

            with sqlite_timelimit(conn, time_limit_ms):
                try:
                    cursor = conn.cursor()
                    cursor.execute(sql, params or {})
                    max_returned_rows = self.max_returned_rows
                    if max_returned_rows == page_size:
                        max_returned_rows += 1
                    if max_returned_rows and truncate:
                        rows = cursor.fetchmany(max_returned_rows + 1)
                        truncated = len(rows) > max_returned_rows
                        rows = rows[:max_returned_rows]
                    else:
                        rows = cursor.fetchall()
                        truncated = False
                except sqlite3.OperationalError as e:
                    if e.args == ('interrupted', ):
                        raise InterruptedError(e)
                    print("ERROR: conn={}, sql = {}, params = {}: {}".format(
                        conn, repr(sql), params, e))
                    raise

            if truncate:
                return Results(rows, truncated, cursor.description)

            else:
                return Results(rows, False, cursor.description)

        return await asyncio.get_event_loop().run_in_executor(
            self.executor, sql_operation_in_thread)

    def app(self):
        app = Sanic(__name__)
        default_templates = str(app_root / "datasette" / "templates")
        template_paths = []
        if self.template_dir:
            template_paths.append(self.template_dir)
        template_paths.extend([
            plugin["templates_path"] for plugin in get_plugins(pm)
            if plugin["templates_path"]
        ])
        template_paths.append(default_templates)
        template_loader = ChoiceLoader([
            FileSystemLoader(template_paths),
            # Support {% extends "default:table.html" %}:
            PrefixLoader({"default": FileSystemLoader(default_templates)},
                         delimiter=":"),
        ])
        self.jinja_env = Environment(loader=template_loader, autoescape=True)
        self.jinja_env.filters["escape_css_string"] = escape_css_string
        self.jinja_env.filters[
            "quote_plus"] = lambda u: urllib.parse.quote_plus(u)
        self.jinja_env.filters["escape_sqlite"] = escape_sqlite
        self.jinja_env.filters["to_css_class"] = to_css_class
        pm.hook.prepare_jinja2_environment(env=self.jinja_env)
        app.add_route(IndexView.as_view(self), r"/<as_format:(\.jsono?)?$>")
        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
        app.add_route(favicon, "/favicon.ico")
        app.static("/-/static/", str(app_root / "datasette" / "static"))
        for path, dirname in self.static_mounts:
            app.static(path, dirname)
        # Mount any plugin static/ directories
        for plugin in get_plugins(pm):
            if plugin["static_path"]:
                modpath = "/-/static-plugins/{}/".format(plugin["name"])
                app.static(modpath, plugin["static_path"])
        app.add_route(
            JsonDataView.as_view(self, "inspect.json", self.inspect),
            r"/-/inspect<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "metadata.json",
                                 lambda: self._metadata),
            r"/-/metadata<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "versions.json", self.versions),
            r"/-/versions<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "plugins.json", self.plugins),
            r"/-/plugins<as_format:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "config.json", lambda: self._config),
            r"/-/config<as_format:(\.json)?$>",
        )
        app.add_route(DatabaseDownload.as_view(self),
                      r"/<db_name:[^/]+?><as_db:(\.db)$>")
        app.add_route(DatabaseView.as_view(self),
                      r"/<db_name:[^/]+?><as_format:(\.jsono?|\.csv)?$>")
        app.add_route(
            TableView.as_view(self),
            r"/<db_name:[^/]+>/<table_and_format:[^/]+?$>",
        )
        app.add_route(
            RowView.as_view(self),
            r"/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_format:(\.jsono?)?$>",
        )
        self.register_custom_units()
        # On 404 with a trailing slash redirect to path without that slash:
        @app.middleware("response")
        def redirect_on_404_with_trailing_slash(request, original_response):
            if original_response.status == 404 and request.path.endswith("/"):
                path = request.path.rstrip("/")
                if request.query_string:
                    path = "{}?{}".format(path, request.query_string)
                return response.redirect(path)

        @app.exception(Exception)
        def on_exception(request, exception):
            title = None
            help = None
            if isinstance(exception, NotFound):
                status = 404
                info = {}
                message = exception.args[0]
            elif isinstance(exception, InvalidUsage):
                status = 405
                info = {}
                message = exception.args[0]
            elif isinstance(exception, DatasetteError):
                status = exception.status
                info = exception.error_dict
                message = exception.message
                if exception.messagge_is_html:
                    message = Markup(message)
                title = exception.title
            else:
                status = 500
                info = {}
                message = str(exception)
                traceback.print_exc()
            templates = ["500.html"]
            if status != 500:
                templates = ["{}.html".format(status)] + templates
            info.update({
                "ok": False,
                "error": message,
                "status": status,
                "title": title
            })
            if request is not None and request.path.split("?")[0].endswith(
                    ".json"):
                return response.json(info, status=status)

            else:
                template = self.jinja_env.select_template(templates)
                return response.html(template.render(info), status=status)

        return app
Example #18
0
class Datasette:
    def __init__(
        self,
        files,
        num_threads=3,
        cache_headers=True,
        cors=False,
        inspect_data=None,
        metadata=None,
        sqlite_extensions=None,
        template_dir=None,
        plugins_dir=None,
        static_mounts=None,
        config=None,
    ):
        self.files = files
        self.num_threads = num_threads
        self.executor = futures.ThreadPoolExecutor(max_workers=num_threads)
        self.cache_headers = cache_headers
        self.cors = cors
        self._inspect = inspect_data
        self.metadata = metadata or {}
        self.sqlite_functions = []
        self.sqlite_extensions = sqlite_extensions or []
        self.template_dir = template_dir
        self.plugins_dir = plugins_dir
        self.static_mounts = static_mounts or []
        self.config = dict(DEFAULT_CONFIG, **(config or {}))
        self.max_returned_rows = self.config["max_returned_rows"]
        self.sql_time_limit_ms = self.config["sql_time_limit_ms"]
        self.page_size = self.config["default_page_size"]
        # Execute plugins in constructor, to ensure they are available
        # when the rest of `datasette inspect` executes
        if self.plugins_dir:
            for filename in os.listdir(self.plugins_dir):
                filepath = os.path.join(self.plugins_dir, filename)
                mod = module_from_path(filepath, name=filename)
                try:
                    pm.register(mod)
                except ValueError:
                    # Plugin already registered
                    pass

    def app_css_hash(self):
        if not hasattr(self, "_app_css_hash"):
            self._app_css_hash = hashlib.sha1(
                open(os.path.join(str(app_root),
                                  "datasette/static/app.css")).read().encode(
                                      "utf8")).hexdigest()[:6]
        return self._app_css_hash

    def get_canned_query(self, database_name, query_name):
        query = self.metadata.get("databases",
                                  {}).get(database_name,
                                          {}).get("queries",
                                                  {}).get(query_name)
        if query:
            return {"name": query_name, "sql": query}

    def asset_urls(self, key):
        urls_or_dicts = (self.metadata.get(key) or [])
        # Flatten list-of-lists from plugins:
        urls_or_dicts += list(
            itertools.chain.from_iterable(getattr(pm.hook, key)()))
        for url_or_dict in urls_or_dicts:
            if isinstance(url_or_dict, dict):
                yield {
                    "url": url_or_dict["url"],
                    "sri": url_or_dict.get("sri")
                }

            else:
                yield {"url": url_or_dict}

    def extra_css_urls(self):
        return self.asset_urls("extra_css_urls")

    def extra_js_urls(self):
        return self.asset_urls("extra_js_urls")

    def update_with_inherited_metadata(self, metadata):
        # Fills in source/license with defaults, if available
        metadata.update({
            "source":
            metadata.get("source") or self.metadata.get("source"),
            "source_url":
            metadata.get("source_url") or self.metadata.get("source_url"),
            "license":
            metadata.get("license") or self.metadata.get("license"),
            "license_url":
            metadata.get("license_url") or self.metadata.get("license_url"),
        })

    def prepare_connection(self, conn):
        conn.row_factory = sqlite3.Row
        conn.text_factory = lambda x: str(x, "utf-8", "replace")
        for name, num_args, func in self.sqlite_functions:
            conn.create_function(name, num_args, func)
        if self.sqlite_extensions:
            conn.enable_load_extension(True)
            for extension in self.sqlite_extensions:
                conn.execute("SELECT load_extension('{}')".format(extension))
        pm.hook.prepare_connection(conn=conn)

    def inspect(self):
        " Inspect the database and return a dictionary of table metadata "
        if self._inspect:
            return self._inspect

        self._inspect = {}
        for filename in self.files:
            path = Path(filename)
            name = path.stem
            if name in self._inspect:
                raise Exception("Multiple files with same stem %s" % name)

            with sqlite3.connect("file:{}?immutable=1".format(path),
                                 uri=True) as conn:
                self.prepare_connection(conn)
                self._inspect[name] = {
                    "hash":
                    inspect_hash(path),
                    "file":
                    str(path),
                    "views":
                    inspect_views(conn),
                    "tables":
                    inspect_tables(
                        conn,
                        self.metadata.get("databases", {}).get(name, {}))
                }
        return self._inspect

    def register_custom_units(self):
        "Register any custom units defined in the metadata.json with Pint"
        for unit in self.metadata.get("custom_units", []):
            ureg.define(unit)

    def versions(self):
        conn = sqlite3.connect(":memory:")
        self.prepare_connection(conn)
        sqlite_version = conn.execute("select sqlite_version()").fetchone()[0]
        sqlite_extensions = {}
        for extension, testsql, hasversion in (
            ("json1", "SELECT json('{}')", False),
            ("spatialite", "SELECT spatialite_version()", True),
        ):
            try:
                result = conn.execute(testsql)
                if hasversion:
                    sqlite_extensions[extension] = result.fetchone()[0]
                else:
                    sqlite_extensions[extension] = None
            except Exception as e:
                pass
        # Figure out supported FTS versions
        fts_versions = []
        for fts in ("FTS5", "FTS4", "FTS3"):
            try:
                conn.execute(
                    "CREATE VIRTUAL TABLE v{fts} USING {fts} (data)".format(
                        fts=fts))
                fts_versions.append(fts)
            except sqlite3.OperationalError:
                continue

        return {
            "python": {
                "version": ".".join(map(str, sys.version_info[:3])),
                "full": sys.version
            },
            "datasette": {
                "version": __version__
            },
            "sqlite": {
                "version": sqlite_version,
                "fts_versions": fts_versions,
                "extensions": sqlite_extensions,
            },
        }

    def plugins(self):
        return [{
            "name": p["name"],
            "static": p["static_path"] is not None,
            "templates": p["templates_path"] is not None,
            "version": p.get("version"),
        } for p in get_plugins(pm)]

    async def execute(
        self,
        db_name,
        sql,
        params=None,
        truncate=False,
        custom_time_limit=None,
        page_size=None,
    ):
        """Executes sql against db_name in a thread"""
        page_size = page_size or self.page_size

        def sql_operation_in_thread():
            conn = getattr(connections, db_name, None)
            if not conn:
                info = self.inspect()[db_name]
                conn = sqlite3.connect(
                    "file:{}?immutable=1".format(info["file"]),
                    uri=True,
                    check_same_thread=False,
                )
                self.prepare_connection(conn)
                setattr(connections, db_name, conn)

            time_limit_ms = self.sql_time_limit_ms
            if custom_time_limit and custom_time_limit < time_limit_ms:
                time_limit_ms = custom_time_limit

            with sqlite_timelimit(conn, time_limit_ms):
                try:
                    cursor = conn.cursor()
                    cursor.execute(sql, params or {})
                    max_returned_rows = self.max_returned_rows
                    if max_returned_rows == page_size:
                        max_returned_rows += 1
                    if max_returned_rows and truncate:
                        rows = cursor.fetchmany(max_returned_rows + 1)
                        truncated = len(rows) > max_returned_rows
                        rows = rows[:max_returned_rows]
                    else:
                        rows = cursor.fetchall()
                        truncated = False
                except sqlite3.OperationalError as e:
                    if e.args == ('interrupted', ):
                        raise InterruptedError(e)
                    print("ERROR: conn={}, sql = {}, params = {}: {}".format(
                        conn, repr(sql), params, e))
                    raise

            if truncate:
                return Results(rows, truncated, cursor.description)

            else:
                return Results(rows, False, cursor.description)

        return await asyncio.get_event_loop().run_in_executor(
            self.executor, sql_operation_in_thread)

    def app(self):
        app = Sanic(__name__)
        default_templates = str(app_root / "datasette" / "templates")
        template_paths = []
        if self.template_dir:
            template_paths.append(self.template_dir)
        template_paths.extend([
            plugin["templates_path"] for plugin in get_plugins(pm)
            if plugin["templates_path"]
        ])
        template_paths.append(default_templates)
        template_loader = ChoiceLoader([
            FileSystemLoader(template_paths),
            # Support {% extends "default:table.html" %}:
            PrefixLoader({"default": FileSystemLoader(default_templates)},
                         delimiter=":"),
        ])
        self.jinja_env = Environment(loader=template_loader, autoescape=True)
        self.jinja_env.filters["escape_css_string"] = escape_css_string
        self.jinja_env.filters[
            "quote_plus"] = lambda u: urllib.parse.quote_plus(u)
        self.jinja_env.filters["escape_sqlite"] = escape_sqlite
        self.jinja_env.filters["to_css_class"] = to_css_class
        pm.hook.prepare_jinja2_environment(env=self.jinja_env)
        app.add_route(IndexView.as_view(self), "/<as_json:(\.jsono?)?$>")
        # TODO: /favicon.ico and /-/static/ deserve far-future cache expires
        app.add_route(favicon, "/favicon.ico")
        app.static("/-/static/", str(app_root / "datasette" / "static"))
        for path, dirname in self.static_mounts:
            app.static(path, dirname)
        # Mount any plugin static/ directories
        for plugin in get_plugins(pm):
            if plugin["static_path"]:
                modpath = "/-/static-plugins/{}/".format(plugin["name"])
                app.static(modpath, plugin["static_path"])
        app.add_route(
            JsonDataView.as_view(self, "inspect.json", self.inspect),
            "/-/inspect<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "metadata.json", lambda: self.metadata),
            "/-/metadata<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "versions.json", self.versions),
            "/-/versions<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "plugins.json", self.plugins),
            "/-/plugins<as_json:(\.json)?$>",
        )
        app.add_route(
            JsonDataView.as_view(self, "config.json", lambda: self.config),
            "/-/config<as_json:(\.json)?$>",
        )
        app.add_route(DatabaseView.as_view(self),
                      "/<db_name:[^/\.]+?><as_json:(\.jsono?)?$>")
        app.add_route(DatabaseDownload.as_view(self),
                      "/<db_name:[^/]+?><as_db:(\.db)$>")
        app.add_route(
            TableView.as_view(self),
            "/<db_name:[^/]+>/<table:[^/]+?><as_json:(\.jsono?)?$>",
        )
        app.add_route(
            RowView.as_view(self),
            "/<db_name:[^/]+>/<table:[^/]+?>/<pk_path:[^/]+?><as_json:(\.jsono?)?$>",
        )

        self.register_custom_units()

        @app.exception(Exception)
        def on_exception(request, exception):
            title = None
            if isinstance(exception, NotFound):
                status = 404
                info = {}
                message = exception.args[0]
            elif isinstance(exception, InvalidUsage):
                status = 405
                info = {}
                message = exception.args[0]
            elif isinstance(exception, DatasetteError):
                status = exception.status
                info = exception.error_dict
                message = exception.message
                title = exception.title
            else:
                status = 500
                info = {}
                message = str(exception)
                traceback.print_exc()
            templates = ["500.html"]
            if status != 500:
                templates = ["{}.html".format(status)] + templates
            info.update({
                "ok": False,
                "error": message,
                "status": status,
                "title": title
            })
            if request.path.split("?")[0].endswith(".json"):
                return response.json(info, status=status)

            else:
                template = self.jinja_env.select_template(templates)
                return response.html(template.render(info), status=status)

        return app
Example #19
0
class Renderer(object):
    """Renders a template."""

    def __init__(self, path=None, loader=None, filters=None):
        """Constructs a new Renderer object.

        Either path or loader has to be specified.

        Keyword arguments:
        path -- list or str which represents template locations
        loader -- a jinja2 template loader instance (default: None)
        filters -- dict containing filters (default: {})

        """
        if (path is None and loader is None
                or path is not None and loader is not None):
            raise ValueError('Either specify path oder loader')
        if path is not None:
            loader = FileSystemLoader(path)
        self._env = Environment(loader=loader)
        self._env.add_extension('jinja2.ext.do')
        self._add_filters(filters)

    def _add_filters(self, filters):
        """Adds new filters to the jinja2 environment.

        filters is a dict of filters.

        """
        self._env.filters['dateformat'] = dateformat
        self._env.filters.update(filters or {})

    def _custom_template_names(self, template):
        """Returns a list of custom template names.

        template is the name of the original template.

        """
        splitted = template.rsplit('/', 1)
        name = 'custom_' + splitted[-1]
        ret = [name]
        if len(splitted) == 2:
            ret.append(splitted[0] + '/' + name)
        return ret

    def render_only(self, template, *args, **kwargs):
        """Renders template template.

        The rendered template is returned (no data will be written
        or printed).
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        names = self._custom_template_names(template)
        names.append(template)
        tmpl = self._env.select_template(names)
        return tmpl.render(*args, **kwargs)

    def _render(self, template, out, *args, **kwargs):
        """Renders template template.

        out is a file or file-like object to which the rendered
        template should be written to.
        *args and **kwargs are passed to the render_only method.

        """
        text = self.render_only(template, *args, **kwargs)
        try:
            out.write(text)
        except UnicodeEncodeError:
            text = text.encode('utf-8')
            out.write(text)

    def render(self, template, *args, **kwargs):
        """Renders template template.

        Writes the rendered template to sys.stdout.
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        self._render(template, sys.stdout, *args, **kwargs)

    def render_text(self, text, *args, **kwargs):
        """Renders text.

        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        global TEXT_TEMPLATE
        self.render(TEXT_TEMPLATE, text=text, *args, **kwargs)

    def render_error(self, template, *args, **kwargs):
        """Renders template template.

        Writes the rendered template to sys.stderr.
        *args and **kwargs are passed to jinja2 Template's render
        method.

        """
        self._render(template, sys.stderr, *args, **kwargs)