def setup(*,
app=None,
project="web.site",
path=None,
home: str = None,
views: str = "view",
apis: str = "api",
static="static",
shared="shared",
css="global", # Default CSS (without .css extension)
js="global", # Default JS (without .js extension)
auth_required=False, # Default: Don't enforce --optional security
engine="bottle",
):
"""
Setup Bottle web application with symmetrical static/shared public folders.
Only one project per app. CSS/JS params are used as the only static includes.
"""
global _ver, _homes, _enabled
if engine != "bottle":
raise NotImplementedError("Only Bottle is supported at the moment.")
_ver = _ver or gw.version()
bottle.BaseRequest.MEMFILE_MAX = UPLOAD_MB * 1024 * 1024
if not isinstance(project, str) or not project:
gw.abort("Project must be a non-empty string.")
# Track project for later global static collection
_enabled.add(project)
# Always use the given project, never a list
try:
source = gw[project]
except Exception:
gw.abort(f"Project {project} not found in Gateway during app setup.")
# Default path is the dotted project name, minus any leading web/
if path is None:
path = project.replace('.', '/')
if path.startswith('web/'):
path = path.removeprefix('web/')
is_new_app = not (app := gw.unwrap_one(app, Bottle) if (oapp := app) else None)
if is_new_app:
gw.info("No Bottle app found; creating a new Bottle app.")
app = Bottle()
_homes.clear()
add_home(home, path)
@app.route("/", method=["GET", "POST"])
def index():
response.status = 302
response.set_header("Location", default_home())
return ""
@app.error(404)
def handle_404(error):
return gw.web.error.redirect(f"404 Not Found: {request.url}", err=error)
elif home:
add_home(home, path)
# Serve shared files (flat mount)
if shared:
@app.route(f"/{path}/{shared}/<filepath:path>")
@app.route(f"/{shared}/<filepath:path>")
def send_shared(filepath):
file_path = gw.resource("work", "shared", filepath)
if os.path.isfile(file_path):
return static_file(os.path.basename(file_path), root=os.path.dirname(file_path))
return HTTPResponse(status=404, body="shared file not found")
# Serve static files (flat mount)
if static:
@app.route(f"/{path}/{static}/<filepath:path>")
@app.route(f"/{static}/<filepath:path>")
def send_static(filepath):
file_path = gw.resource("data", "static", filepath)
if os.path.isfile(file_path):
return static_file(os.path.basename(file_path), root=os.path.dirname(file_path))
return HTTPResponse(status=404, body="static file not found")
# Main view dispatcher (only if views is not None)
if views:
@app.route(f"/{path}/<view:path>", method=["GET", "POST"])
def view_dispatch(view):
nonlocal home, views
# --- AUTH CHECK ---
if is_enabled('web.auth') and not gw.web.auth.is_authorized(strict=auth_required):
return gw.web.error.unauthorized("Unauthorized: You are not permitted to view this page.")
# Set current endpoint in GWAY context (for helpers/build_url etc)
gw.context['current_endpoint'] = path
segments = [s for s in view.strip("/").split("/") if s]
view_name = segments[0].replace("-", "_") if segments else home
args = segments[1:] if segments else []
kwargs = dict(request.query)
if request.method == "POST":
try:
kwargs.update(request.json or dict(request.forms))
except Exception as e:
return gw.web.error.redirect("Error loading JSON payload", error=e)
target_func_name = f"{views}_{view_name}" if views else view_name
view_func = getattr(source, target_func_name, None)
if not callable(view_func):
return gw.web.error.redirect(f"View not found: {target_func_name} in {project}")
try:
content = view_func(*args, **kwargs)
if isinstance(content, HTTPResponse):
return content
elif isinstance(content, bytes):
response.content_type = "application/octet-stream"
response.body = content
return response
elif content is None:
return ""
elif not isinstance(content, str):
content = gw.to_html(content)
except HTTPResponse as res:
return res
except Exception as e:
return gw.web.error.redirect("Broken view", err=e)
media_origin = "/shared" if shared else ("static" if static else "")
return render_template(
title="GWAY - " + view_func.__name__.replace("_", " ").title(),
content=content,
css_files=(f"{media_origin}/{css}.css",),
js_files=(f"{media_origin}/{js}.js",),
)
# API dispatcher (only if apis is not None)
if apis:
@app.route(f"/api/{path}/<view:path>", method=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"])
def api_dispatch(view):
nonlocal home, apis
# --- AUTH CHECK ---
if is_enabled('web.auth') and not gw.web.auth.is_authorized(strict=auth_required):
return gw.web.error.unauthorized("Unauthorized: API access denied.")
# Set current endpoint in GWAY context (for helpers/build_url etc)
gw.context['current_endpoint'] = path
segments = [s for s in view.strip("/").split("/") if s]
view_name = segments[0].replace("-", "_") if segments else home
args = segments[1:] if segments else []
kwargs = dict(request.query)
if request.method == "POST":
try:
kwargs.update(request.json or dict(request.forms))
except Exception as e:
return gw.web.error.redirect("Error loading JSON payload", err=e)
method = request.method.lower()
specific_af = f"{apis}_{method}_{view_name}"
generic_af = f"{apis}_{view_name}"
api_func = getattr(source, specific_af, None)
if not callable(api_func):
api_func = getattr(source, generic_af, None)
if not callable(api_func):
return gw.web.error.redirect(f"API not found: {specific_af} or {generic_af} in {project}")
try:
result = api_func(*args, **kwargs)
if isinstance(result, HTTPResponse):
return result
response.content_type = "application/json"
return gw.to_json(result)
except HTTPResponse as res:
return res
except Exception as e:
return gw.web.error.redirect("Broken API", err=e)
@app.route("/favicon.ico")
def favicon():
proj_parts = project.split('.')
candidate = gw.resource("data", "static", *proj_parts, "favicon.ico")
if os.path.isfile(candidate):
return static_file("favicon.ico", root=os.path.dirname(candidate))
global_favicon = gw.resource("data", "static", "favicon.ico")
if os.path.isfile(global_favicon):
return static_file("favicon.ico", root=os.path.dirname(global_favicon))
return HTTPResponse(status=404, body="favicon.ico not found")
if gw.verbose:
gw.debug(f"Registered homes: {_homes}")
debug_routes(app)
return oapp if oapp else app
def publish_parts(source, source_path=None, source_class=io.StringInput,
destination_path=None,
reader=None, reader_name='standalone',
parser=None, parser_name='restructuredtext',
writer=None, writer_name='pseudoxml',
settings=None, settings_spec=None,
settings_overrides=None, config_section=None,
enable_exit_status=False):
"""
Set up & run a `Publisher`, and return a dictionary of document parts.
Dictionary keys are the names of parts.
Dictionary values are `str` instances; encoding is up to the client,
e.g.::
parts = publish_parts(...)
body = parts['body'].encode(parts['encoding'], parts['errors'])
See the `API documentation`__ for details on the provided parts.
Parameters: see `publish_programmatically()`.
__ https://docutils.sourceforge.io/docs/api/publisher.html#publish-parts
"""
output, publisher = publish_programmatically(
source=source, source_path=source_path, source_class=source_class,
destination_class=io.StringOutput,
destination=None, destination_path=destination_path,
reader=reader, reader_name=reader_name,
parser=parser, parser_name=parser_name,
writer=writer, writer_name=writer_name,
settings=settings, settings_spec=settings_spec,
settings_overrides=settings_overrides,
config_section=config_section,
enable_exit_status=enable_exit_status)
return publisher.writer.parts
def view_help(topic="", *args, **kwargs):
"""
Render dynamic help based on GWAY introspection and search-style links.
If there is an exact match in the search, show it at the top (highlighted).
"""
# TODO: Change the wat the help system works: Instead of just using the results of
# gw.gelp at all times, compliment this result with other information.
topic_in = topic or ""
topic = topic.replace(" ", "/").replace(".", "/").replace("-", "_") if topic else ""
parts = [p for p in topic.strip("/").split("/") if p]
if not parts:
help_info = gw.help()
title = "Available Projects"
content = "<ul>"
for project in help_info["Available Projects"]:
content += f'<li><a href="?topic={project}">{project}</a></li>'
content += "</ul>"
return f"<h1>{title}</h1>{content}"
elif len(parts) == 1:
project = parts[0]
help_info = gw.help(project)
title = f"Help Topics for <code>{project}</code>"
else:
*project_path, maybe_function = parts
obj = gw
for segment in project_path:
obj = getattr(obj, segment, None)
if obj is None:
return f"<h2>Not Found</h2><p>Project path invalid at <code>{segment}</code>.</p>"
project_str = ".".join(project_path)
if hasattr(obj, maybe_function):
function = maybe_function
help_info = gw.help(project_str, function, full=True)
full_name = f"{project_str}.{function}"
title = f"Help for <code>{full_name}</code>"
else:
help_info = gw.help(project_str)
full_name = f"{project_str}.{maybe_function}"
title = f"Help Topics for <code>{full_name}</code>"
if help_info is None:
return "<h2>Not Found</h2><p>No help found for the given input.</p>"
highlight_js = '''
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
<script>
window.addEventListener('DOMContentLoaded',function(){
if(window.hljs){
document.querySelectorAll('pre code.python').forEach(el => { hljs.highlightElement(el); });
}
});
</script>
'''
# --- Exact match highlighting logic ---
# Only applies if help_info contains "Matches"
if "Matches" in help_info:
matches = help_info["Matches"]
exact_key = (topic_in.replace(" ", "/").replace(".", "/").replace("-", "_")).strip("/")
# Try to find an exact match (project, or project/function) in matches
def canonical_str(m):
p, f = m.get("Project", ""), m.get("Function", "")
return (f"{p}/{f}" if f else p).replace(".", "/").replace("-", "_")
exact = None
exact_idx = -1
for idx, m in enumerate(matches):
if canonical_str(m).lower() == exact_key.lower():
exact = m
exact_idx = idx
break
sections = []
# If found, show exact at top with highlight
if exact is not None:
sections.append('<div class="help-exact">' + _render_help_section(exact, use_query_links=True, highlight=True) + '</div>')
# Add separator if there are more matches
if len(matches) > 1:
sections.append('<hr class="help-sep">')
# Remove exact match from below
rest = [m for i, m in enumerate(matches) if i != exact_idx]
else:
rest = matches
for idx, match in enumerate(rest):
section_html = _render_help_section(match, use_query_links=True)
if idx < len(rest) - 1:
section_html += '<hr class="help-sep">'
sections.append(section_html)
multi = f"<div class='help-multi'>{''.join(sections)}</div>"
if "Full Code" in str(help_info):
multi += highlight_js
return f"<h1>{title}</h1>{multi}"
# Not a multi-match result: just render normally
body = _render_help_section(help_info, use_query_links=True)
if "Full Code" in str(help_info):
body += highlight_js
return f"<h1>{title}</h1>{body}"
def view_qr_code(*args, value=None, **kwargs):
"""Generate a QR code for a given value and serve it from cache if available."""
if not value:
return '''
<h1>QR Code Generator</h1>
<form method="post">
<input type="text" name="value" placeholder="Enter text or URL" required class="main" />
<button type="submit" class="submit">Generate QR</button>
</form>
'''
qr_url = gw.qr.generate_url(value)
back_link = gw.web.app_url("qr-code")
return f"""
<h1>QR Code for:</h1>
<h2><code>{value}</code></h2>
<img src="{qr_url}" alt="QR Code" class="qr" />
<p><a href="{back_link}">Generate another</a></p>
"""
def help(*args, full=False):
from gway import gw
import os, textwrap, ast, sqlite3
gw.info(f"Help on {' '.join(args)} requested")
def extract_gw_refs(source):
refs = set()
try:
tree = ast.parse(source)
except SyntaxError:
return refs
class GwVisitor(ast.NodeVisitor):
def visit_Attribute(self, node):
parts = []
cur = node
while isinstance(cur, ast.Attribute):
parts.append(cur.attr)
cur = cur.value
if isinstance(cur, ast.Name) and cur.id == "gw":
parts.append("gw")
full = ".".join(reversed(parts))[3:] # remove "gw."
refs.add(full)
self.generic_visit(node)
GwVisitor().visit(tree)
return refs
db_path = gw.resource("data", "help.sqlite")
if not os.path.isfile(db_path):
gw.release.build_help_db()
joined_args = " ".join(args).strip().replace("-", "_")
norm_args = [a.replace("-", "_").replace("/", ".") for a in args]
with gw.sql.open_connection(db_path, row_factory=True) as cur:
if not args:
cur.execute("SELECT DISTINCT project FROM help")
return {"Available Projects": sorted([row["project"] for row in cur.fetchall()])}
rows = []
# Case 1: help("web.site.view_help")
if len(norm_args) == 1 and "." in norm_args[0]:
parts = norm_args[0].split(".")
if len(parts) >= 2:
project = ".".join(parts[:-1])
function = parts[-1]
cur.execute("SELECT * FROM help WHERE project = ? AND function = ?", (project, function))
rows = cur.fetchall()
if not rows:
try:
cur.execute("SELECT * FROM help WHERE help MATCH ?", (f'"{norm_args[0]}"',))
rows = cur.fetchall()
except sqlite3.OperationalError as e:
gw.warning(f"FTS query failed for {norm_args[0]}: {e}")
else:
return {"error": f"Could not parse dotted input: {norm_args[0]}"}
# Case 2: help("web", "view_help") or help("builtin", "hello_world")
elif len(norm_args) >= 2:
*proj_parts, maybe_func = norm_args
project = ".".join(proj_parts)
function = maybe_func
cur.execute("SELECT * FROM help WHERE project = ? AND function = ?", (project, function))
rows = cur.fetchall()
if not rows:
fuzzy_query = ".".join(norm_args)
try:
cur.execute("SELECT * FROM help WHERE help MATCH ?", (f'"{fuzzy_query}"',))
rows = cur.fetchall()
except sqlite3.OperationalError as e:
gw.warning(f"FTS fallback failed for {fuzzy_query}: {e}")
# Final fallback: maybe it's a builtin like help("hello_world")
if not rows and len(norm_args) == 1:
name = norm_args[0]
cur.execute("SELECT * FROM help WHERE project = ? AND function = ?", ("builtin", name))
rows = cur.fetchall()
if not rows:
fuzzy_query = ".".join(norm_args)
try:
cur.execute("SELECT * FROM help WHERE help MATCH ?", (f'"{fuzzy_query}"',))
rows = cur.fetchall()
except sqlite3.OperationalError as e:
gw.warning(f"FTS final fallback failed for {fuzzy_query}: {e}")
return {"error": f"No help found and fallback failed for: {joined_args}"}
results = []
for row in rows:
project = row["project"]
function = row["function"]
prefix = f"gway {project} {function.replace('_', '-')}" if project != "builtin" else f"gway {function.replace('_', '-')}"
entry = {
"Project": project,
"Function": function,
"Sample CLI": prefix,
"References": sorted(extract_gw_refs(row["source"])),
}
if full:
entry["Full Code"] = row["source"]
else:
entry["Signature"] = textwrap.fill(row["signature"], 100).strip()
entry["Docstring"] = row["docstring"].strip() if row["docstring"] else None
entry["TODOs"] = row["todos"].strip() if row["todos"] else None
results.append({k: v for k, v in entry.items() if v})
return results[0] if len(results) == 1 else {"Matches": results}