Tightened up the startup process.

master
Pacman Ghost 3 years ago
parent a30c7b8741
commit 63af9c678c
  1. 1
      asl_rulebook2/webapp/__init__.py
  2. 48
      asl_rulebook2/webapp/content.py
  3. 2
      asl_rulebook2/webapp/globvars.py
  4. 15
      asl_rulebook2/webapp/main.py
  5. 48
      asl_rulebook2/webapp/search.py
  6. 62
      asl_rulebook2/webapp/startup.py
  7. 20
      asl_rulebook2/webapp/static/MainApp.js
  8. 2
      asl_rulebook2/webapp/static/css/global.css
  9. 18
      asl_rulebook2/webapp/static/utils.js
  10. 5
      asl_rulebook2/webapp/templates/index.html
  11. 4
      asl_rulebook2/webapp/templates/testing.html
  12. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-footnotes/test.footnotes
  13. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-footnotes/test.index
  14. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-index/test.index
  15. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-search-aliases/search-aliases.json
  16. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-search-replacements/search-replacements.json
  17. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-search-synonyms/search-synonyms.json
  18. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-targets/test.index
  19. 1
      asl_rulebook2/webapp/tests/fixtures/invalid-targets/test.targets
  20. 5
      asl_rulebook2/webapp/tests/test_search.py
  21. 55
      asl_rulebook2/webapp/tests/test_startup.py
  22. 55
      asl_rulebook2/webapp/tests/utils.py

@ -69,6 +69,7 @@ else:
# load the application
import asl_rulebook2.webapp.main #pylint: disable=wrong-import-position,cyclic-import
import asl_rulebook2.webapp.startup #pylint: disable=wrong-import-position,cyclic-import
import asl_rulebook2.webapp.content #pylint: disable=wrong-import-position,cyclic-import
from asl_rulebook2.webapp import globvars #pylint: disable=wrong-import-position,cyclic-import
app.before_request( globvars.on_request )

@ -14,7 +14,7 @@ content_docs = None
# ---------------------------------------------------------------------
def load_content_docs( logger ):
def load_content_docs( startup_msgs, logger ):
"""Load the content documents from the data directory."""
# initialize
@ -23,27 +23,36 @@ def load_content_docs( logger ):
dname = app.config.get( "DATA_DIR" )
if not dname:
return
if not os.path.dirname( dname ):
raise RuntimeError( "Invalid data directory: {}".format( dname ) )
if not os.path.isdir( dname ):
startup_msgs.error( "Invalid data directory.", dname )
return
def get_doc( content_doc, key, fname, binary=False ):
def load_file( fname, content_doc, key, on_error, binary=False ):
fname = os.path.join( dname, fname )
if not os.path.isfile( fname ):
return
if binary:
with open( fname, mode="rb" ) as fp:
data = fp.read()
logger.debug( "- Loaded \"%s\" file: #bytes=%d", key, len(data) )
content_doc[ key ] = data
else:
with open( fname, "r", encoding="utf-8" ) as fp:
content_doc[ key ] = json.load( fp )
logger.debug( "- Loaded \"%s\" file.", key )
return False
# load the specified file
try:
if binary:
with open( fname, mode="rb" ) as fp:
data = fp.read()
logger.debug( "- Loaded \"%s\" file: #bytes=%d", key, len(data) )
else:
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
logger.debug( "- Loaded \"%s\" file.", key )
except Exception as ex: #pylint: disable=broad-except
on_error( "Couldn't load \"{}\".".format( os.path.basename(fname) ), str(ex) )
return False
# save the file data
content_doc[ key ] = data
return True
# load each content doc
logger.info( "Loading content docs: %s", dname )
fspec = os.path.join( dname, "*.index" )
for fname in glob.glob( fspec ):
# load the main index file
fname2 = os.path.basename( fname )
logger.info( "- %s", fname2 )
title = os.path.splitext( fname2 )[0]
@ -52,10 +61,13 @@ def load_content_docs( logger ):
"doc_id": slugify( title ),
"title": title,
}
get_doc( content_doc, "index", fname2 )
get_doc( content_doc, "targets", change_extn(fname2,".targets") )
get_doc( content_doc, "footnotes", change_extn(fname2,".footnotes") )
get_doc( content_doc, "content", change_extn(fname2,".pdf"), binary=True )
if not load_file( fname2, content_doc, "index", startup_msgs.error ):
continue # nb: we can't do anything without an index file
# load any associated files
load_file( change_extn(fname2,".targets"), content_doc, "targets", startup_msgs.warning )
load_file( change_extn(fname2,".footnotes"), content_doc, "footnotes", startup_msgs.warning )
load_file( change_extn(fname2,".pdf"), content_doc, "content", startup_msgs.warning, binary=True )
# save the new content doc
content_docs[ content_doc["doc_id"] ] = content_doc
# ---------------------------------------------------------------------

