Skip to content

Commit

Permalink
Implements #24 support for serving multiple click scripts.
Browse files Browse the repository at this point in the history
  • Loading branch information
fredrik-corneliusson committed Sep 26, 2024
1 parent d98693d commit cad1bac
Show file tree
Hide file tree
Showing 8 changed files with 160 additions and 71 deletions.
59 changes: 26 additions & 33 deletions click_web/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@

jinja_env = jinja2.Environment(extensions=['jinja2.ext.do'])

'The full path to the click script file to execute.'
script_file = None
'The click root command to serve'
click_root_cmd = None


def _get_output_folder():
_output_folder = (Path(tempfile.gettempdir()) / 'click-web')
Expand All @@ -24,10 +19,6 @@ def _get_output_folder():
return _output_folder


'Where to place result files for download'
OUTPUT_FOLDER = str(_get_output_folder())

_flask_app = None
logger = None


Expand All @@ -48,43 +39,45 @@ def create_click_web_app(module, command: click.BaseCommand, root='/'):
app = create_click_web_app(a_click_script, a_click_script.a_group_or_command)
"""
global _flask_app, logger
assert _flask_app is None, "Flask App already created."

_register(module, command)

_flask_app = Flask(__name__, static_url_path=root.rstrip('/') + '/static')

_flask_app.config['APPLICATION_ROOT'] = root
global logger
root = root.rstrip('/')
app = Flask(__name__, static_url_path=root + '/static')
app.config['APPLICATION_ROOT'] = root
app.config['OUTPUT_FOLDER'] = str(_get_output_folder())

_register(app, module, command)

# add the "do" extension needed by our jinja templates
_flask_app.jinja_env.add_extension('jinja2.ext.do')
app.jinja_env.add_extension('jinja2.ext.do')

_flask_app.add_url_rule(root + '/', 'index', click_web.resources.index.index)
_flask_app.add_url_rule(root + '/<path:command_path>', 'command', click_web.resources.cmd_form.get_form_for)
app.add_url_rule(root + '/', 'index', click_web.resources.index.index)
app.add_url_rule(root + '/<path:command_path>', 'command', click_web.resources.cmd_form.get_form_for)

executor = click_web.resources.cmd_exec.Executor()
_flask_app.add_url_rule(root + '/<path:command_path>', 'command_execute', executor.exec,
methods=['POST'])
executor = click_web.resources.cmd_exec.Executor(app)
app.add_url_rule(root + '/<path:command_path>', 'command_execute', executor.exec,
methods=['POST'])

_flask_app.logger.info(f'OUTPUT_FOLDER: {OUTPUT_FOLDER}')
results_blueprint = Blueprint('results', __name__, static_url_path=root + '/static/results',
static_folder=OUTPUT_FOLDER)
_flask_app.register_blueprint(results_blueprint)
app.logger.info(f"OUTPUT_FOLDER: {app.config['OUTPUT_FOLDER']}")
results_blueprint = Blueprint('results',
__name__,
static_url_path=root + '/static/results',
static_folder=app.config['OUTPUT_FOLDER']
)
app.register_blueprint(results_blueprint)

logger = _flask_app.logger
logger = app.logger

return _flask_app
return app


def _register(module, command: click.BaseCommand):
def _register(app, module, command: click.BaseCommand):
"""
:param module: the module that contains the command, needed to get the path to the script.
:param command: The actual click root command, needed to be able to read the command tree and arguments
in order to generate the index page and the html forms
"""
global click_root_cmd, script_file
script_file = str(Path(module.__file__).absolute())
click_root_cmd = command
# The full path to the click script file to execute.
app.config['CLICK_WEB_SCRIPT_FILE'] = str(Path(module.__file__).absolute())
# The click root command to serve
app.config['CLICK_WEB_ROOT_CMD'] = command
37 changes: 21 additions & 16 deletions click_web/resources/cmd_exec.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pathlib import Path
from typing import List, Union

from flask import Response, request
from flask import Response, current_app, request, stream_with_context
from werkzeug.utils import secure_filename

import click_web
Expand All @@ -34,7 +34,8 @@
class Executor:
RAW_CMD_PATH = "_rawcmd"

def __init__(self):
def __init__(self, flask_app):
self._flask_app = flask_app
self.returncode = None
self._command_line = None

Expand All @@ -59,9 +60,9 @@ def _exec_raw(self, command):
For example:
print-lines 5 --delay 1 --message Red
"""
self._command_line = CommandLineRaw(click_web.script_file, command)
self._command_line = CommandLineRaw(current_app.config['CLICK_WEB_SCRIPT_FILE'], command)

