From 95bdcc4bb4ce74c270452905c0efdf58907a23c5 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 13 Feb 2026 17:10:15 -0400 Subject: [PATCH 1/7] Ensure demographics collected prior to running blocks --- klibs/KLExperiment.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/klibs/KLExperiment.py b/klibs/KLExperiment.py index 586b40e..48fef83 100755 --- a/klibs/KLExperiment.py +++ b/klibs/KLExperiment.py @@ -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() From 9bfb262772f1e5b906746dba7d31ecca888c04df Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 13 Feb 2026 17:22:15 -0400 Subject: [PATCH 2/7] Ensure demographics only collected once --- klibs/KLCommunication.py | 52 ++++++++++++++++++++-------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 1e65048..f57215e 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -52,10 +52,12 @@ def collect_demographics(anonymous=False): user for input. ''' - from klibs.KLEnvironment import exp, db + from klibs.KLEnvironment import db - # ie. demographic questions aren't being asked for this experiment - if not P.collect_demographics and not anonymous: return + # If demographics already collected, raise error + if P.demographics_collected: + e = "Demographics have already been collected for this participant." + raise RuntimeError(e) # first insert required, automatically-populated fields demographics = EntryTemplate('participants') @@ -88,29 +90,27 @@ def collect_demographics(anonymous=False): 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) + # Insert demographics in database and get db id number + 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.tables: + 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")) def init_default_textstyles(): From 6d9a36cd307cbd06ea4eecc99acabb246e28b15e Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 13 Feb 2026 19:15:07 -0400 Subject: [PATCH 3/7] Gather and validate queries prior to collection --- docs/CHANGELOG.rst | 14 ++++++++ klibs/KLCommunication.py | 73 +++++++++++++++++++++++++++++----------- 2 files changed, 67 insertions(+), 20 deletions(-) diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 6c8f261..347aba5 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -3,6 +3,20 @@ Changelog This is a log of the latest changes and improvements to KLibs. +0.7.9b1 +------- + +(Unreleased) + + +Runtime Changes: + +* 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. + + 0.7.8b2 ------- diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index f57215e..cb3c06b 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -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) @@ -27,6 +28,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. @@ -69,26 +99,29 @@ def collect_demographics(anonymous=False): 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) + # 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() + demographics.log(P.unique_identifier, unique_id) + + # Collect all other demographics queries + for db_col, q in queries.items(): + value = query(q, anonymous=anonymous) + demographics.log(db_col, value) # Insert demographics in database and get db id number P.participant_id = db.insert(demographics) From e8dc3696818047ba22b6792dbf1321677222ca41 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 13 Feb 2026 19:36:17 -0400 Subject: [PATCH 4/7] Remove use of EntryTemplate in collect_demographics --- klibs/KLCommunication.py | 39 +++++++++++++++++---------------------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index cb3c06b..00de1f4 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -17,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 @@ -89,16 +88,6 @@ def collect_demographics(anonymous=False): e = "Demographics have already been collected for this participant." raise RuntimeError(e) - # 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 - # Gather demographic queries, separating id query from others queries = _get_demographics_queries(db, user_queries.demographic) id_query = queries.pop(P.unique_identifier) @@ -116,26 +105,32 @@ def collect_demographics(anonymous=False): blit(message(err, "alert", align='center'), 5, P.screen_c) flip() any_key() - demographics.log(P.unique_identifier, unique_id) + + # 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(): - value = query(q, anonymous=anonymous) - demographics.log(db_col, value) + demographics[db_col] = query(q, anonymous=anonymous) # Insert demographics in database and get db id number - P.participant_id = db.insert(demographics) + 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 - if 'session_info' in db.tables: - 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) + runtime_info = runtime_info_init() + if P.condition and "condition" in db.get_columns("session_info"): + runtime_info["condition"] = P.condition + db.insert(runtime_info, "session_info") # Save copy of experiment.py and config files as they were for participant if not P.development_mode: From 6494b735b975a00061b975ea70e13cb544fe91d1 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Fri, 13 Feb 2026 19:40:53 -0400 Subject: [PATCH 5/7] Assume 'condition' runtime info column always present --- klibs/KLCommunication.py | 2 -- klibs/KLRuntimeInfo.py | 3 +++ 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/klibs/KLCommunication.py b/klibs/KLCommunication.py index 00de1f4..c141c42 100755 --- a/klibs/KLCommunication.py +++ b/klibs/KLCommunication.py @@ -128,8 +128,6 @@ def collect_demographics(anonymous=False): # Log info about current runtime environment to database runtime_info = runtime_info_init() - if P.condition and "condition" in db.get_columns("session_info"): - runtime_info["condition"] = P.condition db.insert(runtime_info, "session_info") # Save copy of experiment.py and config files as they were for participant diff --git a/klibs/KLRuntimeInfo.py b/klibs/KLRuntimeInfo.py index fdd65e7..65e1057 100644 --- a/klibs/KLRuntimeInfo.py +++ b/klibs/KLRuntimeInfo.py @@ -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' From d74802d92c50681aa01b742f0ca726b81a6d5df5 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Thu, 7 May 2026 14:49:39 -0300 Subject: [PATCH 6/7] Update CHANGELOG --- docs/CHANGELOG.rst | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/CHANGELOG.rst b/docs/CHANGELOG.rst index 347aba5..b27ccbc 100644 --- a/docs/CHANGELOG.rst +++ b/docs/CHANGELOG.rst @@ -11,10 +11,16 @@ This is a log of the latest changes and improvements to KLibs. 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 From 83aa08bceb48ee5390c6f39436c8fff798d8a5b1 Mon Sep 17 00:00:00 2001 From: Austin Hurst Date: Thu, 7 May 2026 15:24:51 -0300 Subject: [PATCH 7/7] Update unit tests --- klibs/tests/conftest.py | 18 ++++++++++++++++++ klibs/tests/test_KLCommunication.py | 29 ++++++++++++++++++++++++++++- klibs/tests/test_KLDatabase.py | 17 +---------------- klibs/tests/test_KLExperiment.py | 1 + 4 files changed, 48 insertions(+), 17 deletions(-) diff --git a/klibs/tests/conftest.py b/klibs/tests/conftest.py index 899c2f4..118c11b 100644 --- a/klibs/tests/conftest.py +++ b/klibs/tests/conftest.py @@ -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() diff --git a/klibs/tests/test_KLCommunication.py b/klibs/tests/test_KLCommunication.py index 8a02f16..bd917a3 100644 --- a/klibs/tests/test_KLCommunication.py +++ b/klibs/tests/test_KLCommunication.py @@ -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): diff --git a/klibs/tests/test_KLDatabase.py b/klibs/tests/test_KLDatabase.py index 0d6b1d9..7c1f084 100644 --- a/klibs/tests/test_KLDatabase.py +++ b/klibs/tests/test_KLDatabase.py @@ -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 = { diff --git a/klibs/tests/test_KLExperiment.py b/klibs/tests/test_KLExperiment.py index 1c0acb5..bdf1e13 100755 --- a/klibs/tests/test_KLExperiment.py +++ b/klibs/tests/test_KLExperiment.py @@ -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