From f48cb705d816563e7c8f1af72e67c5e36cc81dab Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Wed, 15 Nov 2017 11:53:00 -0800 Subject: [PATCH 1/4] Initial cut at `datasette publish heroku` Rather gross, but proves that it works. --- datasette/cli.py | 48 +++++++++++++++++++++++++++++----------------- datasette/utils.py | 46 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 18 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index d4f91bbe..ccac5a74 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -2,11 +2,11 @@ import click from click_default_group import DefaultGroup import json import shutil -from subprocess import call +from subprocess import call, check_output import sys from .app import Datasette from .utils import ( - temporary_docker_directory, + temporary_docker_directory, temporary_heroku_directory ) @@ -26,7 +26,7 @@ def build(files, inspect_file): @cli.command() -@click.argument('publisher', type=click.Choice(['now'])) +@click.argument('publisher', type=click.Choice(['now', 'heroku'])) @click.argument('files', type=click.Path(exists=True), nargs=-1) @click.option( '-n', '--name', default='datasette', @@ -52,22 +52,34 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta Example usage: datasette publish now my-database.db """ - if not shutil.which('now'): - click.secho( - ' The publish command requires "now" to be installed and configured ', - bg='red', - fg='white', - bold=True, - err=True, - ) - click.echo('Follow the instructions at https://zeit.co/now#whats-now', err=True) - sys.exit(1) + if publisher == 'now': + if not shutil.which('now'): + click.secho( + ' The publish command requires "now" to be installed and configured ', + bg='red', + fg='white', + bold=True, + err=True, + ) + click.echo('Follow the instructions at https://zeit.co/now#whats-now', err=True) + sys.exit(1) - with temporary_docker_directory(files, name, metadata, extra_options, extra_metadata): - if force: - call(['now', '--force']) - else: - call('now') + with temporary_docker_directory(files, name, metadata, extra_options, extra_metadata): + if force: + call(['now', '--force']) + else: + call('now') + + elif publisher == 'heroku': + # FIXME: need to verify we have heroku, heroku-builds, and are logged in (ugh) + with temporary_heroku_directory(files, name, metadata, extra_options, extra_metadata): + # build(files) doesn't work, dunno why + app = Datasette(files) + open("inspect-data.json", 'w').write(json.dumps(app.inspect(), indent=2)) + + create_output = check_output(['heroku', 'apps:create', '--json']) + app_name = json.loads(create_output)["name"] + call(["heroku", "builds:create", "-a", app_name]) @cli.command() diff --git a/datasette/utils.py b/datasette/utils.py index 21061cdf..fcccf0b8 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -7,6 +7,7 @@ import sqlite3 import tempfile import time import urllib +import shlex def compound_pks_from_path(path): @@ -197,3 +198,48 @@ def temporary_docker_directory(files, name, metadata, extra_options, extra_metad finally: tmp.cleanup() os.chdir(saved_cwd) + +@contextmanager +def temporary_heroku_directory(files, name, metadata, extra_options, extra_metadata=None): + # FIXME: lots of duplicated code from above + + extra_metadata = extra_metadata or {} + tmp = tempfile.TemporaryDirectory() + saved_cwd = os.getcwd() + + file_paths = [ + os.path.join(saved_cwd, name) + for name in files + ] + file_names = [os.path.split(f)[-1] for f in files] + + if metadata: + metadata_content = json.load(metadata) + else: + metadata_content = {} + for key, value in extra_metadata.items(): + if value: + metadata_content[key] = value + + try: + os.chdir(tmp.name) + + if metadata_content: + open('metadata.json', 'w').write(json.dumps(metadata_content, indent=2)) + + open('runtime.txt', 'w').write('python-3.6.2') + open('requirements.txt', 'w').write('datasette') + + quoted_files = " ".join(map(shlex.quote, files)) + procfile_cmd = f'web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json' + open('Procfile', 'w').write(procfile_cmd) + + for path, filename in zip(file_paths, file_names): + os.link(path, os.path.join(tmp.name, filename)) + + yield + + finally: + tmp.cleanup() + os.chdir(saved_cwd) + From 6eb23d21435eece1ec3c3fe996a9c36b6b967fc7 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Fri, 17 Nov 2017 12:09:01 -0800 Subject: [PATCH 2/4] Moved `datasette build` to a post_compile hook. --- datasette/cli.py | 4 ---- datasette/utils.py | 2 ++ 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index ccac5a74..3381dc88 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -73,10 +73,6 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta elif publisher == 'heroku': # FIXME: need to verify we have heroku, heroku-builds, and are logged in (ugh) with temporary_heroku_directory(files, name, metadata, extra_options, extra_metadata): - # build(files) doesn't work, dunno why - app = Datasette(files) - open("inspect-data.json", 'w').write(json.dumps(app.inspect(), indent=2)) - create_output = check_output(['heroku', 'apps:create', '--json']) app_name = json.loads(create_output)["name"] call(["heroku", "builds:create", "-a", app_name]) diff --git a/datasette/utils.py b/datasette/utils.py index fcccf0b8..825a794e 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -229,6 +229,8 @@ def temporary_heroku_directory(files, name, metadata, extra_options, extra_metad open('runtime.txt', 'w').write('python-3.6.2') open('requirements.txt', 'w').write('datasette') + os.mkdir('bin') + open('bin/post_compile', 'w').write('datasette build --inspect-file inspect-data.json') quoted_files = " ".join(map(shlex.quote, files)) procfile_cmd = f'web: datasette serve --host 0.0.0.0 {quoted_files} --cors --port $PORT --inspect-file inspect-data.json' From 1f79be7e4e203c7d494df21224f77e6a83d63879 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 21 Nov 2017 10:10:48 -0800 Subject: [PATCH 3/4] More error checking and docs --- datasette/cli.py | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/datasette/cli.py b/datasette/cli.py index 42697c56..23fd0f15 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -30,7 +30,7 @@ def build(files, inspect_file): @click.argument('files', type=click.Path(exists=True), nargs=-1) @click.option( '-n', '--name', default='datasette', - help='Application name to use when deploying to Now' + help='Application name to use when deploying to Now (ignored for Heroku)' ) @click.option( '-m', '--metadata', type=click.File(mode='r'), @@ -47,23 +47,27 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta """ Publish specified SQLite database files to the internet along with a datasette API. - Only current option for PUBLISHER is 'now'. You must have Zeit Now installed: - https://zeit.co/now + 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 """ - if publisher == 'now': - if not shutil.which('now'): + 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( - ' The publish command requires "now" to be installed and configured ', + f" Publishing to {publish_target} requires {binary} to be installed and configured ", bg='red', fg='white', bold=True, - err=True, + err=True ) - click.echo('Follow the instructions at https://zeit.co/now#whats-now', err=True) + click.echo(f"Follow the instructions at {install_link}", err=True) sys.exit(1) + if publisher == 'now': + _fail_if_publish_binary_not_installed('now', 'Zeit Now', 'https://zeit.co/now') with temporary_docker_directory(files, name, metadata, extra_options, extra_metadata): if force: call(['now', '--force']) @@ -71,13 +75,20 @@ def publish(publisher, files, name, metadata, extra_options, force, **extra_meta call('now') elif publisher == 'heroku': - # FIXME: need to verify we have heroku, heroku-builds, and are logged in (ugh) + _fail_if_publish_binary_not_installed('heroku', 'Heroku', 'https://cli.heroku.com') + + # Check for heroku-builds plugin + plugins = [line.split()[0] for line in check_output(['heroku', 'plugins']).splitlines()] + if '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, extra_metadata): create_output = check_output(['heroku', 'apps:create', '--json']) app_name = json.loads(create_output)["name"] call(["heroku", "builds:create", "-a", app_name]) - @cli.command() @click.argument('files', type=click.Path(exists=True), nargs=-1, required=True) @click.option( From de42240afd1e3829fd21cbe77a89ab0eaab20d78 Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Tue, 21 Nov 2017 10:51:58 -0800 Subject: [PATCH 4/4] Some bug fixes. --- datasette/cli.py | 2 +- datasette/utils.py | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/datasette/cli.py b/datasette/cli.py index b87f36f1..857320ed 100644 --- a/datasette/cli.py +++ b/datasette/cli.py @@ -81,7 +81,7 @@ def publish(publisher, files, name, metadata, extra_options, force, branch, **ex # Check for heroku-builds plugin plugins = [line.split()[0] for line in check_output(['heroku', 'plugins']).splitlines()] - if 'heroku-builds' not in plugins: + 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"]) diff --git a/datasette/utils.py b/datasette/utils.py index 322ad86f..b5f323e4 100644 --- a/datasette/utils.py +++ b/datasette/utils.py @@ -3,6 +3,7 @@ import base64 import json import os import re +import shlex import sqlite3 import tempfile import time