@ -26,7 +26,7 @@ def on_request():
global _init_done
if not _init_done or (request.path == "/" and request.args.get("reload")):
try:
from asl_rulebook2.webapp.main import init_webapp
from asl_rulebook2.webapp.startup import init_webapp
init_webapp()
finally:
# NOTE: It's important to set this, even if initialization failed, so we don't

@ -8,25 +8,10 @@ import logging
from flask import render_template, jsonify, abort
from asl_rulebook2.webapp import app, globvars, shutdown_event
from asl_rulebook2.webapp.content import load_content_docs
from asl_rulebook2.webapp.search import init_search
from asl_rulebook2.webapp.utils import parse_int
# ---------------------------------------------------------------------
def init_webapp():
"""Initialize the webapp.
IMPORTANT: This is called on the first Flask request, but can also be called multiple times
after that by the test suite, to reset the webapp before each test.
"""
# initialize the webapp
logger = logging.getLogger( "startup" )
load_content_docs( logger )
init_search( logger )
# ---------------------------------------------------------------------
@app.route( "/" )
def main():
"""Return the main page."""

@ -321,7 +321,7 @@ def _adjust_sort_order( results ):
# ---------------------------------------------------------------------
def init_search( logger ):
def init_search( startup_msgs, logger ):
"""Initialize the search engine."""
# initialize
@ -376,11 +376,11 @@ def init_search( logger ):
assert len(_fts_index_entries) == _get_row_count( conn, "searchable" )
# load the search config
load_search_config( logger )
load_search_config( startup_msgs, logger )
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
def load_search_config( logger ):
def load_search_config( startup_msgs, logger ):
"""Load the search config."""
# initialize
@ -411,12 +411,16 @@ def load_search_config( logger ):
_SEARCH_TERM_ADJUSTMENTS[ key ].update( vals )
# load the search replacements
def load_search_replacements( fname ):
def load_search_replacements( fname, ftype ):
if not os.path.isfile( fname ):
return
logger.info( "Loading search replacements: %s", fname )
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
try:
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
except Exception as ex: #pylint: disable=broad-except
startup_msgs.warning( "Can't load {} search replacements.".format( ftype ), str(ex) )
return
nitems = 0
for key, val in data.items():
if key.startswith( "_" ):
@ -425,16 +429,20 @@ def load_search_config( logger ):
add_search_term_adjustment( key, val )
nitems += 1
logger.info( "- Loaded %s.", plural(nitems,"search replacement","search replacements") )
load_search_replacements( make_config_path( "search-replacements.json" ) )
load_search_replacements( make_data_path( "search-replacements.json" ) )
load_search_replacements( make_config_path( "search-replacements.json" ), "default" )
load_search_replacements( make_data_path( "search-replacements.json" ), "user" )
# load the search aliases
def load_search_aliases( fname ):
def load_search_aliases( fname, ftype ):
if not os.path.isfile( fname ):
return
logger.info( "Loading search aliases: %s", fname )
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
try:
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
except Exception as ex: #pylint: disable=broad-except
startup_msgs.warning( "Can't load {} search aliases.".format( ftype ), str(ex) )
return
nitems = 0
for keys, aliases in data.items():
if keys.startswith( "_" ):
@ -444,16 +452,20 @@ def load_search_config( logger ):
add_search_term_adjustment( key, set( itertools.chain( aliases, [key] ) ) )
nitems += 1
logger.info( "- Loaded %s.", plural(nitems,"search aliases","search aliases") )
load_search_aliases( make_config_path( "search-aliases.json" ) )
load_search_aliases( make_data_path( "search-aliases.json" ) )
load_search_aliases( make_config_path( "search-aliases.json" ), "default" )
load_search_aliases( make_data_path( "search-aliases.json" ), "user" )
# load the search synonyms
def load_search_synonyms( fname ):
def load_search_synonyms( fname, ftype ):
if not os.path.isfile( fname ):
return
logger.info( "Loading search synonyms: %s", fname )
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
try:
with open( fname, "r", encoding="utf-8" ) as fp:
data = json.load( fp )
except Exception as ex: #pylint: disable=broad-except
startup_msgs.warning( "Can't load {} search synonyms.".format( ftype ), str(ex) )
return
nitems = 0
for synonyms in data:
if isinstance( synonyms, str ):
@ -464,8 +476,8 @@ def load_search_config( logger ):
add_search_term_adjustment( term, synonyms )
nitems += 1
logger.info( "- Loaded %s.", plural(nitems,"search synonym","search synonyms") )
load_search_synonyms( make_config_path( "search-synonyms.json" ) )
load_search_synonyms( make_data_path( "search-synonyms.json" ) )
load_search_synonyms( make_config_path( "search-synonyms.json" ), "default" )
load_search_synonyms( make_data_path( "search-synonyms.json" ), "user" )
# ---------------------------------------------------------------------