def generate():
def _generate_output():
try:
yield from self._run_script_and_generate_stream()
except Exception as e:
Expand All @@ -76,16 +77,19 @@ def generate():
else:
yield json.dumps({"result": "ERROR", "returncode": self.returncode,
"message": f'Script exited with error code: {self.returncode}'})

return Response(generate(), content_type='text/plain; charset=utf-8')
return Response(
# stream_with_context needed to make falsk request context available in generator.
stream_with_context(_generate_output()),
content_type='text/plain; charset=utf-8')

def _exec_html(self, command_path):
"""
Execute the command and stream the output from it as response
:param command_path:
"""
root_command, *commands = command_path.split('/')
self._command_line = CommandLineForm(click_web.script_file, commands)

self._command_line = CommandLineForm(current_app.config['CLICK_WEB_SCRIPT_FILE'], commands)

def _generate_output():
yield self._create_cmd_header(commands)
Expand All @@ -99,15 +103,18 @@ def _generate_output():

yield from self._create_result_footer()

return Response(_generate_output(), mimetype='text/plain')
return Response(
# stream_with_context needed to make falsk request context available in generator.
stream_with_context(_generate_output()),
mimetype='text/plain')

def _run_script_and_generate_stream(self):
"""
Execute the command the via Popen and yield output
"""
logger.info('Executing: %s', self._command_line.get_commandline(obfuscate=True))
if not os.environ.get('PYTHONIOENCODING'):
# Fix unicode on windows
# Fix Unicode on windows
os.environ['PYTHONIOENCODING'] = 'UTF-8'

