Sample CLI
gway web.app build-url
Full Code
def build_url(prefix, *args, **kwargs):
path = "/".join(str(a).strip("/") for a in args if a)
url = f"/{prefix}/{path}"
if kwargs:
url += "?" + urlencode(kwargs)
return url
Sample CLI
gway web.app cookies-enabled
Full Code
def cookies_enabled():
return request.get_cookie("cookies_accepted") == "yes"
Sample CLI
gway web.app redirect-error
References
['debug', 'error', 'exception']
Full Code
def redirect_error(error=None, note="", default="/gway/readme", broken_view_name=None):
from bottle import request, response
gw.error("Redirecting due to error." + (" " + note if note else ""))
gw.error(f"Method: {request.method}")
gw.error(f"Path: {request.path}")
gw.error(f"Full URL: {request.url}")
gw.error(f"Query: {dict(request.query)}")
try:
if request.json:
gw.error(f"JSON body: {request.json}")
elif request.forms:
gw.error(f"Form data: {request.forms.decode()}")
except Exception as e:
gw.exception(e)
gw.error(f"Headers: {dict(request.headers)}")
gw.error(f"Cookies: {request.cookies}")
if error:
gw.exception(error)
if broken_view_name and cookies_enabled():
gw.debug(f"Removing cookie for {broken_view_name=}")
raw = request.get_cookie("visited", "")
visited = raw.split("|") if raw else []
broken_title = broken_view_name.replace("-", " ").replace("_", " ").title()
visited = [v for v in visited if not v.startswith(f"{broken_title}=")]
response.set_cookie("visited", "|".join(visited), path="/")
response.status = 302
response.set_header("Location", default)
return ""
Sample CLI
gway web.app render-navbar
References
['qr', 'qr.generate_url', 'resource']
Full Code
def render_navbar(visited, path, current_url=None):
if not cookies_enabled() or len(visited) < 1:
visited = ["Readme=gway/readme"]
links = ""
seen = set()
for entry in reversed(visited):
if "=" not in entry:
continue
title, route = entry.split("=", 1)
if title in seen:
continue
seen.add(title)
links += f'<li><a href="/{route}">{title}</a></li>'
search_box = f'''
<form action="/{path}/help" method="get" class="navbar">
<input type="text" name="topic" placeholder="Search GWAY" class="help" />
</form>
'''
qr_html = ""
if current_url:
qr_url = gw.qr.generate_url(current_url)
qr_html = f'''
<div class="qr">
<p class="qr">QR Code for this page:</p>
<img src="{qr_url}" alt="QR Code" class="navbar-qr" />
</div>
'''
styles_dir = gw.resource("data", "static", "styles")
available_styles = sorted(
f for f in os.listdir(styles_dir)
if f.endswith(".css") and os.path.isfile(os.path.join(styles_dir, f))
)
current_style = request.get_cookie("css") or "default.css"
options = "\n".join(
f'<option value="{s}"{" selected" if s == current_style else ""}>{s[:-4]}</option>'
for s in available_styles
)
style_selector = f'''
<form method="get" class="style-form">
<label for="css-style">Style:</label>
<select id="css-style" name="css" class="style-selector" onchange="this.form.submit()">
{options}
</select>
<noscript><button type="submit">Set</button></noscript>
</form>
'''
return f"<aside>{search_box}<ul>{links}</ul><br>{qr_html}<br>{style_selector}</aside>"
Sample CLI
gway web.app render-template
Full Code
def render_template(*, title="GWAY", navbar="", content="", static="static", css_files=None):
global _version
version = _version = _version or gw.version()
css_files = css_files or ["default.css"]
css_links = "\n".join(
f'<link rel="stylesheet" href="/{static}/styles/{css}">' for css in css_files
)
favicon = f'<link rel="icon" href="/{static}/favicon.ico" type="image/x-icon" />'
credits = f'''
<p>GWAY is powered by <a href="https://www.python.org/">Python 3.13</a>.
Hosting by <a href="https://www.gelectriic.com/">Gelectriic Solutions</a>
and <a href="https://github.com/arthexis/gway">Github</a>.</p>
'''
return template("""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>{{!title}}</title>
{{!css_links}}
{{!favicon}}
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
{{!navbar}}
<main>{{!content}}</main>
<br/><footer><p>This website was built, tested and released with
<a href="/gway/readme">GWAY</a>
<a href="https://pypi.org/project/gway/{{!version}}/">v{{!version}}</a>.</p>
{{!credits}}
</footer>
</body>
</html>
""", **locals())
Sample CLI
gway web.app security-middleware
Full Code
def security_middleware(app):
global _version
_version = _version or gw.version()
def wrapped_app(environ, start_response):
def custom_start_response(status, headers, exc_info=None):
headers = [(k, v) for k, v in headers if k.lower() != "server"]
headers += [
("Cache-Control", "no-cache"),
("X-Content-Type-Options", "nosniff"),
("Server", f"GWAY v{_version}"),
]
return start_response(status, headers, exc_info)
original_set_cookie = response.set_cookie
@wraps(original_set_cookie)
def secure_set_cookie(name, value, **kwargs):
is_secure = request.urlparts.scheme == "https"
kwargs.setdefault("secure", is_secure)
kwargs.setdefault("httponly", True)
kwargs.setdefault("samesite", "Lax")
kwargs.setdefault("path", "/")
return original_set_cookie(name, value, **kwargs)
response.set_cookie = secure_set_cookie
return app(environ, custom_start_response)
return wrapped_app
Sample CLI
gway web.app setup
References
['debug', 'info', 'resource', 'to_html', 'to_list', 'unwrap', 'version', 'web', 'web.app_url', 'web.redirect_error', 'web.static_url', 'web.work_url']
Full Code
def setup(*,
app=None,
project="web.site",
path=None,
static="static",
work="work",
home: str = "readme",
prefix: str = "view",
navbar: bool = True,
):
"""
Configure one or more Bottle-based apps. Use web server start-app to launch.
"""
global _version
_version = _version or gw.version()
# Normalize `project` into a list of project names
projects = gw.to_list(project, flat=True)
# Determine default `path` if not provided
if path is None:
first_proj = projects[0]
path = "gway" if first_proj == "web.site" else first_proj.replace(".", "/")
_is_new_app = not (app := gw.unwrap(app, Bottle) if (oapp := app) else None)
gw.debug(f"Unwrapped {app=} from {oapp=} ({_is_new_app=})")
if _is_new_app:
gw.info("No Bottle app found; creating a new Bottle app.")
app = Bottle()
# Define URL-building helpers
gw.web.static_url = lambda *args, **kwargs: build_url(static, *args, **kwargs)
gw.web.work_url = lambda *args, **kwargs: build_url(work, *args, **kwargs)
gw.web.app_url = lambda *args, **kwargs: build_url(path, *args, **kwargs)
gw.web.redirect_error = redirect_error
@app.route("/accept-cookies", method="POST")
def accept_cookies():
response.set_cookie("cookies_accepted", "yes")
redirect_url = request.forms.get("next", "/readme")
response.status = 303
if not redirect_url.startswith("/"):
redirect_url = f"/{redirect_url}"
response.set_header("Location", f"/{path}{redirect_url}")
return ""
if static:
@app.route(f"/{static}/<filename:path>")
def send_static(filename):
return static_file(filename, root=gw.resource("data", "static"))
if work:
@app.route(f"/{work}/<filename:path>")
def send_work(filename):
filename = filename.replace('-', '_')
return static_file(filename, root=gw.resource("work", "shared"))
# TODO: When passing a param to a view, such as from a form that was left empty by the user
# avoid passing None, instead pass "" (empty string) as it may differentiate between a
# form that has not been submitted and one submitted with empty fields.
@app.route(f"/{path}/<view:path>", method=["GET", "POST"])
def view_dispatch(view):
nonlocal navbar
segments = [s for s in view.strip("/").split("/") if s]
if not segments:
segments = [home]
view_name = segments[0].replace("-", "_")
args = segments[1:]
kwargs = dict(request.query)
if request.method == "POST":
try:
if request.json:
kwargs.update(request.json)
elif request.forms:
kwargs.update(request.forms.decode())
except Exception as e:
return redirect_error(e, note="Error loading JSON payload", broken_view_name=view_name)
sources = []
for proj_name in projects:
try:
sources.append(gw[proj_name])
except Exception:
continue
for source in sources:
view_func = getattr(source, f"{prefix}_{view_name}", None)
if callable(view_func):
break
else:
return redirect_error(
note=f"View '{prefix}_{view_name}' not found in any project: {projects}",
broken_view_name=view_name,
default=f"/{path}/{home}" if path and home else "/gway/readme"
)
try:
gw.debug(f"Dispatch to {view_func.__name__} (args={args}, kwargs={kwargs})")
content = view_func(*args, **kwargs)
if content and not isinstance(content, str):
content = gw.to_html(content)
visited = update_visited(view_name)
except HTTPResponse as resp:
return resp
except Exception as e:
return redirect_error(e, note="Error during view execution", broken_view_name=view_name)
full_url = request.fullpath
if request.query_string:
full_url += "?" + request.query_string
if navbar is True:
navbar = render_navbar(visited, path, current_url=full_url)
if not cookies_enabled():
consent_box = f"""
<div class="consent-box">
<form action="/accept-cookies" method="post">
<input type="hidden" name="next" value="/{view}" />
This site uses cookies to improve your experience.
<button type="submit">Accept</button>
</form>
</div>
"""
content = consent_box + content
style_param = request.query.get("css") or request.query.get("style")
if style_param:
if not style_param.endswith(".css"):
style_param += ".css"
response.set_cookie("css", style_param, path="/")
css_files = ["default.css", style_param]
else:
css_cookie = request.get_cookie("css", "")
css_files = ["default.css"] + [c.strip() for c in css_cookie.split(",") if c.strip()]
return render_template(
title="GWAY - " + view_name.replace("_", " ").title(),
navbar=navbar,
content=content,
static=static,
css_files=css_files
)
@app.route("/", method=["GET", "POST"])
def index():
response.status = 302
response.set_header("Location", f"/{path}/readme")
return ""
@app.error(404)
def handle_404(error):
fallback = "/gway/readme"
try:
return redirect_error(
error,
note=f"404 Not Found: {request.url}",
default=f"/{path}/{home}" if path and home else fallback
)
except Exception as e:
return redirect_error(e, note="Failed during 404 fallback", default=fallback)
if _is_new_app:
app = security_middleware(app)
return app if not oapp else (oapp, app)
return oapp
Sample CLI
gway web.app static-file
Full Code
def static_file(filename, root,
mimetype=True,
download=False,
charset='UTF-8',
etag=None,
headers=None):
""" Open a file in a safe way and return an instance of :exc:`HTTPResponse`
that can be sent back to the client.
:param filename: Name or path of the file to send, relative to ``root``.
:param root: Root path for file lookups. Should be an absolute directory
path.
:param mimetype: Provide the content-type header (default: guess from
file extension)
:param download: If True, ask the browser to open a `Save as...` dialog
instead of opening the file with the associated program. You can
specify a custom filename as a string. If not specified, the
original filename is used (default: False).
:param charset: The charset for files with a ``text/*`` mime-type.
(default: UTF-8)
:param etag: Provide a pre-computed ETag header. If set to ``False``,
ETag handling is disabled. (default: auto-generate ETag header)
:param headers: Additional headers dict to add to the response.
While checking user input is always a good idea, this function provides
additional protection against malicious ``filename`` parameters from
breaking out of the ``root`` directory and leaking sensitive information
to an attacker.
Read-protected files or files outside of the ``root`` directory are
answered with ``403 Access Denied``. Missing files result in a
``404 Not Found`` response. Conditional requests (``If-Modified-Since``,
``If-None-Match``) are answered with ``304 Not Modified`` whenever
possible. ``HEAD`` and ``Range`` requests (used by download managers to
check or continue partial downloads) are also handled automatically.
"""
root = os.path.join(os.path.abspath(root), '')
filename = os.path.abspath(os.path.join(root, filename.strip('/\\')))
headers = headers.copy() if headers else {}
getenv = request.environ.get
if not filename.startswith(root):
return HTTPError(403, "Access denied.")
if not os.path.exists(filename) or not os.path.isfile(filename):
return HTTPError(404, "File does not exist.")
if not os.access(filename, os.R_OK):
return HTTPError(403, "You do not have permission to access this file.")
if mimetype is True:
name = download if isinstance(download, str) else filename
mimetype, encoding = mimetypes.guess_type(name)
if encoding == 'gzip':
mimetype = 'application/gzip'
elif encoding: # e.g. bzip2 -> application/x-bzip2
mimetype = 'application/x-' + encoding
if charset and mimetype and 'charset=' not in mimetype \
and (mimetype[:5] == 'text/' or mimetype == 'application/javascript'):
mimetype += '; charset=%s' % charset
if mimetype:
headers['Content-Type'] = mimetype
if download is True:
download = os.path.basename(filename)
if download:
download = download.replace('"','')
headers['Content-Disposition'] = 'attachment; filename="%s"' % download
stats = os.stat(filename)
headers['Content-Length'] = clen = stats.st_size
headers['Last-Modified'] = email.utils.formatdate(stats.st_mtime, usegmt=True)
headers['Date'] = email.utils.formatdate(time.time(), usegmt=True)
if etag is None:
etag = '%d:%d:%d:%d:%s' % (stats.st_dev, stats.st_ino, stats.st_mtime,
clen, filename)
etag = hashlib.sha1(tob(etag)).hexdigest()
if etag:
headers['ETag'] = etag
check = getenv('HTTP_IF_NONE_MATCH')
if check and check == etag:
return HTTPResponse(status=304, **headers)
ims = getenv('HTTP_IF_MODIFIED_SINCE')
if ims:
ims = parse_date(ims.split(";")[0].strip())
if ims is not None and ims >= int(stats.st_mtime):
return HTTPResponse(status=304, **headers)
body = '' if request.method == 'HEAD' else open(filename, 'rb')
headers["Accept-Ranges"] = "bytes"
range_header = getenv('HTTP_RANGE')
if range_header:
ranges = list(parse_range_header(range_header, clen))
if not ranges:
return HTTPError(416, "Requested Range Not Satisfiable")
offset, end = ranges[0]
rlen = end - offset
headers["Content-Range"] = "bytes %d-%d/%d" % (offset, end - 1, clen)
headers["Content-Length"] = str(rlen)
if body: body = _closeiter(_rangeiter(body, offset, rlen), body.close)
return HTTPResponse(body, status=206, **headers)
return HTTPResponse(body, **headers)
Sample CLI
gway web.app template
Full Code
def template(*args, **kwargs):
"""
Get a rendered template as a string iterator.
You can use a name, a filename or a template string as first parameter.
Template rendering arguments can be passed as dictionaries
or directly (as keyword arguments).
"""
tpl = args[0] if args else None
for dictarg in args[1:]:
kwargs.update(dictarg)
adapter = kwargs.pop('template_adapter', SimpleTemplate)
lookup = kwargs.pop('template_lookup', TEMPLATE_PATH)
tplid = (id(lookup), tpl)
if tplid not in TEMPLATES or DEBUG:
settings = kwargs.pop('template_settings', {})
if isinstance(tpl, adapter):
TEMPLATES[tplid] = tpl
if settings: TEMPLATES[tplid].prepare(**settings)
elif "\n" in tpl or "{" in tpl or "%" in tpl or '$' in tpl:
TEMPLATES[tplid] = adapter(source=tpl, lookup=lookup, **settings)
else:
TEMPLATES[tplid] = adapter(name=tpl, lookup=lookup, **settings)
if not TEMPLATES[tplid]:
abort(500, 'Template (%s) not found' % tpl)
return TEMPLATES[tplid].render(kwargs)
Sample CLI
gway web.app update-visited
Full Code
def update_visited(current, cookie_name="visited"):
if not cookies_enabled():
return []
raw = request.get_cookie(cookie_name, "")
visited = raw.split("|") if raw else []
title = current.replace("-", " ").replace("_", " ").title()
visited = [v for v in visited if not v.startswith(f"{title}=")]
route = request.fullpath.lstrip("/")
visited.append(f"{title}={route}")
cookie_value = "|".join(visited)
response.set_cookie(cookie_name, cookie_value)
return visited
Sample CLI
gway web.app urlencode
Full Code
def urlencode(query, doseq=False, safe='', encoding=None, errors=None,
quote_via=quote_plus):
"""Encode a dict or sequence of two-element tuples into a URL query string.
If any values in the query arg are sequences and doseq is true, each
sequence element is converted to a separate parameter.
If the query arg is a sequence of two-element tuples, the order of the
parameters in the output will match the order of parameters in the
input.
The components of a query arg may each be either a string or a bytes type.
The safe, encoding, and errors parameters are passed down to the function
specified by quote_via (encoding and errors only if a component is a str).
"""
if hasattr(query, "items"):
query = query.items()
else:
# It's a bother at times that strings and string-like objects are
# sequences.
try:
# non-sequence items should not work with len()
# non-empty strings will fail this
if len(query) and not isinstance(query[0], tuple):
raise TypeError
# Zero-length sequences of all types will get here and succeed,
# but that's a minor nit. Since the original implementation
# allowed empty dicts that type of behavior probably should be
# preserved for consistency
except TypeError as err:
raise TypeError("not a valid non-string sequence "
"or mapping object") from err
l = []
if not doseq:
for k, v in query:
if isinstance(k, bytes):
k = quote_via(k, safe)
else:
k = quote_via(str(k), safe, encoding, errors)
if isinstance(v, bytes):
v = quote_via(v, safe)
else:
v = quote_via(str(v), safe, encoding, errors)
l.append(k + '=' + v)
else:
for k, v in query:
if isinstance(k, bytes):
k = quote_via(k, safe)
else:
k = quote_via(str(k), safe, encoding, errors)
if isinstance(v, bytes):
v = quote_via(v, safe)
l.append(k + '=' + v)
elif isinstance(v, str):
v = quote_via(v, safe, encoding, errors)
l.append(k + '=' + v)
else:
try:
# Is this a sufficient test for sequence-ness?
x = len(v)
except TypeError:
# not a sequence
v = quote_via(str(v), safe, encoding, errors)
l.append(k + '=' + v)
else:
# loop over the sequence
for elt in v:
if isinstance(elt, bytes):
elt = quote_via(elt, safe)
else:
elt = quote_via(str(elt), safe, encoding, errors)
l.append(k + '=' + elt)
return '&'.join(l)
Sample CLI
gway web.app wraps
Full Code
def wraps(wrapped,
assigned = WRAPPER_ASSIGNMENTS,
updated = WRAPPER_UPDATES):
"""Decorator factory to apply update_wrapper() to a wrapper function
Returns a decorator that invokes update_wrapper() with the decorated
function as the wrapper argument and the arguments to wraps() as the
remaining arguments. Default arguments are as for update_wrapper().
This is a convenience function to simplify applying partial() to
update_wrapper().
"""
return partial(update_wrapper, wrapped=wrapped,
assigned=assigned, updated=updated)
Sample CLI
gway web.server start-app
References
['info', 'warning', 'web', 'web.app', 'web.app.setup', 'web.server', 'web.server.start']
Full Code
def start_app(*,
host="[WEBSITE_HOST|127.0.0.1]",
port="[WEBSITE_PORT|8888]",
debug=False,
proxy=None,
app=None,
daemon=False,
threaded=True,
is_worker=False,
):
"""Start an HTTP (WSGI) or ASGI server to host the given application.
- If `app` is a FastAPI instance, runs with Uvicorn.
- If `app` is a WSGI app (Bottle, Paste URLMap, or generic WSGI callables), uses Paste+ws4py or Bottle.
- If `app` is a zero-arg factory, it will be invoked (supporting sync or async factories).
- If `app` is a list of apps, each will be run in its own thread (each on an incremented port).
"""
import inspect
import asyncio
def run_server():
nonlocal app
all_apps = app if iterable(app) else (app, )
# B. Dispatch multiple apps in threads if we aren't already in a worker
if not is_worker and len(all_apps) > 1:
from threading import Thread
from collections import Counter
threads = []
app_types = []
gw.info(f"Starting {len(all_apps)} apps in parallel threads.")
for i, sub_app in enumerate(all_apps):
try:
from fastapi import FastAPI
app_type = "FastAPI" if isinstance(sub_app, FastAPI) else type(sub_app).__name__
except ImportError:
app_type = type(sub_app).__name__
port_i = int(port) + i
gw.info(f" App {i+1}: type={app_type}, port={port_i}")
app_types.append(app_type)
t = Thread(
target=gw.web.server.start,
kwargs=dict(
host=host,
port=port_i,
debug=debug,
proxy=proxy,
app=sub_app,
daemon=daemon,
threaded=threaded,
is_worker=True,
),
daemon=daemon,
)
t.start()
threads.append(t)
type_summary = Counter(app_types)
summary_str = ", ".join(f"{count}×{t}" for t, count in type_summary.items())
gw.info(f"All {len(all_apps)} apps started. Types: {summary_str}")
if not daemon:
for t in threads:
t.join()
return
# 1. If no apps passed, fallback to default app
if not all_apps:
gw.warning(
"Building default app (app is None). Run with --app default to silence.")
app = gw.web.app.setup(app=None)
else:
app = all_apps[0] # Run the first (or only) app normally
# 2. Wrap with proxy if requested
if proxy:
from .proxy import setup_app as setup_proxy
app = setup_proxy(endpoint=proxy, app=app)
# 3. If app is a zero-arg factory, invoke it
if callable(app):
sig = inspect.signature(app)
if len(sig.parameters) == 0:
gw.info(f"Calling app factory: {app}")
maybe_app = app()
if inspect.isawaitable(maybe_app):
maybe_app = asyncio.get_event_loop().run_until_complete(maybe_app)
app = maybe_app
else:
gw.info(f"Detected callable WSGI/ASGI app: {app}")
gw.info(f"Starting {app=} @ {host}:{port}")
# 4. Detect ASGI/FastAPI
try:
from fastapi import FastAPI
is_asgi = isinstance(app, FastAPI)
except ImportError:
is_asgi = False
if is_asgi:
try:
import uvicorn
except ImportError:
raise RuntimeError("uvicorn is required to serve ASGI apps. Please install uvicorn.")
uvicorn.run(
app,
host=host,
port=int(port),
log_level="debug" if debug else "info",
workers=1,
reload=debug,
)
return
# 5. Fallback to WSGI servers
from bottle import run as bottle_run, Bottle
try:
from paste import httpserver
except ImportError:
httpserver = None
try:
from ws4py.server.wsgiutils import WebSocketWSGIApplication
ws4py_available = True
except ImportError:
ws4py_available = False
if httpserver:
httpserver.serve(app, host=host, port=int(port))
elif isinstance(app, Bottle):
bottle_run(
app,
host=host,
port=int(port),
debug=debug,
threaded=threaded,
)
else:
raise TypeError(f"Unsupported WSGI app type: {type(app)}")
if daemon:
return asyncio.to_thread(run_server)
else:
run_server()
Sample CLI
gway web.site view-qr-code
References
['qr', 'qr.generate_url', 'web', 'web.app_url']
Full Code
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>
"""