@ -0,0 +1,62 @@
""" Manage the startup process. """
import logging
from collections import defaultdict
from flask import jsonify
from asl_rulebook2.webapp import app
from asl_rulebook2.webapp.content import load_content_docs
from asl_rulebook2.webapp.search import init_search
_logger = logging.getLogger( "startup" )
_startup_msgs = None
# ---------------------------------------------------------------------
def init_webapp():
"""Initialize the webapp.
IMPORTANT: This is called on the first Flask request, but can also be called multiple times
after that by the test suite, to reset the webapp before each test.
"""
# initialize
global _startup_msgs
_startup_msgs = StartupMsgs()
# initialize the webapp
load_content_docs( _startup_msgs, _logger )
init_search( _startup_msgs, _logger )
# ---------------------------------------------------------------------
@app.route( "/startup-msgs" )
def get_startup_msgs():
"""Return any messages issued during startup."""
return jsonify( _startup_msgs.msgs )
# ---------------------------------------------------------------------
class StartupMsgs:
"""Store messages issued during startup."""
def __init__( self ):
self.msgs = defaultdict( list )
#pylint: disable=missing-function-docstring
def info( self, msg, msg_info=None ):
return self._add_msg( "info", msg, msg_info )
def warning( self, msg, msg_info=None ):
return self._add_msg( "warning", msg, msg_info )
def error( self, msg, msg_info=None ):
return self._add_msg( "error", msg, msg_info )
def _add_msg( self, msg_type, msg, msg_info ):
"""Add a startup message."""
if msg_info:
self.msgs[ msg_type ].append( ( msg, msg_info ) )
getattr( _logger, msg_type )( "%s\n %s", msg, msg_info )
else:
self.msgs[ msg_type ].append( msg )
getattr( _logger, msg_type )( "%s", msg )

@ -1,4 +1,4 @@
import { showErrorMsg } from "./utils.js" ;
import { showErrorMsg, showNotificationMsg } from "./utils.js" ;
// parse any URL parameters
export let gUrlParams = new URLSearchParams( window.location.search.substring(1) ) ;
@ -45,6 +45,7 @@ gMainApp.component( "main-app", {
this.getContentDocs( this ),
] ).then( () => {
this.isLoaded = true ;
this.showStartupMsgs() ;
$( "#query-string" ).focus() ; // nb: because autofocus on the <input> doesn't work :-/
} ) ;
},
@ -73,6 +74,23 @@ gMainApp.component( "main-app", {
} ) ;
},
showStartupMsgs() {
$.getJSON( gGetStartupMsgsUrl, (resp) => { //eslint-disable-line no-undef
// show any startup messages
[ "info", "warning", "error" ].forEach( (msgType) => {
if ( ! resp[msgType] )
return ;
resp[msgType].forEach( (msg) => {
if ( Array.isArray( msg ) )
msg = msg[0] + " <div class='pre'>" + msg[1] + "</div>" ;
showNotificationMsg( msgType, msg ) ;
} ) ;
} ) ;
} ).fail( (xhr, status, errorMsg) => { //eslint-disable-line no-unused-vars
showErrorMsg( "Couldn't get the startup messages." ) ;
} ) ;
},
},
} ) ;

@ -12,4 +12,4 @@
.growl .growl-close { position: absolute ; top: 0 ; right: 6px ; }
.growl .growl-title { display: none ; }
.growl .pre { font-family: monospace ; }
.growl div.pre { margin: 0 0 15px 15px ; font-size: 80% ; }
.growl div.pre { margin: 0 0 10px 10px ; font-size: 80% ; }

