From c62ef9f9d7dc151d45b3ffa6f9228f7d9ee62a00 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 14 Nov 2026 16:52:15 +0000 Subject: [PATCH 1/3] Test that the Pipeline describes its Evidence and keys ignore case advertize-accepted-evidence.md line 5 says every Pipeline MUST be able to describe the Evidence keys it can use, and evidence.md line 10 says keys MUST be case-insensitive. The tests ask a Pipeline which keys it accepts, change an element's filter after the build, and filter Flow Data's Evidence through the Pipeline. They send usage sharing the same values under keys in two letter cases, expecting one record, record a key whose prefix is not in lower case, expecting it under the lower case element, and give on-premise Device Detection its usual keys in other letter cases against a real data file. --- .../tests/test_evidence_key_case.py | 66 +++++++++++++ .../test_pipeline_evidence_key_filter.py | 93 +++++++++++++++++++ .../tests/test_share_usage_record_fields.py | 26 ++++++ 3 files changed, 185 insertions(+) create mode 100644 device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py create mode 100644 pipeline/fiftyone_pipeline_core/tests/test_pipeline_evidence_key_filter.py diff --git a/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py b/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py new file mode 100644 index 00000000..8d225b9d --- /dev/null +++ b/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py @@ -0,0 +1,66 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +import unittest + +from fiftyone_devicedetection_onpremise.devicedetection_onpremise_pipelinebuilder import DeviceDetectionOnPremisePipelineBuilder + +data_file = "../../cxx/data/device-detection/51Degrees-LiteV4.1.hash" + +MOBILE_USER_AGENT = ( + "Mozilla/5.0 (iPhone; CPU iPhone OS 7_1 like Mac OS X) AppleWebKit/537" + ".51.2 (KHTML, like Gecko) Version/7.0 Mobile/11D167 Safari/9537.53") + + +class EvidenceKeyCaseTests(unittest.TestCase): + """! + evidence.md line 10: "Keys MUST be case-insensitive". The native + evidence matches a key's prefix with case, so a key spelled in another + case must still reach the native engine. Checked against a real data + file. + """ + + def setUp(self): + self.pipeline = DeviceDetectionOnPremisePipelineBuilder( + data_file_path=data_file, licence_keys="", + usage_sharing=False).build() + self.addCleanup(self.pipeline.close) + + def is_mobile(self, key): + flowdata = self.pipeline.create_flowdata() + flowdata.evidence.add(key, MOBILE_USER_AGENT) + flowdata.process() + return flowdata.device.ismobile + + def test_a_key_in_any_case_reaches_the_native_engine(self): + for key in ("Header.User-Agent", "HEADER.USER-AGENT", + "Query.User-Agent"): + with self.subTest(key=key): + expected = self.is_mobile(key.lower()) + actual = self.is_mobile(key) + self.assertTrue(expected.has_value()) + self.assertTrue(actual.has_value()) + self.assertEqual(expected.value(), actual.value()) + + +if __name__ == "__main__": + unittest.main() diff --git a/pipeline/fiftyone_pipeline_core/tests/test_pipeline_evidence_key_filter.py b/pipeline/fiftyone_pipeline_core/tests/test_pipeline_evidence_key_filter.py new file mode 100644 index 00000000..16cb4612 --- /dev/null +++ b/pipeline/fiftyone_pipeline_core/tests/test_pipeline_evidence_key_filter.py @@ -0,0 +1,93 @@ +# ********************************************************************* +# This Original Work is copyright of 51 Degrees Mobile Experts Limited. +# Copyright 2026 51 Degrees Mobile Experts Limited, Davidson House, +# Forbury Square, Reading, Berkshire, United Kingdom RG1 3EU. +# +# This Original Work is licensed under the European Union Public Licence +# (EUPL) v.1.2 and is subject to its terms as set out below. +# +# If a copy of the EUPL was not distributed with this file, You can obtain +# one at https://opensource.org/licenses/EUPL-1.2. +# +# The 'Compatible Licences' set out in the Appendix to the EUPL (as may be +# amended by the European Commission) shall be deemed incompatible for +# the purposes of the Work and the provisions of the compatibility +# clause in Article 5 of the EUPL shall not apply. +# +# If using the Work as, or as part of, a network application, by +# including the attribution notice(s) required under Article 5 of the EUPL +# in the end user terms of the application under an appropriate heading, +# such notice(s) shall fulfill the requirements of that article. +# ********************************************************************* + +import unittest + +from fiftyone_pipeline_core.basiclist_evidence_keyfilter import BasicListEvidenceKeyFilter +from fiftyone_pipeline_core.flowelement import FlowElement +from fiftyone_pipeline_core.pipelinebuilder import PipelineBuilder + +NO_DEFAULT_ELEMENTS = { + "add_javascript_builder": False, + "use_setheader_properties": False, +} + + +class Element(FlowElement): + """An element that accepts the keys it holds when asked.""" + + def __init__(self, datakey, keys): + super(Element, self).__init__() + self.datakey = datakey + self.keys = keys + + def get_evidence_key_filter(self): + return BasicListEvidenceKeyFilter(self.keys) + + def process_internal(self, flowdata): + pass + + +class PipelineEvidenceKeyFilterTests(unittest.TestCase): + """! + advertize-accepted-evidence.md line 5: "All Flow Elements and Pipelines + MUST be capable of programmatically describing the Evidence keys that + they can make use of." + """ + + def build(self, *elements): + builder = PipelineBuilder(NO_DEFAULT_ELEMENTS) + for element in elements: + builder.add(element) + pipeline = builder.build() + self.addCleanup(pipeline.close) + return pipeline + + def test_the_pipeline_accepts_what_any_element_accepts(self): + accepted = self.build( + Element("one", ["header.user-agent"]), + Element("two", ["query.mark"])).get_evidence_key_filter() + self.assertTrue(accepted.filter_evidence_key("header.user-agent")) + self.assertTrue(accepted.filter_evidence_key("Header.User-Agent")) + self.assertTrue(accepted.filter_evidence_key("query.mark")) + self.assertFalse(accepted.filter_evidence_key("cookie.other")) + + def test_the_pipeline_reads_its_elements_filters_when_asked(self): + later = Element("later", []) + accepted = self.build(later).get_evidence_key_filter() + self.assertFalse(accepted.filter_evidence_key("header.x-later")) + later.keys = ["header.x-later"] + self.assertTrue(accepted.filter_evidence_key("header.x-later")) + + def test_the_pipeline_filters_evidence_to_what_it_accepts(self): + pipeline = self.build(Element("one", ["header.user-agent"])) + flowdata = pipeline.create_flowdata() + flowdata.evidence.add("header.user-agent", "a browser") + flowdata.evidence.add("cookie.other", "not read") + self.assertEqual( + {"header.user-agent": "a browser"}, + pipeline.get_evidence_key_filter().filter_evidence( + flowdata.evidence.get_all())) + + +if __name__ == "__main__": + unittest.main() diff --git a/pipeline/fiftyone_pipeline_engines_fiftyone/tests/test_share_usage_record_fields.py b/pipeline/fiftyone_pipeline_engines_fiftyone/tests/test_share_usage_record_fields.py index 9590bab4..2586e325 100644 --- a/pipeline/fiftyone_pipeline_engines_fiftyone/tests/test_share_usage_record_fields.py +++ b/pipeline/fiftyone_pipeline_engines_fiftyone/tests/test_share_usage_record_fields.py @@ -259,6 +259,32 @@ def test_evidence_added_beside_the_web_request_is_shared(self): + "<", name) self.assertIn('Name="user-agent"', sent[0]) + def test_a_repeat_with_keys_in_another_case_is_shared_once(self): + # evidence.md line 10, "Keys MUST be case-insensitive", so the same + # values under keys spelled in another case are a repeat. + usage = usage_element() + pipeline = PipelineBuilder(NO_DEFAULT_ELEMENTS).add(usage).build() + for key in ("header.x-test", "Header.X-Test"): + flow_data = pipeline.create_flowdata() + flow_data.evidence.add("header.user-agent", "agent") + flow_data.evidence.add(key, "the value") + flow_data.process() + self.assertEqual(1, len(sent_on_close(usage))) + + def test_evidence_is_recorded_under_its_prefix_in_lower_case(self): + # The record names each value's element by the key's prefix, which + # is the same element whatever the letters of the key. + usage = usage_element() + pipeline = PipelineBuilder(NO_DEFAULT_ELEMENTS).add(usage).build() + flow_data = pipeline.create_flowdata() + flow_data.evidence.add("Header.X-Test", "the value") + flow_data.process() + sent = sent_on_close(usage) + self.assertEqual(1, len(sent)) + self.assertIn("
the value<", sent[0]) + if __name__ == "__main__": unittest.main() From 34dcb7a26cd86642f14f6a5278357add35d94c4c Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sat, 14 Nov 2026 16:53:23 +0000 Subject: [PATCH 2/3] Describe the Pipeline's Evidence, and read Evidence keys in any case The Pipeline had no way to give the Evidence it accepts. It now has get_evidence_key_filter, which accepts a key when any of its elements does, reading each element's filter when asked, as advertize-accepted-evidence.md line 5 asks. evidence.md line 10 says keys are case-insensitive. Usage sharing hashed the values for its repeat check under keys as spelled, so the same values under keys in another case were shared again, and it named each record element by the key's prefix as spelled. On-premise Device Detection gave the native evidence each key as spelled, where the native evidence matches the prefix with case, so Header.User-Agent was ignored. The repeat check now hashes lower case keys, the record element is the prefix in lower case, and the engine gives the native evidence each prefix in lower case. --- .../devicedetection_onpremise.py | 5 +++ .../src/fiftyone_pipeline_core/pipeline.py | 34 +++++++++++++++++++ .../share_usage.py | 10 ++++-- 3 files changed, 47 insertions(+), 2 deletions(-) diff --git a/device-detection/fiftyone_devicedetection_onpremise/src/fiftyone_devicedetection_onpremise/devicedetection_onpremise.py b/device-detection/fiftyone_devicedetection_onpremise/src/fiftyone_devicedetection_onpremise/devicedetection_onpremise.py index 0fd31aac..38cbafb7 100644 --- a/device-detection/fiftyone_devicedetection_onpremise/src/fiftyone_devicedetection_onpremise/devicedetection_onpremise.py +++ b/device-detection/fiftyone_devicedetection_onpremise/src/fiftyone_devicedetection_onpremise/devicedetection_onpremise.py @@ -478,7 +478,12 @@ def process_internal(self, flow_data): evidence = EvidenceDeviceDetectionSwig() # The native evidence takes text, and the Sequence Element adds a number. + # Evidence keys ignore case, but the native evidence matches a key's + # prefix with case, so the prefix is given in lower case. for key, value in flow_data.evidence.get_all().items(): + prefix, dot, field = key.partition(".") + if dot: + key = prefix.lower() + dot + field evidence.__setitem__(key, str(value)) result = self.engine.process(evidence) diff --git a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/pipeline.py b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/pipeline.py index 4ee68108..b859fecf 100644 --- a/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/pipeline.py +++ b/pipeline/fiftyone_pipeline_core/src/fiftyone_pipeline_core/pipeline.py @@ -20,9 +20,30 @@ # such notice(s) shall fulfill the requirements of that article. # ********************************************************************* +from .evidence_keyfilter import EvidenceKeyFilter from .flowdata import FlowData from .logger import Logger + +class PipelineEvidenceKeyFilter(EvidenceKeyFilter): + + """! + The Evidence a Pipeline accepts, being every key one of its FlowElements + accepts. Each element's filter is read when a key is checked, as an + element's list can change once its data or the cloud service's answer + arrives. + + """ + + def __init__(self, pipeline): + self.pipeline = pipeline + + def filter_evidence_key(self, key): + return any( + element.get_evidence_key_filter().filter_evidence_key(key) + for element in self.pipeline.flow_elements_list.values()) + + class Pipeline: """! @@ -168,6 +189,19 @@ def get_element(self, key): return self.flow_elements_list[key] + def get_evidence_key_filter(self): + + """! + The Evidence the Pipeline accepts, being every key one of its + FlowElements accepts. + + @rtype: EvidenceKeyFilter + @return: Returns an EvidenceKeyFilter + + """ + + return PipelineEvidenceKeyFilter(self) + def element_not_found(self, element, flowdata): """! Trigger error when an element cannot be found in the pipeline diff --git a/pipeline/fiftyone_pipeline_engines_fiftyone/src/fiftyone_pipeline_engines_fiftyone/share_usage.py b/pipeline/fiftyone_pipeline_engines_fiftyone/src/fiftyone_pipeline_engines_fiftyone/share_usage.py index f35fac78..176a7111 100644 --- a/pipeline/fiftyone_pipeline_engines_fiftyone/src/fiftyone_pipeline_engines_fiftyone/share_usage.py +++ b/pipeline/fiftyone_pipeline_engines_fiftyone/src/fiftyone_pipeline_engines_fiftyone/share_usage.py @@ -693,7 +693,11 @@ def process_internal(self, flow_data): # accepts no evidence of its own. cachekey = self.tracker_evidence_key_filter.filter_evidence( self.found_evidence(flow_data)) - cachekey = json.dumps(cachekey, sort_keys=True, default=str) + # Evidence keys ignore case, so a repeat is found whatever the + # letters of its keys. + cachekey = json.dumps( + {key.lower(): value for key, value in cachekey.items()}, + sort_keys=True, default=str) share = self.tracker.track(cachekey) if share: self.tracker.set_cache_value(cachekey) @@ -864,9 +868,11 @@ def try_add(self, key, value): category = "" field = key + # Evidence keys ignore case, so the prefix, which names the record's + # element, is in lower case whatever the letters of the key. first_separator = key.find(".") if first_separator > 0: - category = key[0:first_separator] + category = key[0:first_separator].lower() field = key[first_separator + 1:len(key)] # Add the evidence to the dictionary. From 0c514e764a48eb0a15552c5702bd914e3321d6b2 Mon Sep 17 00:00:00 2001 From: James Rosewell Date: Sun, 15 Nov 2026 00:26:45 +0000 Subject: [PATCH 3/3] Close each flow data in the Evidence key case test, which leaves no native results open --- .../tests/test_evidence_key_case.py | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py b/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py index 8d225b9d..c3ded15d 100644 --- a/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py +++ b/device-detection/fiftyone_devicedetection_onpremise/tests/test_evidence_key_case.py @@ -46,20 +46,21 @@ def setUp(self): self.addCleanup(self.pipeline.close) def is_mobile(self, key): - flowdata = self.pipeline.create_flowdata() - flowdata.evidence.add(key, MOBILE_USER_AGENT) - flowdata.process() - return flowdata.device.ismobile + # The value is read before the flow data closes, which releases its + # native results. + with self.pipeline.create_flowdata() as flowdata: + flowdata.evidence.add(key, MOBILE_USER_AGENT) + flowdata.process() + ismobile = flowdata.device.ismobile + self.assertTrue(ismobile.has_value(), key) + return ismobile.value() def test_a_key_in_any_case_reaches_the_native_engine(self): for key in ("Header.User-Agent", "HEADER.USER-AGENT", "Query.User-Agent"): with self.subTest(key=key): - expected = self.is_mobile(key.lower()) - actual = self.is_mobile(key) - self.assertTrue(expected.has_value()) - self.assertTrue(actual.has_value()) - self.assertEqual(expected.value(), actual.value()) + self.assertEqual( + self.is_mobile(key.lower()), self.is_mobile(key)) if __name__ == "__main__":