Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,26 @@ Changelog
This is a log of the latest changes and improvements to KLibs.


0.7.9b1
-------

(Unreleased)


Runtime Changes:

* KLibs now ensures that the participant has been initialized in the database
prior to the start of the first block, and likewise ensures that demographics
collection is not performed more than once.
* Demographics collection has been changed so that queries in
`user_queries.json` are skipped if they do not correspond to a column in the
`participants` table of the database. Additionally, the query for the
participant's unique identifer is now always collected first.
* Demographics collection now fails immediately with an informative message if
a required column in the `participants` table does not have a corresponding
query.


0.7.8b2
-------

Expand Down
146 changes: 86 additions & 60 deletions klibs/KLCommunication.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import re
from os.path import join
from shutil import copyfile, copytree
from collections import OrderedDict

from sdl2 import (SDL_StartTextInput, SDL_StopTextInput,
SDL_KEYDOWN, SDLK_ESCAPE, SDLK_BACKSPACE, SDLK_RETURN, SDLK_KP_ENTER, SDL_TEXTINPUT)
Expand All @@ -16,7 +17,6 @@
from klibs.KLEventQueue import pump, flush
from klibs.KLUtilities import pretty_list, now, utf8, make_hash
from klibs.KLUtilities import colored_stdout as cso
from klibs.KLDatabase import EntryTemplate
from klibs.KLRuntimeInfo import runtime_info_init
from klibs.KLGraphics import blit, clear, fill, flip
from klibs.KLUserInterface import ui_request, any_key
Expand All @@ -27,6 +27,35 @@
default_strings = None


def _get_demographics_queries(db, queries):
# Get all columns that need to be filled during demographics
required = []
exclude = ['id', 'created', 'random_seed', 'klibs_commit']
participants = db.table_schemas['participants']
for col in participants.keys():
if col not in exclude and not participants[col]['allow_null']:
required.append(col)

# Ensure all required demographics cols have corresponding queries
query_cols = [q.database_field for q in queries]
missing = set(required).difference(set(query_cols))
if len(missing):
e = "Missing entries in '{0}' for the following database fields: {1}"
raise RuntimeError(e.format("user_queries.json", str(list(missing))))

# Gather queries into a dict for easy use
query_set = OrderedDict()
for q in queries:
if not q.database_field in db.get_columns('participants'):
msg = ("Query '{0}' does not correspond to any column in the participants "
"table, skipping...")
print(" * Warning: " + msg.format(q.title) + "\n")
continue
query_set[q.database_field] = q

return query_set


def alert(text):
'''A convenience function for clearing the screen and displaying an alert message. Will
probably be depricated soon.
Expand All @@ -52,65 +81,62 @@ def collect_demographics(anonymous=False):
user for input.

'''
from klibs.KLEnvironment import exp, db

# ie. demographic questions aren't being asked for this experiment
if not P.collect_demographics and not anonymous: return

# first insert required, automatically-populated fields
demographics = EntryTemplate('participants')
demographics.log('created', now(True))
try:
# columns moved to session_info in newer templates
demographics.log("random_seed", P.random_seed)
demographics.log("klibs_commit", P.klibs_commit)
except ValueError:
pass

# collect a response and handle errors for each question
for q in user_queries.demographic:
if q.active:
# if querying unique identifier, make sure it doesn't already exist in db
if q.database_field == P.unique_identifier:
existing = [utf8(pid) for pid in db.get_unique_ids()]
while True:
value = query(q, anonymous=anonymous)
if utf8(value) in existing:
err = ("A participant with that ID already exists!\n"
"Please try a different identifier.")
fill()
blit(message(err, "alert", align='center', blit_txt=False), 5, P.screen_c)
flip()
any_key()
else:
break
else:
value = query(q, anonymous=anonymous)
demographics.log(q.database_field, value)

# typical use; P.collect_demographics is True and called automatically by klibs
if not P.demographics_collected:
P.participant_id = db.insert(demographics)
P.p_id = P.participant_id
P.demographics_collected = True
# Log info about current runtime environment to database
if 'session_info' in db.table_schemas.keys():
runtime_info = EntryTemplate('session_info')
for col, value in runtime_info_init().items():
runtime_info.log(col, value)
if P.condition and 'condition' in runtime_info.schema.keys():
runtime_info.log('condition', P.condition)
db.insert(runtime_info)
# Save copy of experiment.py and config files as they were for participant
if not P.development_mode:
pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser
P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True)))
os.mkdir(P.version_dir)
copyfile("experiment.py", join(P.version_dir, "experiment.py"))
copytree(P.config_dir, join(P.version_dir, "Config"))
else:
# The context for this is: collect_demographics is set to false but then explicitly called later
db.update(demographics.table, demographics.defined)
from klibs.KLEnvironment import db

# If demographics already collected, raise error
if P.demographics_collected:
e = "Demographics have already been collected for this participant."
raise RuntimeError(e)

# Gather demographic queries, separating id query from others
queries = _get_demographics_queries(db, user_queries.demographic)
id_query = queries.pop(P.unique_identifier)

# Collect the unique identifier for the participant
unique_id = None
existing = [utf8(pid) for pid in db.get_unique_ids()]
while not unique_id:
unique_id = query(id_query, anonymous=anonymous)
if utf8(unique_id) in existing:
unique_id = None
err = ("A participant with that ID already exists!\n"
"Please try a different identifier.")
fill()
blit(message(err, "alert", align='center'), 5, P.screen_c)
flip()
any_key()

