Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
caeb725
update_input: don't swallow bad dates
dale-wahl Jul 16, 2026
541b8c4
update_input: fix OPTION_MULTI fail on list; raise on invalid options
dale-wahl Jul 16, 2026
26b0c55
update_input: clean up text input: ensure coarse works, determine num…
dale-wahl Jul 16, 2026
8c71026
whoops: refactor missed lines
dale-wahl Jul 16, 2026
17e051e
user_input: add knowns keys to check (to catch "coerce" vs "coerce_ty…
dale-wahl Jul 16, 2026
09ce69b
user_input: swithc silently_correct default to False (more secure), a…
dale-wahl Jul 16, 2026
38136ca
add tests! check option defaults (where possible) and ensure no bad o…
dale-wahl Jul 16, 2026
601cc12
user_input: tell user which option failed
dale-wahl Jul 16, 2026
388a301
user_input: warn on bad default _before_ converted to user input
dale-wahl Jul 16, 2026
d5e4ea9
revert: still warn, but do not silently correct (better than user val…
dale-wahl Jul 16, 2026
3a0676e
remove_author_info: itertools.chain is a ONE use iterator (make it a …
dale-wahl Jul 16, 2026
7f63546
fix bad defaults
dale-wahl Jul 16, 2026
5634bbb
"hidden_in_explorer" should be "hide_in_explorer"
dale-wahl Jul 16, 2026
afc3f5b
video_scene_identifier: options use keys not values
dale-wahl Jul 16, 2026
4f0b3d0
image_wall: image uses different default than video
dale-wahl Jul 16, 2026
92a3e6e
random_filter: validate_query not needed if get_option is fixed
dale-wahl Jul 16, 2026
c70de2a
merge in user_input changes
dale-wahl Jul 16, 2026
40b6428
add comments to all known option keys
dale-wahl Jul 17, 2026
75b39c5
add mandatory user option flag/tag
dale-wahl Jul 17, 2026
08c6a1f
options mandatory check: add missing multi-select and update test
dale-wahl Jul 17, 2026
22f611a
user_input: never fill in a value the user did not send
dale-wahl Jul 17, 2026
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
414 changes: 346 additions & 68 deletions common/lib/user_input.py

Large diffs are not rendered by default.

6 changes: 4 additions & 2 deletions datasources/eightchan/search_8chan.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,13 +101,15 @@ def get_options(cls, parent_dataset=None, config=None):
"min": 0,
"max": 100,
"default": 15,
"tooltip": "At least this many % of messages in the thread must match the query"
"tooltip": "At least this many % of messages in the thread must match the query",
"requires": "search_scope==dense-threads"
},
"scope_length": {
"type": UserInput.OPTION_TEXT,
"help": "Min. dense thread length",
"min": 30,
"default": 30,
"tooltip": "A thread must at least be this many messages long to qualify as a 'dense thread'"
"tooltip": "A thread must at least be this many messages long to qualify as a 'dense thread'",
"requires": "search_scope==dense-threads"
}
}
6 changes: 4 additions & 2 deletions datasources/eightkun/search_8kun.py
Original file line number Diff line number Diff line change
Expand Up @@ -104,13 +104,15 @@ def get_options(cls, parent_dataset=None, config=None):
"min": 0,
"max": 100,
"default": 15,
"tooltip": "At least this many % of messages in the thread must match the query"
"tooltip": "At least this many % of messages in the thread must match the query",
"requires": "search_scope==dense-threads"
},
"scope_length": {
"type": UserInput.OPTION_TEXT,
"help": "Min. dense thread length",
"min": 30,
"default": 30,
"tooltip": "A thread must at least be this many messages long to qualify as a 'dense thread'"
"tooltip": "A thread must at least be this many messages long to qualify as a 'dense thread'",
"requires": "search_scope==dense-threads"
}
}
22 changes: 15 additions & 7 deletions datasources/fourchan/search_4chan.py
Original file line number Diff line number Diff line change
Expand Up @@ -390,18 +390,21 @@ def get_options(cls, parent_dataset=None, config=None):
"min": 0,
"max": 100,
"default": 15,
"tooltip": "At least this many % of posts in the thread must match the query"
"tooltip": "At least this many % of posts in the thread must match the query",
"requires": "search_scope==dense-threads"
},
"scope_length": {
"type": UserInput.OPTION_TEXT,
"help": "Min. dense thread length",
"min": 30,
"default": 30,
"tooltip": "A thread must at least be this many posts long to qualify as a 'dense thread'"
"tooltip": "A thread must at least be this many posts long to qualify as a 'dense thread'",
"requires": "search_scope==dense-threads"
},
"valid_ids": {
"type": UserInput.OPTION_TEXT,
"help": "Post IDs (comma-separated)"
"help": "Post IDs (comma-separated)",
"requires": "search_scope==match-ids"
}
}

Expand Down Expand Up @@ -870,12 +873,17 @@ def validate_query(query, request, config):
query["max_date"] = before

del query["daterange"]

