Skip to content

Commit 7615c84

Browse files
authored
Make server more flexible (#576)
* Added pythonish query language * Moved to object-ql * New version of object_ql * Poke it to make it run tests again * Update pyproject.toml, oql 0.1.2 * Added tests for object-ql * Add more options to server * Update requirements * Update requirements * Use app.logger * Fix log message level on adding a user * Revise flags; messages * Update translogger; linting * Allow gramps_webapi to be called without exiting * Allow gramps_webapi to be called without exiting * Fixed typo * Change message to gramps-web-api server * Added copyright
1 parent 7859d8b commit 7615c84

File tree

4 files changed

+210
-23
lines changed

4 files changed

+210
-23
lines changed

gramps_webapi/__main__.py

Lines changed: 98 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
# Gramps Web API - A RESTful API for the Gramps genealogy program
33
#
44
# Copyright (C) 2020 David Straub
5+
# Copyright (C) 2024 Doug Blank
56
#
67
# This program is free software; you can redistribute it and/or modify
78
# it under the terms of the GNU Affero General Public License as published by
@@ -27,18 +28,19 @@
2728
import sys
2829
import time
2930
import warnings
31+
from threading import Thread
3032

3133
import click
34+
import waitress
35+
import webbrowser
3236

3337
from .api.search import get_search_indexer
3438
from .api.util import get_db_manager, list_trees, close_db
3539
from .app import create_app
3640
from .auth import add_user, delete_user, fill_tree, user_db
3741
from .const import ENV_CONFIG_FILE, TREE_MULTI
3842
from .dbmanager import WebDbManager
39-
40-
logging.basicConfig()
41-
LOG = logging.getLogger("gramps_webapi")
43+
from .translogger import TransLogger
4244

4345

4446
@click.group("cli")
@@ -58,12 +60,80 @@ def cli(ctx, config):
5860

5961
@cli.command("run")
6062
@click.option("-p", "--port", help="Port to use (default: 5000)", default=5000)
61-
@click.option("--tree", help="Tree ID", default=None)
63+
@click.option("-t", "--tree", help="Tree ID: '*' for multi-trees", default=None)
64+
@click.option(
65+
"-o",
66+
"--open-browser",
67+
help="Open gramps-web in browser: 'tab', 'window', or 'no'",
68+
default="no",
69+
type=click.Choice(["tab", "window", "no"], case_sensitive=False),
70+
)
71+
@click.option(
72+
"-d",
73+
"--debug-level",
74+
help="Debug level: 'info', 'debug', 'warning', 'critical'",
75+
default="info",
76+
type=click.Choice(["info", "debug", "warning", "critical"], case_sensitive=False),
77+
)
78+
@click.option("-l", "--log-file", help="Set logging file to this path", default=None)
79+
@click.option(
80+
"--host", help="Set the host address for server to listen on", default="127.0.0.1"
81+
)
82+
@click.option(
83+
"--max-workers",
84+
help="Maximum number of workers for frontend; requires --use-wsgi",
85+
default=None,
86+
)
87+
@click.option("--use-wsgi", is_flag=True, help="Add a wsgi wrapper around server")
6288
@click.pass_context
63-
def run(ctx, port, tree):
89+
def run(
90+
ctx, port, tree, host, open_browser, debug_level, log_file, max_workers, use_wsgi
91+
):
6492
"""Run the app."""
6593
app = ctx.obj["app"]
66-
app.run(port=port, threaded=True)
94+
debug_level = debug_level.upper()
95+
open_browser = open_browser.lower()
96+
97+
if max_workers is None:
98+
max_workers = min(32, os.cpu_count() + 4)
99+
100+
def open_webbrowser_after_start():
101+
# Wait a bit for for server to start:
102+
time.sleep(1.0)
103+
new = {"tab": 2, "window": 1}[open_browser]
104+
webbrowser.open("http://%s:%s" % (host, port), new=0, autoraise=True)
105+
106+
if open_browser != "no":
107+
thread = Thread(target=open_webbrowser_after_start)
108+
thread.start()
109+
110+
if log_file:
111+
file_handler = logging.FileHandler(log_file, "w+")
112+
app.logger.addHandler(file_handler)
113+
app.logger.setLevel(debug_level)
114+
115+
print("Running gramps-web-api server...")
116+
if open_browser != "no":
117+
print(
118+
f" Opening gramps-web in browser {open_browser} on http://{host}:{port}..."
119+
)
120+
121+
print(" Control+C to quit")
122+
if use_wsgi:
123+
waitress.serve(
124+
TransLogger(
125+
app,
126+
setup_console_handler=False,
127+
set_logger_level=debug_level,
128+
),
129+
host=host,
130+
port=port,
131+
threads=max_workers,
132+
)
133+
else:
134+
app.run(port=port, threaded=True)
135+
print()
136+
print("Stopping gramps-web-api server...")
67137

68138

69139
@cli.group("user", help="Manage users.")
@@ -82,8 +152,8 @@ def user(ctx):
82152
@click.pass_context
83153
def user_add(ctx, name, password, fullname, email, role, tree):
84154
"""Add a user."""
85-
LOG.error(f"Adding user {name} ...")
86155
app = ctx.obj["app"]
156+
app.logger.info(f"Adding user {name} ...")
87157
with app.app_context():
88158
user_db.create_all()
89159
add_user(name, password, fullname, email, role, tree)
@@ -94,8 +164,8 @@ def user_add(ctx, name, password, fullname, email, role, tree):
94164
@click.pass_context
95165
def user_del(ctx, name):
96166
"""Delete a user."""
97-
LOG.info(f"Deleting user {name} ...")
98167
app = ctx.obj["app"]
168+
app.logger.info(f"Deleting user {name} ...")
99169
with app.app_context():
100170
delete_user(name)
101171

@@ -146,51 +216,55 @@ def search(ctx, tree, semantic):
146216
ctx.obj["search_indexer"] = get_search_indexer(tree=tree, semantic=semantic)
147217

148218

149-
def progress_callback_count(current: int, total: int, prev: int | None = None) -> None:
219+
def progress_callback_count(
220+
app, current: int, total: int, prev: int | None = None
221+
) -> None:
150222
if total == 0:
151223
return
152224
pct = int(100 * current / total)
153225
if prev is None:
154226
prev = current - 1
155227
pct_prev = int(100 * prev / total)
156228
if current == 0 or pct != pct_prev:
157-
LOG.info(f"Progress: {pct}%")
229+
app.logger.info(f"Progress: {pct}%")
158230

159231

160232
@search.command("index-full")
161233
@click.pass_context
162234
def index_full(ctx):
163235
"""Perform a full reindex."""
164-
LOG.info("Rebuilding search index ...")
236+
app = ctx.obj["app"]
237+
app.logger.info("Rebuilding search index ...")
165238
db_manager = ctx.obj["db_manager"]
166239
indexer = ctx.obj["search_indexer"]
167240
db = db_manager.get_db().db
168241

169242
t0 = time.time()
170243
try:
171-
indexer.reindex_full(db, progress_cb=progress_callback_count)
244+
indexer.reindex_full(app, db, progress_cb=progress_callback_count)
172245
except:
173-
LOG.exception("Error during indexing")
246+
app.logger.exception("Error during indexing")
174247
finally:
175248
close_db(db)
176-
LOG.info(f"Done building search index in {time.time() - t0:.0f} seconds.")
249+
app.logger.info(f"Done building search index in {time.time() - t0:.0f} seconds.")
177250

178251

179252
@search.command("index-incremental")
180253
@click.pass_context
181254
def index_incremental(ctx):
182255
"""Perform an incremental reindex."""
256+
app = ctx.obj["app"]
183257
db_manager = ctx.obj["db_manager"]
184258
indexer = ctx.obj["search_indexer"]
185259
db = db_manager.get_db().db
186260

187261
try:
188262
indexer.reindex_incremental(db, progress_cb=progress_callback_count)
189263
except Exception:
190-
LOG.exception("Error during indexing")
264+
app.logger.exception("Error during indexing")
191265
finally:
192266
close_db(db)
193-
LOG.info("Done updating search index.")
267+
app.logger.info("Done updating search index.")
194268

195269

196270
@cli.group("tree", help="Manage trees.")
@@ -212,7 +286,7 @@ def tree_list(ctx):
212286
print(f"{dirname:>36} {name:<}")
213287

214288

215-
@cli.group("grampsdb", help="Manage a Gramps daabase.")
289+
@cli.group("grampsdb", help="Manage a Gramps database.")
216290
@click.option("--tree", help="Tree ID", default=None)
217291
@click.pass_context
218292
def grampsdb(ctx, tree):
@@ -236,8 +310,10 @@ def migrate_gramps_db(ctx):
236310

237311

238312
if __name__ == "__main__":
239-
LOG.setLevel(logging.INFO)
240-
241-
cli(
242-
prog_name="python3 -m gramps_webapi"
243-
) # pylint:disable=no-value-for-parameter,unexpected-keyword-arg
313+
try:
314+
cli(
315+
prog_name="python3 -m gramps_webapi"
316+
) # pylint:disable=no-value-for-parameter,unexpected-keyword-arg
317+
except SystemExit as e:
318+
if e.code != 0:
319+
raise

gramps_webapi/auth/__init__.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,6 @@
3535

3636
user_db = SQLAlchemy()
3737

38-
3938
def add_user(
4039
name: str,
4140
password: str,

gramps_webapi/translogger.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# -*- coding: utf-8 -*-
2+
# Copyright (c) 2024 Doug Blank <[email protected]>
3+
# Based on:
4+
# Copyright (c) 2005 Ian Bicking and contributors; written for Paste (http://pythonpaste.org)
5+
# Licensed under the MIT license: http://www.opensource.org/licenses/mit-license.php
6+
"""
7+
Middleware for logging requests, using Apache combined log format
8+
"""
9+
10+
import logging
11+
import time
12+
import urllib.parse
13+
14+
15+
class TransLogger:
16+
"""
17+
This logging middleware will log all requests as they go through.
18+
They are, by default, sent to a logger named ``'wsgi'`` at the
19+
INFO level.
20+
21+
If ``setup_console_handler`` is true, then messages for the named
22+
logger will be sent to the console.
23+
"""
24+
25+
format = (
26+
"%(REMOTE_ADDR)s - %(REMOTE_USER)s [%(time)s] "
27+
'"%(REQUEST_METHOD)s %(REQUEST_URI)s %(HTTP_VERSION)s" '
28+
'%(status)s %(bytes)s "%(HTTP_REFERER)s" "%(HTTP_USER_AGENT)s"'
29+
)
30+
31+
def __init__(
32+
self,
33+
application,
34+
logger=None,
35+
format=None,
36+
logging_level=logging.INFO,
37+
logger_name="wsgi",
38+
setup_console_handler=True,
39+
set_logger_level=logging.DEBUG,
40+
):
41+
if format is not None:
42+
self.format = format
43+
self.application = application
44+
self.logging_level = logging_level
45+
self.logger_name = logger_name
46+
if logger is None:
47+
self.logger = logging.getLogger(self.logger_name)
48+
if setup_console_handler:
49+
console = logging.StreamHandler()
50+
console.setLevel(logging.DEBUG)
51+
# We need to control the exact format:
52+
console.setFormatter(logging.Formatter("%(message)s"))
53+
self.logger.addHandler(console)
54+
self.logger.propagate = False
55+
if set_logger_level is not None:
56+
self.logger.setLevel(set_logger_level)
57+
else:
58+
self.logger = logger
59+
60+
def __call__(self, environ, start_response):
61+
start = time.localtime()
62+
req_uri = urllib.parse.quote(
63+
environ.get("SCRIPT_NAME", "") + environ.get("PATH_INFO", "")
64+
)
65+
if environ.get("QUERY_STRING"):
66+
req_uri += "?" + environ["QUERY_STRING"]
67+
method = environ["REQUEST_METHOD"]
68+
69+
def replacement_start_response(status, headers, exc_info=None):
70+
# @@: Ideally we would count the bytes going by if no
71+
# content-length header was provided; but that does add
72+
# some overhead, so at least for now we'll be lazy.
73+
bytes = None
74+
for name, value in headers:
75+
if name.lower() == "content-length":
76+
bytes = value
77+
self.write_log(environ, method, req_uri, start, status, bytes)
78+
return start_response(status, headers)
79+
80+
return self.application(environ, replacement_start_response)
81+
82+
def write_log(self, environ, method, req_uri, start, status, bytes):
83+
if bytes is None:
84+
bytes = "-"
85+
if time.daylight:
86+
offset = time.altzone / 60 / 60 * -100
87+
else:
88+
offset = time.timezone / 60 / 60 * -100
89+
if offset >= 0:
90+
offset = "+%0.4d" % (offset)
91+
elif offset < 0:
92+
offset = "%0.4d" % (offset)
93+
remote_addr = "-"
94+
if environ.get("HTTP_X_FORWARDED_FOR"):
95+
remote_addr = environ["HTTP_X_FORWARDED_FOR"]
96+
elif environ.get("REMOTE_ADDR"):
97+
remote_addr = environ["REMOTE_ADDR"]
98+
d = {
99+
"REMOTE_ADDR": remote_addr,
100+
"REMOTE_USER": environ.get("REMOTE_USER") or "-",
101+
"REQUEST_METHOD": method,
102+
"REQUEST_URI": req_uri,
103+
"HTTP_VERSION": environ.get("SERVER_PROTOCOL"),
104+
"time": time.strftime("%d/%b/%Y:%H:%M:%S ", start) + offset,
105+
"status": status.split(None, 1)[0],
106+
"bytes": bytes,
107+
"HTTP_REFERER": environ.get("HTTP_REFERER", "-"),
108+
"HTTP_USER_AGENT": environ.get("HTTP_USER_AGENT", "-"),
109+
}
110+
message = self.format % d
111+
self.logger.log(self.logging_level, message)

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ dependencies = [
2626
"Flask-Limiter>=2.9.0",
2727
"Flask-SQLAlchemy",
2828
"marshmallow>=3.13.0",
29+
"waitress",
2930
"webargs",
3031
"SQLAlchemy",
3132
"pdf2image",

0 commit comments

Comments
 (0)