mirror of
https://github.com/simonw/datasette.git
synced 2025-12-10 16:51:24 +01:00
This change introduces a new plugin hook, publish_subcommand, which can be used to implement new subcommands for the "datasette publish" command family. I've used this new hook to refactor out the "publish now" and "publish heroku" implementations into separate modules. I've also added unit tests for these two publishers, mocking the subprocess.call and subprocess.check_output functions. As part of this, I introduced a mechanism for loading default plugins. These are defined in the new "default_plugins" list inside datasette/app.py Closes #217 (Plugin support for datasette publish) Closes #348 (Unit tests for "datasette publish") Refs #14, #59, #102, #103, #146, #236, #347
375 lines
10 KiB
Python
375 lines
10 KiB
Python
import click
|
|
from click import formatting
|
|
from click_default_group import DefaultGroup
|
|
import json
|
|
import os
|
|
import shutil
|
|
from subprocess import call
|
|
import sys
|
|
from .app import Datasette, DEFAULT_CONFIG, CONFIG_OPTIONS, pm
|
|
from .utils import (
|
|
temporary_docker_directory,
|
|
value_as_boolean,
|
|
StaticMount,
|
|
ValueAsBooleanError,
|
|
)
|
|
|
|
|
|
class Config(click.ParamType):
|
|
name = "config"
|
|
|
|
def convert(self, config, param, ctx):
|
|
if ":" not in config:
|
|
self.fail(
|
|
'"{}" should be name:value'.format(config), param, ctx
|
|
)
|
|
return
|
|
name, value = config.split(":")
|
|
if name not in DEFAULT_CONFIG:
|
|
self.fail(
|
|
"{} is not a valid option (--help-config to see all)".format(
|
|
name
|
|
), param, ctx
|
|
)
|
|
return
|
|
# Type checking
|
|
default = DEFAULT_CONFIG[name]
|
|
if isinstance(default, bool):
|
|
try:
|
|
return name, value_as_boolean(value)
|
|
except ValueAsBooleanError:
|
|
self.fail(
|
|
'"{}" should be on/off/true/false/1/0'.format(name), param, ctx
|
|
)
|
|
return
|
|
elif isinstance(default, int):
|
|
if not value.isdigit():
|
|
self.fail(
|
|
'"{}" should be an integer'.format(name), param, ctx
|
|
)
|
|
return
|
|
return name, int(value)
|
|
else:
|
|
# Should never happen:
|
|
self.fail('Invalid option')
|
|
|
|
|
|
@click.group(cls=DefaultGroup, default="serve", default_if_no_args=True)
|
|
@click.version_option()
|
|
def cli():
|
|
"""
|
|
Datasette!
|
|
"""
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("files", type=click.Path(exists=True), nargs=-1)
|
|
@click.option("--inspect-file", default="inspect-data.json")
|
|
@click.option(
|
|
"sqlite_extensions",
|
|
"--load-extension",
|
|
envvar="SQLITE_EXTENSIONS",
|
|
multiple=True,
|
|
type=click.Path(exists=True, resolve_path=True),
|
|
help="Path to a SQLite extension to load",
|
|
)
|
|
def inspect(files, inspect_file, sqlite_extensions):
|
|
app = Datasette(files, sqlite_extensions=sqlite_extensions)
|
|
open(inspect_file, "w").write(json.dumps(app.inspect(), indent=2))
|
|
|
|
|
|
@cli.group()
|
|
def publish():
|
|
"Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"
|
|
pass
|
|
|
|
|
|
# Register publish plugins
|
|
pm.hook.publish_subcommand(publish=publish)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("files", type=click.Path(exists=True), nargs=-1, required=True)
|
|
@click.option(
|
|
"-m",
|
|
"--metadata",
|
|
default="metadata.json",
|
|
help="Name of metadata file to generate",
|
|
)
|
|
@click.option(
|
|
"sqlite_extensions",
|
|
"--load-extension",
|
|
envvar="SQLITE_EXTENSIONS",
|
|
multiple=True,
|
|
type=click.Path(exists=True, resolve_path=True),
|
|
help="Path to a SQLite extension to load",
|
|
)
|
|
def skeleton(files, metadata, sqlite_extensions):
|
|
"Generate a skeleton metadata.json file for specified SQLite databases"
|
|
if os.path.exists(metadata):
|
|
click.secho(
|
|
"File {} already exists, will not over-write".format(metadata),
|
|
bg="red",
|
|
fg="white",
|
|
bold=True,
|
|
err=True,
|
|
)
|
|
sys.exit(1)
|
|
app = Datasette(files, sqlite_extensions=sqlite_extensions)
|
|
databases = {}
|
|
for database_name, info in app.inspect().items():
|
|
databases[database_name] = {
|
|
"title": None,
|
|
"description": None,
|
|
"description_html": None,
|
|
"license": None,
|
|
"license_url": None,
|
|
"source": None,
|
|
"source_url": None,
|
|
"queries": {},
|
|
"tables": {
|
|
table_name: {
|
|
"title": None,
|
|
"description": None,
|
|
"description_html": None,
|
|
"license": None,
|
|
"license_url": None,
|
|
"source": None,
|
|
"source_url": None,
|
|
"units": {},
|
|
}
|
|
for table_name in (info.get("tables") or {})
|
|
},
|
|
}
|
|
open(metadata, "w").write(
|
|
json.dumps(
|
|
{
|
|
"title": None,
|
|
"description": None,
|
|
"description_html": None,
|
|
"license": None,
|
|
"license_url": None,
|
|
"source": None,
|
|
"source_url": None,
|
|
"databases": databases,
|
|
},
|
|
indent=4,
|
|
)
|
|
)
|
|
click.echo("Wrote skeleton to {}".format(metadata))
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("files", type=click.Path(exists=True), nargs=-1, required=True)
|
|
@click.option(
|
|
"-t",
|
|
"--tag",
|
|
help="Name for the resulting Docker container, can optionally use name:tag format",
|
|
)
|
|
@click.option(
|
|
"-m",
|
|
"--metadata",
|
|
type=click.File(mode="r"),
|
|
help="Path to JSON file containing metadata to publish",
|
|
)
|
|
@click.option("--extra-options", help="Extra options to pass to datasette serve")
|
|
@click.option("--branch", help="Install datasette from a GitHub branch e.g. master")
|
|
@click.option(
|
|
"--template-dir",
|
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
help="Path to directory containing custom templates",
|
|
)
|
|
@click.option(
|
|
"--plugins-dir",
|
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
help="Path to directory containing custom plugins",
|
|
)
|
|
@click.option(
|
|
"--static",
|
|
type=StaticMount(),
|
|
help="mountpoint:path-to-directory for serving static files",
|
|
multiple=True,
|
|
)
|
|
@click.option(
|
|
"--install",
|
|
help="Additional packages (e.g. plugins) to install",
|
|
multiple=True,
|
|
)
|
|
@click.option(
|
|
"--spatialite", is_flag=True, help="Enable SpatialLite extension"
|
|
)
|
|
@click.option("--version-note", help="Additional note to show on /-/versions")
|
|
@click.option("--title", help="Title for metadata")
|
|
@click.option("--license", help="License label for metadata")
|
|
@click.option("--license_url", help="License URL for metadata")
|
|
@click.option("--source", help="Source label for metadata")
|
|
@click.option("--source_url", help="Source URL for metadata")
|
|
def package(
|
|
files,
|
|
tag,
|
|
metadata,
|
|
extra_options,
|
|
branch,
|
|
template_dir,
|
|
plugins_dir,
|
|
static,
|
|
install,
|
|
spatialite,
|
|
version_note,
|
|
**extra_metadata
|
|
):
|
|
"Package specified SQLite files into a new datasette Docker container"
|
|
if not shutil.which("docker"):
|
|
click.secho(
|
|
' The package command requires "docker" to be installed and configured ',
|
|
bg="red",
|
|
fg="white",
|
|
bold=True,
|
|
err=True,
|
|
)
|
|
sys.exit(1)
|
|
with temporary_docker_directory(
|
|
files,
|
|
"datasette",
|
|
metadata,
|
|
extra_options,
|
|
branch,
|
|
template_dir,
|
|
plugins_dir,
|
|
static,
|
|
install,
|
|
spatialite,
|
|
version_note,
|
|
extra_metadata,
|
|
):
|
|
args = ["docker", "build"]
|
|
if tag:
|
|
args.append("-t")
|
|
args.append(tag)
|
|
args.append(".")
|
|
call(args)
|
|
|
|
|
|
@cli.command()
|
|
@click.argument("files", type=click.Path(exists=True), nargs=-1)
|
|
@click.option(
|
|
"-h", "--host", default="127.0.0.1", help="host for server, defaults to 127.0.0.1"
|
|
)
|
|
@click.option("-p", "--port", default=8001, help="port for server, defaults to 8001")
|
|
@click.option(
|
|
"--debug", is_flag=True, help="Enable debug mode - useful for development"
|
|
)
|
|
@click.option(
|
|
"--reload",
|
|
is_flag=True,
|
|
help="Automatically reload if code change detected - useful for development",
|
|
)
|
|
@click.option(
|
|
"--cors", is_flag=True, help="Enable CORS by serving Access-Control-Allow-Origin: *"
|
|
)
|
|
@click.option(
|
|
"sqlite_extensions",
|
|
"--load-extension",
|
|
envvar="SQLITE_EXTENSIONS",
|
|
multiple=True,
|
|
type=click.Path(exists=True, resolve_path=True),
|
|
help="Path to a SQLite extension to load",
|
|
)
|
|
@click.option(
|
|
"--inspect-file", help='Path to JSON file created using "datasette inspect"'
|
|
)
|
|
@click.option(
|
|
"-m",
|
|
"--metadata",
|
|
type=click.File(mode="r"),
|
|
help="Path to JSON file containing license/source metadata",
|
|
)
|
|
@click.option(
|
|
"--template-dir",
|
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
help="Path to directory containing custom templates",
|
|
)
|
|
@click.option(
|
|
"--plugins-dir",
|
|
type=click.Path(exists=True, file_okay=False, dir_okay=True),
|
|
help="Path to directory containing custom plugins",
|
|
)
|
|
@click.option(
|
|
"--static",
|
|
type=StaticMount(),
|
|
help="mountpoint:path-to-directory for serving static files",
|
|
multiple=True,
|
|
)
|
|
@click.option(
|
|
"--config",
|
|
type=Config(),
|
|
help="Set config option using configname:value datasette.readthedocs.io/en/latest/config.html",
|
|
multiple=True,
|
|
)
|
|
@click.option("--version-note", help="Additional note to show on /-/versions")
|
|
@click.option(
|
|
"--help-config",
|
|
is_flag=True,
|
|
help="Show available config options",
|
|
)
|
|
def serve(
|
|
files,
|
|
host,
|
|
port,
|
|
debug,
|
|
reload,
|
|
cors,
|
|
sqlite_extensions,
|
|
inspect_file,
|
|
metadata,
|
|
template_dir,
|
|
plugins_dir,
|
|
static,
|
|
config,
|
|
version_note,
|
|
help_config,
|
|
):
|
|
"""Serve up specified SQLite database files with a web UI"""
|
|
if help_config:
|
|
formatter = formatting.HelpFormatter()
|
|
with formatter.section("Config options"):
|
|
formatter.write_dl([
|
|
(option.name, '{} (default={})'.format(
|
|
option.help, option.default
|
|
))
|
|
for option in CONFIG_OPTIONS
|
|
])
|
|
click.echo(formatter.getvalue())
|
|
sys.exit(0)
|
|
if reload:
|
|
import hupper
|
|
|
|
reloader = hupper.start_reloader("datasette.cli.serve")
|
|
if metadata:
|
|
reloader.watch_files([metadata.name])
|
|
|
|
inspect_data = None
|
|
if inspect_file:
|
|
inspect_data = json.load(open(inspect_file))
|
|
|
|
metadata_data = None
|
|
if metadata:
|
|
metadata_data = json.loads(metadata.read())
|
|
|
|
click.echo("Serve! files={} on port {}".format(files, port))
|
|
ds = Datasette(
|
|
files,
|
|
cache_headers=not debug and not reload,
|
|
cors=cors,
|
|
inspect_data=inspect_data,
|
|
metadata=metadata_data,
|
|
sqlite_extensions=sqlite_extensions,
|
|
template_dir=template_dir,
|
|
plugins_dir=plugins_dir,
|
|
static_mounts=static,
|
|
config=dict(config),
|
|
version_note=version_note,
|
|
)
|
|
# Force initial hashing/table counting
|
|
ds.inspect()
|
|
ds.app().run(host=host, port=port, debug=debug)
|