# the scope fields are declared with a "requires" on the matching
# search scope, so on the web path they are already left out of the
# parsed input when their scope is not selected; this also covers
# callers that skip that parsing and pass them along anyway
if query.get("search_scope") not in ("dense-threads",):
del query["scope_density"]
del query["scope_length"]
query.pop("scope_density", None)
query.pop("scope_length", None)

if query.get("search_scope") not in ("match-ids",) and "valid_ids" in query.keys():
del query["valid_ids"]
if query.get("search_scope") not in ("match-ids",):
query.pop("valid_ids", None)

return query

Expand Down
5 changes: 4 additions & 1 deletion processors/conversion/remove_author_info.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,10 @@ def get_options(cls, parent_dataset=None, config=None):
parent_columns = parent_dataset.get_columns()
parent_columns = {c: c for c in sorted(parent_columns)}

default_fields = itertools.chain(*[[c for c in parent_columns if fnmatch.fnmatch(c, d)] for d in default_fields])
# a list, not the generator itertools.chain returns: this ends up as
# the option's default, which is stored and must survive being read
# more than once
default_fields = list(itertools.chain(*[[c for c in parent_columns if fnmatch.fnmatch(c, d)] for d in default_fields]))

options["fields"] = {
"type": UserInput.OPTION_MULTI_SELECT,
Expand Down
24 changes: 4 additions & 20 deletions processors/filtering/random_filter.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
from processors.filtering.base_filter import BaseFilter
from common.lib.compatibility import Compatibility
from common.lib.helpers import UserInput
from common.lib.exceptions import QueryParametersException

__author__ = "Sal Hagen"
__credits__ = ["Sal Hagen"]
Expand Down Expand Up @@ -43,7 +42,8 @@ def get_options(cls, parent_dataset=None, config=None) -> dict:
"type": UserInput.OPTION_TEXT,
"help": "Sample size",
"default": 10,
"coerce": int,
"coerce_type": int,
"min": 1,
}
}

Expand All @@ -69,6 +69,8 @@ def filter_items(self):
# Get the amount of rows to sample
sample_size = self.parameters.get("sample_size")

# the sample size is checked when the job is queued, but jobs started by
# presets and other code do not go through that check, so check again
try:
sample_size = int(sample_size)
except ValueError:
Expand Down Expand Up @@ -104,24 +106,6 @@ def filter_items(self):
count += 1


@staticmethod
def validate_query(query, request, config):
"""
Validate input

Checks if everything needed is filled in.

:param query:
:param request:
:param config:
:return:
"""

if not query["sample_size"] or not query["sample_size"].isnumeric() or not int(query["sample_size"]) > 0:
raise QueryParametersException("Please enter a valid sample size.")

return query

class RandomProcessorFilter(RandomFilter):
"""
Retain only posts where a given column matches a given value
Expand Down
6 changes: 4 additions & 2 deletions processors/machine_learning/clarifai_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,10 @@ def get_options(cls, parent_dataset=None, config=None) -> dict:
"annotation_threshold": {
"type": UserInput.OPTION_TEXT,
"help": "Only add labels above this confidence",
"default": "0.95",
"coerce_type": "float",
"default": 0.95,
"coerce_type": float,
"min": 0,
"max": 1,
"requires": "save_annotations==true"
},
"csv_info": {
Expand Down
5 changes: 4 additions & 1 deletion processors/visualisation/histwords.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,10 @@ def get_options(cls, parent_dataset=None, config=None) -> dict:
"type": UserInput.OPTION_TEXT,
"help": "Similarity threshold",
"tooltip": "Decimal value between 0 and 1; only neighbours with a higher similarity score than this will be included",
"default": "0.3"
"coerce_type": float,
"default": 0.3,
"min": -1,
"max": 1
},
"overlay": {
"type": UserInput.OPTION_TOGGLE,
Expand Down
4 changes: 2 additions & 2 deletions processors/visualisation/video_scene_identifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@ def get_options(cls, parent_dataset=None, config=None) -> dict:
"coerce_type": float,
"default": 27.0,
"min": 0,
"max": 5,
"max": 255,
"requires": "detector_type==content_detector"
},
"ad_adaptive_threshold": {
Expand Down Expand Up @@ -194,7 +194,7 @@ def get_options(cls, parent_dataset=None, config=None) -> dict:
"save_annotations": {
"type": UserInput.OPTION_ANNOTATION,
"label": "scene data",
"hidden_in_explorer": True,
"hide_in_explorer": True,
"tooltip": "Add amount of scenes per video to top dataset",
"default": False
}
Expand Down
136 changes: 132 additions & 4 deletions tests/test_modules.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,132 @@ def test_processors(logger, fourcat_modules, mock_job, mock_job_queue, mock_data
else:
logger.info("All processors passed successfully.")


@pytest.mark.dependency(depends=["test_module_collector"])
def test_option_default_validity(logger, fourcat_modules, mock_job, mock_job_queue, mock_dataset, mock_database, mock_basic_config):
"""
Every option's declared default must be valid under that option's own
rules. A bad default -- a number outside its own min/max, a coerce_type
that is not a callable type, a multi-value default that is not a list,
unparseable JSON -- is a mistake in the option definition, not user input,
and should never reach a user. Catch those here instead.