@ -1,3 +1,5 @@
import { gUrlParams } from "./MainApp.js" ;
// --------------------------------------------------------------------
const _HILITE_REGEXES = [
@ -16,15 +18,21 @@ export function fixupSearchHilites( val )
// --------------------------------------------------------------------
export function showInfoMsg( msg ) { _doShowNotificationMsg( "notice", msg ) ; }
export function showWarningMsg( msg ) { _doShowNotificationMsg( "warning", msg ) ; }
export function showErrorMsg( msg ) { _doShowNotificationMsg( "error", msg ) ; }
export function showInfoMsg( msg ) { showNotificationMsg( "notice", msg ) ; }
export function showWarningMsg( msg ) { showNotificationMsg( "warning", msg ) ; }
export function showErrorMsg( msg ) { showNotificationMsg( "error", msg ) ; }
function _doShowNotificationMsg( msgType, msg )
export function showNotificationMsg( msgType, msg )
{
if ( gUrlParams.get( "store-msgs" ) ) {
// store the message for the test suite
$( "#_last-" + msgType + "-msg_" ).val( msg ) ;
return ;
}
// show the notification message
$.growl( {
style: msgType,
style: (msgType == "info") ? "notice" : msgType,
title: null,
message: msg,
location: "br",

@ -20,6 +20,8 @@
<body>
<div id="main-app"></div>
{# NOTE: We include some elements to support the test suite, and since they can be updated by the test suite, we manage them manually (instead of using Vue). #}
{%include "testing.html"%}
</body>
{%if WEB_DEBUG%}
@ -42,7 +44,8 @@
<script src="{{ url_for( 'static', filename='growl/jquery.growl.js' ) }}"></script>
<script>
gGetContentDocsUrl = "{{ url_for( 'get_content_docs') }}" ;
gGetContentDocsUrl = "{{ url_for( 'get_content_docs' ) }}" ;
gGetStartupMsgsUrl = "{{ url_for( 'get_startup_msgs' ) }}" ;
gSearchUrl = "{{ url_for( 'search' ) }}" ;
</script>

@ -0,0 +1,4 @@
<!-- these are for the test suite -->
<textarea id="_last-info-msg_" style="display:none;"></textarea>
<textarea id="_last-warning-msg_" style="display:none;"></textarea>
<textarea id="_last-error-msg_" style="display:none;"></textarea>

@ -7,6 +7,7 @@ from selenium.webdriver.common.keys import Keys
from asl_rulebook2.utils import strip_html
from asl_rulebook2.webapp.search import load_search_config, _make_fts_query_string
from asl_rulebook2.webapp.startup import StartupMsgs
from asl_rulebook2.webapp.tests.utils import init_webapp, select_tabbed_page, get_classes, \
wait_for, find_child, find_children
@ -124,11 +125,13 @@ def test_make_fts_query_string():
"""Test generating the FTS query string."""
# initialize
load_search_config( logging.getLogger("_unknown_") )
startup_msgs = StartupMsgs()
load_search_config( startup_msgs, logging.getLogger("_unknown_") )
def check( query, expected ):
fts_query_string, _ = _make_fts_query_string(query)
assert fts_query_string == expected
assert not startup_msgs.msgs
# test some query strings
check( "", "" )

@ -0,0 +1,55 @@
""" Test the startup process. """
from asl_rulebook2.webapp.tests.utils import init_webapp, \
wait_for_warning_msg, wait_for_error_msg, find_children
# ---------------------------------------------------------------------
def test_load_content_docs( webapp, webdriver ):
"""Test loading content docs."""
# test handling of an invalid data directory
webapp.control_tests.set_data_dir( "_unknown_" )
init_webapp( webapp, webdriver )
wait_for_error_msg( 2, "Invalid data directory.", contains=True )
# test handling of an invalid index file
webapp.control_tests.set_data_dir( "invalid-index" )
init_webapp( webapp, webdriver )
wait_for_error_msg( 2, "Couldn't load \"test.index\".", contains=True )
# NOTE: If we can't load the index file, the content doc is useless and we don't load it at all.
# If any of the associated files are invalid, the content doc is loaded (i.e. a tab will be shown
# for it), and we degrade gracefully.
assert len( find_children( "#content .tabbed-page" ) ) == 0
# test handling of an invalid targets file
webapp.control_tests.set_data_dir( "invalid-targets" )
init_webapp( webapp, webdriver )
wait_for_warning_msg( 2, "Couldn't load \"test.targets\".", contains=True )
assert len( find_children( "#content .tabbed-page" ) ) == 1
# test handling of an invalid footnotes file
webapp.control_tests.set_data_dir( "invalid-footnotes" )
init_webapp( webapp, webdriver )
wait_for_warning_msg( 2, "Couldn't load \"test.footnotes\".", contains=True )
assert len( find_children( "#content .tabbed-page" ) ) == 1
# ---------------------------------------------------------------------
def test_init_search( webapp, webdriver ):
"""Test initializing the search engine."""
# test handling of an invalid search replacements file
webapp.control_tests.set_data_dir( "invalid-search-replacements" )
init_webapp( webapp, webdriver )
wait_for_warning_msg( 2, "Can't load user search replacements.", contains=True )
# test handling of an invalid search aliases file
webapp.control_tests.set_data_dir( "invalid-search-aliases" )
init_webapp( webapp, webdriver )
wait_for_warning_msg( 2, "Can't load user search aliases.", contains=True )
# test handling of an invalid search synonyms file
webapp.control_tests.set_data_dir( "invalid-search-synonyms" )
init_webapp( webapp, webdriver )
wait_for_warning_msg( 2, "Can't load user search synonyms.", contains=True )

@ -1,7 +1,10 @@
""" Helper utilities. """
import sys
import uuid
from selenium.webdriver.support.ui import WebDriverWait
from selenium.common.exceptions import NoSuchElementException
from selenium.common.exceptions import NoSuchElementException, TimeoutException
from asl_rulebook2.webapp import tests as webapp_tests
@ -27,6 +30,7 @@ def init_webapp( webapp, webdriver, **options ):
# FUDGE! Headless Chrome doesn't want to show the PDF in the browser,
# it downloads the file and saves it in the current directory :wtf:
options["no-content"] = 1
options["store-msgs"] = 1 # nb: so that we can retrive notification messages
options["reload"] = 1 # nb: force the webapp to reload
webdriver.get( webapp.url_for( "main", **options ) )
_wait_for_webapp()
@ -70,6 +74,32 @@ def _get_tab_ids( sel ):
# ---------------------------------------------------------------------
#pylint: disable=multiple-statements,missing-function-docstring
def get_last_info(): return get_stored_msg( "info" )
def get_last_warning_msg(): return get_stored_msg( "warning" )
def get_last_error_msg(): return get_stored_msg( "error" )
#pylint: enable=multiple-statements,missing-function-docstring
def get_stored_msg( msg_type ):
"""Get a message stored for us by the front-end."""
elem = find_child( "#_last-{}-msg_".format(msg_type), _webdriver )
assert elem.tag_name == "textarea"
return elem.get_attribute( "value" )
def set_stored_msg( msg_type, val ):
"""Set a message for the front-end."""
elem = find_child( "#_last-{}-msg_".format(msg_type), _webdriver )
assert elem.tag_name == "textarea"
_webdriver.execute_script( "arguments[0].value = arguments[1]", elem, val )
def set_stored_msg_marker( msg_type ):
"""Store marker text in the message buffer (so we can tell if the front-end changes it)."""
marker = "marker:{}:{}".format( msg_type, uuid.uuid4() )
set_stored_msg( msg_type, marker )
return marker
# ---------------------------------------------------------------------
def find_child( sel, parent=None ):
"""Find a single child element."""
try:
@ -101,6 +131,29 @@ def wait_for( timeout, func ):
lambda driver: func()
)
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
#pylint: disable=missing-function-docstring
def wait_for_info_msg( timeout, expected, contains=True ):
return _do_wait_for_msg( timeout, "info", expected, contains )
def wait_for_warning_msg( timeout, expected, contains=True ):
return _do_wait_for_msg( timeout, "warning", expected, contains )
def wait_for_error_msg( timeout, expected, contains=True ):
return _do_wait_for_msg( timeout, "error", expected, contains )
#pylint: enable=missing-function-docstring
def _do_wait_for_msg( timeout, msg_type, expected, contains ):
"""Wait for a message to be issued."""
func = getattr( sys.modules[__name__], "get_last_{}_msg".format( msg_type ) )
try:
wait_for( timeout,
lambda: expected in func() if contains else expected == func()
)
except TimeoutException:
print( "ERROR: Didn't get expected {} message: {}".format( msg_type, expected ) )
print( "- last {} message: {}".format( msg_type, func() ) )
assert False
# ---------------------------------------------------------------------
def get_pytest_option( opt ):

Loading…
Cancel
Save