# Initialize demographics info for partcipant
demographics = {
P.unique_identifier: unique_id,
"created": now(True),
}

# [Compat]: Required for compatibility with older projects
if "random_seed" in db.get_columns("participants"):
demographics["random_seed"] = P.random_seed
demographics["klibs_commit"] = P.klibs_commit

# Collect all other demographics queries
for db_col, q in queries.items():
demographics[db_col] = query(q, anonymous=anonymous)

# Insert demographics in database and get db id number
P.participant_id = db.insert(demographics, "participants")
P.p_id = P.participant_id
P.demographics_collected = True

# Log info about current runtime environment to database
runtime_info = runtime_info_init()
db.insert(runtime_info, "session_info")

# Save copy of experiment.py and config files as they were for participant
if not P.development_mode:
pid = P.random_seed if P.multi_user else P.participant_id # pid set at end for multiuser
P.version_dir = join(P.versions_dir, "p{0}_{1}".format(pid, now(True)))
os.mkdir(P.version_dir)
copyfile("experiment.py", join(P.version_dir, "experiment.py"))
copytree(P.config_dir, join(P.version_dir, "Config"))


def init_default_textstyles():
Expand Down
4 changes: 4 additions & 0 deletions klibs/KLExperiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ def __execute_experiment__(self, *args, **kwargs):
"""For internal use, actually runs the blocks/trials of the experiment in sequence.

"""
if not P.demographics_collected:
e = "Demographics must be collected prior to the start of the first block."
raise RuntimeError(e)

if self.blocks == None:
self.blocks = self.trial_factory.export_trials()

Expand Down
3 changes: 3 additions & 0 deletions klibs/KLRuntimeInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ def runtime_info_init():
'viewing_dist': '{0} cm'.format(int(round(P.view_distance)))
}

if P.condition:
info['condition'] = P.condition

if P.eye_tracking:
from klibs.KLEnvironment import el
info['eyetracker'] = el.version if el.initialized else 'NA'
Expand Down
18 changes: 18 additions & 0 deletions klibs/tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,21 @@ def with_text_init(with_txtm):
_set_display_params((1920, 1080), 21.5, 60.0)
init_default_textstyles()
yield

@pytest.fixture
def db_test_path():
from klibs import KLDatabase as kldb
schema_path = get_resource_path('template/schema.sql')
tmpdir = tempfile.gettempdir()
testpath = os.path.join(tmpdir, "tmp.db")
kldb.rebuild_database(testpath, schema_path)
assert os.path.exists(testpath)
yield testpath
os.remove(testpath)

@pytest.fixture
def db(db_test_path):
from klibs import KLDatabase as kldb
tmp = kldb.Database(db_test_path)
yield tmp
tmp.close()
29 changes: 28 additions & 1 deletion klibs/tests/test_KLCommunication.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,34 @@
from klibs import P
from klibs.KLGraphics import NumpySurface
from klibs.KLText import TextStyle
from klibs.KLCommunication import message
from klibs.KLCommunication import message, _get_demographics_queries
from klibs.KLJSON_Object import import_json, AttributeDict

from conftest import get_resource_path, db_test_path, db


def test_get_demograpics_queries(db):

# Test basic loading and parsing of demographic queries
qpath = get_resource_path('template/user_queries.json')
qset = import_json(qpath).demographic
queries = _get_demographics_queries(db, qset)
assert len(queries) == len(qset)
assert "age" in list(queries.keys())
assert queries["age"].database_field == "age"

# Test error when missing query for a required column
with pytest.raises(RuntimeError):
_get_demographics_queries(db, qset[:-1])

# Test non-failure if extra query exists
extra_q = AttributeDict({
"title": "test",
"database_field": "non_existant"
})
qset.append(extra_q)
queries = _get_demographics_queries(db, qset)
assert len(queries) < len(qset)


def test_message(with_text_init):
Expand Down
17 changes: 1 addition & 16 deletions klibs/tests/test_KLDatabase.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,11 @@
from klibs import KLDatabase as kldb
from klibs.KLRuntimeInfo import runtime_info_init

from conftest import _init_params_pytest, get_resource_path
from conftest import _init_params_pytest, get_resource_path, db_test_path, db


schema_path = get_resource_path('template/schema.sql')

@pytest.fixture
def db_test_path():
tmpdir = tempfile.gettempdir()
testpath = os.path.join(tmpdir, "tmp.db")
kldb.rebuild_database(testpath, schema_path)
assert os.path.exists(testpath)
yield testpath
os.remove(testpath)

@pytest.fixture
def db(db_test_path):
tmp = kldb.Database(db_test_path)
yield tmp
tmp.close()

def generate_id_row(uid=1, gender="f", age=24, handedness="r"):
# Generate participant data
dat = {
Expand Down
1 change: 1 addition & 0 deletions klibs/tests/test_KLExperiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ def run_environment():
P.ind_vars_file_path = os.path.join(template_path, "independent_variables.py")
P.ind_vars_file_local_path = os.path.join(template_path, "doesnt_exist.py")
P.manual_trial_generation = True
P.demographics_collected = True
P.project_name = "PROJECT_NAME"

@pytest.fixture
Expand Down
Loading