Uses UserInput.validate_default. Membership of choice/multi defaults in
option lists that are built from the parent dataset at runtime is
intentionally not checked (it cannot be verified out of context).
"""
from common.lib.user_input import UserInput

problems = []
for processor_name, processor_class in fourcat_modules.processors.items():
try:
options = processor_class.get_options(parent_dataset=mock_dataset, config=mock_basic_config)
except Exception:
# a failing get_options is test_processors' concern, not this test's
continue

if not isinstance(options, dict):
continue

for option_name, settings in options.items():
if not isinstance(settings, dict):
continue
problem = UserInput.validate_default(settings)
if problem:
logger.error(f"{processor_name} option '{option_name}': {problem}")
problems.append(f"{processor_name}.{option_name}: {problem}")

if problems:
pytest.fail("The following option defaults are invalid:\n" + "\n".join(problems))
else:
logger.info("All option defaults are valid.")


@pytest.mark.dependency(depends=["test_module_collector"])
def test_option_keys_known(logger, fourcat_modules, mock_job, mock_job_queue, mock_dataset, mock_database, mock_basic_config):
"""
Every key in an option's settings dictionary must be one the framework
understands. An unknown key (e.g. 'coerce' instead of 'coerce_type') is
silently ignored at parse time, so the setting it was meant to express
quietly has no effect. Catch those typos here.

See UserInput.KNOWN_OPTION_KEYS for the recognised set.
"""
from common.lib.user_input import UserInput

problems = []
for processor_name, processor_class in fourcat_modules.processors.items():
try:
options = processor_class.get_options(parent_dataset=mock_dataset, config=mock_basic_config)
except Exception:
continue

if not isinstance(options, dict):
continue

for option_name, settings in options.items():
unknown = UserInput.unknown_option_keys(settings)
if unknown:
logger.error(f"{processor_name} option '{option_name}': unknown key(s) {unknown}")
problems.append(f"{processor_name}.{option_name}: unknown key(s) {', '.join(unknown)}")

if problems:
pytest.fail("The following options have unrecognised keys:\n" + "\n".join(problems))
else:
logger.info("All option keys are recognised.")


def test_parse_all_warns_about_a_bad_default_but_does_not_correct_it():
"""
An option's default is set by us, so an invalid one is our bug and
developers are warned about it. It is deliberately not corrected quietly:
the form pre-fills defaults, so silently replacing the value would run the
analysis on something the user never chose and cannot see, which is worse
than an error. It is reported like any other invalid value instead.
"""
from common.lib.user_input import UserInput
from common.lib.exceptions import QueryParametersException

options = {"threshold": {
"type": UserInput.OPTION_TEXT,
"help": "Threshold",
"coerce_type": float,
"default": 27.0, # invalid: above the max below
"min": 0,
"max": 5,
}}
log = MagicMock()

# the form pre-fills the (bad) default and submits it back
with pytest.raises(QueryParametersException):
UserInput.parse_all(options, {"option-threshold": "27.0"}, silently_correct=False, log=log)

assert log.warning.called, "developers should be warned about the bad default"


def test_parse_all_error_names_the_option():
"""
A genuine user error should say which option it is about, rather than a
bare 'Provide a value of 5 or lower.' with no context.
"""
from common.lib.user_input import UserInput
from common.lib.exceptions import QueryParametersException

options = {"cd_threshold": {
"type": UserInput.OPTION_TEXT,
"help": "Pixel intensity delta threshold",
"coerce_type": float,
"default": 1.0, # valid, so the value below is the user's own doing
"min": 0,
"max": 5,
}}

with pytest.raises(QueryParametersException) as raised:
UserInput.parse_all(options, {"option-cd_threshold": "99"}, silently_correct=False)

assert "Pixel intensity delta threshold" in str(raised.value)


@pytest.mark.dependency(depends=["test_module_collector"])
def test_compatibility_coverage(logger, fourcat_modules):
"""
Expand Down Expand Up @@ -515,12 +641,14 @@ def test_parse_all_gated_options():
assert parsed == {"gate": False, "plain": "y"}

# gate off, gated field submitted anyway (e.g. hidden field still posts):
# still absent
parsed = UserInput.parse_all(options, {"option-gated": ";"})
# still absent. (plain must be submitted: a missing non-gated option is an
# incomplete submission and raises)
parsed = UserInput.parse_all(options, {"option-plain": "y", "option-gated": ";"})
assert "gated" not in parsed and "gated_toggle" not in parsed

# gate on: gated options are parsed (submitted value) or defaulted (absent)
parsed = UserInput.parse_all(options, {"option-gate": "on", "option-gated": ";"})
# gate on: the gated text field is parsed; the gated toggle is absent from
# the submission, which for a toggle simply means unchecked
parsed = UserInput.parse_all(options, {"option-gate": "on", "option-plain": "y", "option-gated": ";"})
assert parsed["gated"] == ";" and parsed["gated_toggle"] is False and parsed["gate"] is True


Expand Down
Loading
Loading