Skip to content

Commit

Permalink
publish_subcommand hook + default plugins mechanism, used for publish…
Browse files Browse the repository at this point in the history
… heroku/now (#349)

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
  • Loading branch information
Simon Willison authored Jul 26, 2018
1 parent 3ac21c7 commit dbbe707
Show file tree
Hide file tree
Showing 14 changed files with 343 additions and 239 deletions.
11 changes: 11 additions & 0 deletions datasette/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import click
import collections
import hashlib
import importlib
import itertools
import os
import sqlite3
Expand Down Expand Up @@ -41,6 +42,11 @@
from .inspect import inspect_hash, inspect_views, inspect_tables
from .version import __version__

default_plugins = (
"datasette.publish.heroku",
"datasette.publish.now",
)

app_root = Path(__file__).parent.parent

connections = threading.local()
Expand All @@ -49,6 +55,11 @@
pm.add_hookspecs(hookspecs)
pm.load_setuptools_entrypoints("datasette")

# Load default plugins
for plugin in default_plugins:
mod = importlib.import_module(plugin)
pm.register(mod, plugin)


ConfigOption = collections.namedtuple(
"ConfigOption", ("name", "default", "help")
Expand Down
221 changes: 9 additions & 212 deletions datasette/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,32 +4,17 @@
import json
import os
import shutil
from subprocess import call, check_output
from subprocess import call
import sys
from .app import Datasette, DEFAULT_CONFIG, CONFIG_OPTIONS
from .app import Datasette, DEFAULT_CONFIG, CONFIG_OPTIONS, pm
from .utils import (
temporary_docker_directory,
temporary_heroku_directory,
value_as_boolean,
StaticMount,
ValueAsBooleanError,
)


class StaticMount(click.ParamType):
name = "static mount"

def convert(self, value, param, ctx):
if ":" not in value:
self.fail(
'"{}" should be of format mountpoint:directory'.format(value),
param, ctx
)
path, dirpath = value.split(":")
if not os.path.exists(dirpath) or not os.path.isdir(dirpath):
self.fail("%s is not a valid directory path" % value, param, ctx)
return path, dirpath


class Config(click.ParamType):
name = "config"

Expand Down Expand Up @@ -93,202 +78,14 @@ def inspect(files, inspect_file, sqlite_extensions):
open(inspect_file, "w").write(json.dumps(app.inspect(), indent=2))


@cli.command()
@click.argument("publisher", type=click.Choice(["now", "heroku"]))
@click.argument("files", type=click.Path(exists=True), nargs=-1)
@click.option(
"-n",
"--name",
default="datasette",
help="Application name to use when deploying",
)
@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("--force", is_flag=True, help="Pass --force option to now")
@click.option("--branch", help="Install datasette from a GitHub branch e.g. master")
@click.option("--token", help="Auth token to use for deploy (Now only)")
@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 publish(
publisher,
files,
name,
metadata,
extra_options,
force,
branch,
token,
template_dir,
plugins_dir,
static,
install,
spatialite,
version_note,
**extra_metadata
):
"""
Publish specified SQLite database files to the internet along with a datasette API.
Options for PUBLISHER:
* 'now' - You must have Zeit Now installed: https://zeit.co/now
* 'heroku' - You must have Heroku installed: https://cli.heroku.com/
Example usage: datasette publish now my-database.db
"""

def _fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary):
click.secho(
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
bg="red",
fg="white",
bold=True,
err=True,
)
click.echo(
"Follow the instructions at {install_link}".format(
install_link=install_link
),
err=True,
)
sys.exit(1)

if publisher == "now":
_fail_if_publish_binary_not_installed("now", "Zeit Now", "https://zeit.co/now")
if extra_options:
extra_options += " "
else:
extra_options = ""
extra_options += "--config force_https_urls:on"

with temporary_docker_directory(
files,
name,
metadata,
extra_options,
branch,
template_dir,
plugins_dir,
static,
install,
spatialite,
version_note,
extra_metadata,
):
args = []
if force:
args.append("--force")
if token:
args.append("--token={}".format(token))
if args:
call(["now"] + args)
else:
call("now")

elif publisher == "heroku":
_fail_if_publish_binary_not_installed(
"heroku", "Heroku", "https://cli.heroku.com"
)
if spatialite:
click.secho(
"The --spatialite option is not yet supported for Heroku",
bg="red",
fg="white",
bold=True,
err=True,
)
click.echo(
"See https://github.com/simonw/datasette/issues/301",
err=True,
)
sys.exit(1)

# Check for heroku-builds plugin
plugins = [
line.split()[0] for line in check_output(["heroku", "plugins"]).splitlines()
]
if b"heroku-builds" not in plugins:
click.echo(
"Publishing to Heroku requires the heroku-builds plugin to be installed."
)
click.confirm(
"Install it? (this will run `heroku plugins:install heroku-builds`)",
abort=True,
)
call(["heroku", "plugins:install", "heroku-builds"])

with temporary_heroku_directory(
files,
name,
metadata,
extra_options,
branch,
template_dir,
plugins_dir,
static,
install,
extra_metadata,
):

app_name = None
if name:
# Check to see if this app already exists
list_output = check_output(["heroku", "apps:list", "--json"]).decode('utf8')
apps = json.loads(list_output)
@cli.group()
def publish():
"Publish specified SQLite database files to the internet along with a Datasette-powered interface and API"
pass

for app in apps:
if app['name'] == name:
app_name = name
break

if not app_name:
# Create a new app
cmd = ["heroku", "apps:create"]
if name:
cmd.append(name)
cmd.append("--json")
create_output = check_output(cmd).decode(
"utf8"
)
app_name = json.loads(create_output)["name"]

call(["heroku", "builds:create", "-a", app_name])
# Register publish plugins
pm.hook.publish_subcommand(publish=publish)


@cli.command()
Expand Down
5 changes: 5 additions & 0 deletions datasette/hookspecs.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,8 @@ def extra_css_urls():
@hookspec
def extra_js_urls():
"Extra JavaScript URLs added by this plugin"


@hookspec
def publish_subcommand(publish):
"Subcommands for 'datasette publish'"
Empty file added datasette/publish/__init__.py
Empty file.
68 changes: 68 additions & 0 deletions datasette/publish/common.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
from ..utils import StaticMount
import click
import shutil
import sys


def add_common_publish_arguments_and_options(subcommand):
for decorator in reversed((
click.argument("files", type=click.Path(exists=True), nargs=-1),
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("--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"),
)):
subcommand = decorator(subcommand)
return subcommand


def fail_if_publish_binary_not_installed(binary, publish_target, install_link):
"""Exit (with error message) if ``binary` isn't installed"""
if not shutil.which(binary):
click.secho(
"Publishing to {publish_target} requires {binary} to be installed and configured".format(
publish_target=publish_target, binary=binary
),
bg="red",
fg="white",
bold=True,
err=True,
)
click.echo(
"Follow the instructions at {install_link}".format(
install_link=install_link
),
err=True,
)
sys.exit(1)
Loading

0 comments on commit dbbe707

Please sign in to comment.