process = subprocess.Popen(self._command_line.get_commandline(),
Expand Down Expand Up @@ -160,7 +167,7 @@ def _create_result_footer(self):
lines.append('<b>Result files:</b><br>')
for fi in to_download:
lines.append('<ul> ')
lines.append(f'<li>{_get_download_link(fi)}<br>')
lines.append(f'<li>{_get_download_link(self._flask_app, fi)}<br>')
lines.append('</ul>')

if self.returncode == 0:
Expand All @@ -174,11 +181,9 @@ def _create_result_footer(self):
yield html_str


def _get_download_link(field_info):
"""Hack as url_for need request context"""

rel_file_path = Path(field_info.file_path).relative_to(click_web.OUTPUT_FOLDER)
uri = f'/static/results/{rel_file_path.as_posix()}'
def _get_download_link(flask_app, field_info):
rel_file_path = Path(field_info.file_path).relative_to(flask_app.config['OUTPUT_FOLDER'])
uri = request.script_root + f'/static/results/{rel_file_path.as_posix()}'
return f'<a href="{uri}">{field_info.link_name}</a>'


Expand Down Expand Up @@ -431,7 +436,7 @@ def before_script_execute(self):
@classmethod
def temp_dir(cls):
if not cls._temp_dir:
cls._temp_dir = tempfile.mkdtemp(dir=click_web.OUTPUT_FOLDER)
cls._temp_dir = tempfile.mkdtemp(dir=current_app.config['OUTPUT_FOLDER'])
logger.info(f'Temp dir: {cls._temp_dir}')
return cls._temp_dir

Expand Down
6 changes: 3 additions & 3 deletions click_web/resources/cmd_form.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@
from typing import List, Tuple

import click
from flask import abort, render_template
from flask import abort, current_app, render_template

import click_web
from click_web.exceptions import CommandNotFound
from click_web.resources.input_fields import get_input_field

Expand All @@ -30,7 +29,8 @@ def _get_commands_by_path(command_path: str) -> List[Tuple[click.Context, click.
"""
command_path_items = command_path.split('/')
command_name, *command_path_items = command_path_items
command = click_web.click_root_cmd
command = current_app.config['CLICK_WEB_ROOT_CMD']

if command.name != command_name:
raise CommandNotFound('Failed to find root command {}. There is a root command named:{}'
.format(command_name, command.name))
Expand Down
15 changes: 8 additions & 7 deletions click_web/resources/index.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
from typing import Union

import click
from flask import render_template

import click_web
from flask import current_app, render_template, request


def index():
with click.Context(click_web.click_root_cmd, info_name=click_web.click_root_cmd.name, parent=None) as ctx:
return render_template('show_tree.html.j2', ctx=ctx, tree=_click_to_tree(ctx, click_web.click_root_cmd))
click_root_cmd = current_app.config['CLICK_WEB_ROOT_CMD']
with click.Context(click_root_cmd, info_name=click_root_cmd.name, parent=None) as ctx:
return render_template(
'show_tree.html.j2',
ctx=ctx,
tree=_click_to_tree(ctx, click_root_cmd))


def _click_to_tree(ctx: click.Context, node: Union[click.Command, click.MultiCommand], ancestors: list = None):
Expand All @@ -36,8 +38,7 @@ def _click_to_tree(ctx: click.Context, node: Union[click.Command, click.MultiCom
res['short_help'] = node.get_short_help_str().split('\b')[0]
res['help'] = node.help
path_parts = ancestors + [node]
root = click_web._flask_app.config['APPLICATION_ROOT'].rstrip('/')
res['path'] = root + '/' + '/'.join(p.name for p in path_parts)
res['path'] = request.script_root + '/' + '/'.join(p.name for p in path_parts)
if res_childs:
res['childs'] = res_childs
return res
5 changes: 2 additions & 3 deletions click_web/templates/command_form.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@
<div class="command-title-parents">{{ levels[1:-1]| join(' - ', attribute='command.name')|title }}</div>
<h1 class="command-title">{{ command.name|title }}</h1>
<div class="command-help">{{ command.html_help }}</div>

<form id="inputform"
method="post"
action="{{ request.path }}"
onsubmit="return postAndRead('{{ request.path }}');"
action="{{ request.script_root }}{{ request.path }}"
onsubmit="return postAndRead('{{ request.script_root }}{{ request.path }}');"
class="pure-form pure-form-aligned"
enctype="multipart/form-data">
{% set command_list = [] %}
Expand Down
53 changes: 53 additions & 0 deletions example/multi_app/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""
This example shows how to serve multiple click scripts at the same time under different url paths.
It uses Flask DispatcherMiddleware in order to route to each individual Flask app for each Click command and provides a
hard coded index page to reach them.
"""
from flask import Flask, render_template_string
from werkzeug.middleware.dispatcher import DispatcherMiddleware

from click_web import create_click_web_app
from example import example_command
from example.multi_app import example_command2

# Create the main Flask app
app = Flask(__name__)


# Create the index route in the main app
@app.route('/')
def index():
commands = [
{'name': 'Command 1', 'path': '/command1'},
{'name': 'Command 2', 'path': '/command2'},
]
return render_template_string('''
<!doctype html>
<html lang="en">
<head>
<title>Available Commands</title>
</head>
<body>
<h1>Available Commands</h1>
<ul>
{% for cmd in commands %}
<li><a href="{{ cmd.path }}">{{ cmd.name }}</a></li>
{% endfor %}
</ul>
</body>
</html>
''', commands=commands)


# Create individual Flask apps for each Click command
app1 = create_click_web_app(example_command, example_command.cli)
app2 = create_click_web_app(example_command2, example_command2.cli)

# Wrap the original app.wsgi_app with DispatcherMiddleware
app.wsgi_app = DispatcherMiddleware(app.wsgi_app, {
'/command1': app1,
'/command2': app2
})

if __name__ == '__main__':
app.run()
32 changes: 32 additions & 0 deletions example/multi_app/example_command2.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import time

import click

DEBUG = False


@click.group()
@click.option("--debug/--no-debug", help='set debug flag')
def cli(debug):
'Another example click script to test click-web'
global DEBUG
DEBUG = debug


@cli.command()
@click.option("--delay", type=float, default=0.01, required=True, help='delay between each print line')
@click.option("--message", type=click.Choice(['Red', 'Blue']), default='Blue', required=True,
help='Message to print.')
@click.argument("lines", default=10, type=int)
def print_lines(lines, message, delay):
"Just print lines with delay (demonstrates the output streaming to browser)"
if DEBUG:
click.echo("global debug set, printing some debug output")
click.echo(f"writing: {lines} lines with delay {delay}s")
for i in range(lines):
click.echo(f"{message} row: {i}")
time.sleep(delay)


if __name__ == '__main__':
cli()
Loading

0 comments on commit cad1bac

Please sign in to comment.