From ebafb06c6f1c63d7eba43a5a293e39314cec2a4f Mon Sep 17 00:00:00 2001 From: Tzahi Nemet Date: Sat, 3 Oct 2026 08:48:55 +0300 Subject: [PATCH] feat: auto-detect Hebrew/English per query, add Auto language option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `autocomplete_lang` was a fixed setting, so a Hebrew search needed one language and the next English search needed the opposite - manual switching between every query. detect_language(text) returns "he" the moment any character in U+0590-U+05FF appears anywhere in the query (reusing the same Hebrew block prep_search_str already tests a subset of), "en" otherwise; digits and Latin punctuation never flip it, and a single Hebrew character in an otherwise-English query is enough ("שובר bad" -> he). resolve_language(search_str) is the one place that decides the EFFECTIVE language for a request: if autocomplete_lang is "auto" it calls detect_language on the text just typed, otherwise it returns the configured value completely unchanged - an existing install with a real language code set behaves byte-for-byte as before. The language is passed into each provider as a constructor kwarg rather than mutated onto a shared setting, so nothing is global and nothing needs resetting between searches. Google (hl=) and TMDb (language=) now receive the resolved value. Bing's build_url never referenced self.language and still doesn't - it was never asked to change. LocalDictProvider keeps reading its own separate autocomplete_lang_local setting, untouched. resources/settings.xml gains a single new `auto` option in the existing autocomplete_lang spinner; the default stays `en`, so nothing changes for anyone who hasn't picked it. 13 tests in tests/, covering the exact examples from the request plus the two guarantees most likely to regress silently: a fixed language must survive being read again unchanged, and the "auto" sentinel must never reach a real Google/TMDb request URL. AutoCompletion.py resolves xbmcaddon/xbmcvfs at import time rather than inside a function, so tests/kodistubs.py fakes those three modules in sys.modules before import - no third-party test dependency, consistent with the module's own zero-dependency rule. Also adds .github/workflows/ci.yml to run compileall + the new tests on every push and PR (the existing release.yml only runs on a version tag). Manual verification still needed on real hardware (not reproducible outside a running Kodi instance): physical-keyboard and virtual-keyboard input both trigger correct per-query language switching, and no exception appears in kodi.log for either input method. --- .github/workflows/ci.yml | 34 ++++++++++ lib/vendor/AutoCompletion.py | 50 ++++++++++++-- resources/settings.xml | 5 ++ tests/kodistubs.py | 71 ++++++++++++++++++++ tests/test_language.py | 126 +++++++++++++++++++++++++++++++++++ 5 files changed, 280 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 tests/kodistubs.py create mode 100644 tests/test_language.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..3aa2999 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + + - name: Set up Python + uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 + with: + python-version: "3.12" + + - name: Sanity-check Python sources + run: python3 -m compileall -q default.py plugin.py service.py lib + + # No Kodi here, and none of these modules may import a third-party test + # runner (the addon ships with zero third-party dependencies, by the + # repo's own rule - see AutoCompletion.py's module docstring). tests/ + # fakes xbmc/xbmcaddon/xbmcvfs in sys.modules before import instead of + # mocking a function call, because AutoCompletion.py resolves settings + # and paths at IMPORT time, not inside a function a test could patch. + - name: Run unit tests + run: python3 -m unittest discover -s tests -v diff --git a/lib/vendor/AutoCompletion.py b/lib/vendor/AutoCompletion.py index 408b68f..354ca4a 100644 --- a/lib/vendor/AutoCompletion.py +++ b/lib/vendor/AutoCompletion.py @@ -37,22 +37,56 @@ def get_autocomplete_items(search_str, limit=10, provider=None): if xbmc.getCondVisibility("System.HasHiddenInput"): return [] + language = resolve_language(search_str) setting = SETTING("autocomplete_provider").lower() if setting == "youtube": - provider = GoogleProvider(youtube=True, limit=limit) + provider = GoogleProvider(youtube=True, limit=limit, language=language) elif setting == "google": - provider = GoogleProvider(limit=limit) + provider = GoogleProvider(limit=limit, language=language) elif setting == "bing": - provider = BingProvider(limit=limit) + provider = BingProvider(limit=limit, language=language) elif setting == "tmdb": - provider = TmdbProvider(limit=limit) + provider = TmdbProvider(limit=limit, language=language) else: - provider = LocalDictProvider(limit=limit) + provider = LocalDictProvider(limit=limit, language=language) provider.limit = limit return provider.get_predictions(search_str) +def detect_language(text): + """he/en from the query text alone: one Hebrew character (U+0590-U+05FF) + makes it Hebrew. That block is the same one `prep_search_str` below + already tests against (1488-1514 is its letter subset), reused here per + the request rather than invented fresh. Digits and Latin punctuation sit + outside the block, so neither affects the result; a mixed string with + even one Hebrew character returns "he" — the detector runs on the whole + query, not its first token, so "שובר bad" is Hebrew exactly like "שובר + שורות" is. + """ + for char in text or "": + if "֐" <= char <= "׿": + return "he" + return "en" + + +def resolve_language(search_str): + """The effective language for this one request. + + `autocomplete_lang` stays a fixed value for everyone who has not touched + the new "auto" option — existing installs keep behaving exactly as + before. Picking "auto" is what turns detection on, and the result is + returned per call rather than written back into any setting or global: + the configured value needs to survive being read again next time + unchanged, and a fixed value must never be overwritten by whichever + language happened to be typed last. + """ + configured = (SETTING("autocomplete_lang") or "").strip() + if configured.lower() == "auto": + return detect_language(search_str) + return configured + + def prep_search_str(text): for char in text: if 1488 <= ord(char) <= 1514: @@ -66,7 +100,11 @@ class BaseProvider(ABC): def __init__(self, *args, **kwargs): self.limit = kwargs.get("limit", 10) - self.language = SETTING("autocomplete_lang") + # `language` is passed per call by get_autocomplete_items (the already + # resolved value, auto-detected or fixed). The SETTING() fallback is + # for the provider classes' own stated contract of being constructible + # on their own; nothing in this codebase instantiates one without it. + self.language = kwargs.get("language") or SETTING("autocomplete_lang") @abstractmethod def build_url(self, query): diff --git a/resources/settings.xml b/resources/settings.xml index f0cf5c6..2080bef 100644 --- a/resources/settings.xml +++ b/resources/settings.xml @@ -26,6 +26,11 @@ en + + diff --git a/tests/kodistubs.py b/tests/kodistubs.py new file mode 100644 index 0000000..b7a390e --- /dev/null +++ b/tests/kodistubs.py @@ -0,0 +1,71 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""Minimal fake `xbmc`/`xbmcaddon`/`xbmcvfs` so `lib/vendor/AutoCompletion.py` +can be imported outside Kodi. + +That module reads addon settings and resolves paths at IMPORT time (module- +level `PLUGIN_ADDON = xbmcaddon.Addon(PLUGIN_ID)`), so a test cannot simply +mock a function call — the fakes have to exist in `sys.modules` before the +`import AutoCompletion` line runs. `install()` does exactly that and nothing +else: no pip install, no real Kodi, consistent with this repo's own +zero-third-party-dependency rule (the module docstring: "requests replaced +with stdlib urllib"). + +Call `install()` once at the top of a test module, before importing +AutoCompletion. `settings` is a plain dict a test can mutate directly to +change what `SETTING("autocomplete_lang")` returns mid-test. +""" +from __future__ import annotations + +import sys +import types + +settings: dict[str, str] = {"autocomplete_lang": "en", "autocomplete_provider": "Google"} + + +class _FakeAddon: + def getSetting(self, key): + return settings.get(key, "") + + def getAddonInfo(self, key): + return {"path": "/addon", "id": "plugin.program.autocompletion", + "profile": "/profile"}.get(key, "") + + +def _fake_xbmcaddon(): + module = types.ModuleType("xbmcaddon") + module.Addon = lambda *a, **k: _FakeAddon() + return module + + +def _fake_xbmcvfs(): + module = types.ModuleType("xbmcvfs") + module.translatePath = lambda path: path + module.exists = lambda path: False + module.mkdirs = lambda path: None + module.File = lambda *a, **k: None + return module + + +def _fake_xbmc(): + module = types.ModuleType("xbmc") + module.getCondVisibility = lambda condition: False + module.log = lambda msg="", level=0: None + module.LOGDEBUG = 0 + + class _Monitor: + def abortRequested(self): + return False + + def waitForAbort(self, seconds): + return False + + module.Monitor = _Monitor + return module + + +def install(): + """Idempotent: safe to call from every test module that needs it.""" + for name, factory in (("xbmc", _fake_xbmc), ("xbmcaddon", _fake_xbmcaddon), + ("xbmcvfs", _fake_xbmcvfs)): + if name not in sys.modules: + sys.modules[name] = factory() diff --git a/tests/test_language.py b/tests/test_language.py new file mode 100644 index 0000000..20cbef5 --- /dev/null +++ b/tests/test_language.py @@ -0,0 +1,126 @@ +# SPDX-License-Identifier: GPL-2.0-or-later +"""Hebrew/English auto-detection: `detect_language`, `resolve_language`, and +the providers that build a URL from whichever language came out of them. + +The failure mode this guards against is not "detection is wrong" in the +abstract — it is "a fixed install with `autocomplete_lang=en` quietly starts +behaving differently", or "the Google/TMDb URL gets `hl=auto`/`language=auto` +sent to the real API because the sentinel leaked past the point meant to +resolve it". +""" +import os +import sys +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "lib", "vendor")) +sys.path.insert(0, os.path.dirname(__file__)) + +import kodistubs # noqa: E402 + +kodistubs.install() + +import AutoCompletion # noqa: E402 + + +class TestDetectLanguage(unittest.TestCase): + def test_the_examples_from_the_spec(self): + cases = { + "brea": "en", + "שובר": "he", + "breaking": "en", + "breaking bad": "en", + "שובר שורות": "he", + "game of thrones": "en", + "משחקי הכס": "he", + "matrix 2": "en", + "מטריקס 2": "he", + } + for query, expected in cases.items(): + self.assertEqual(AutoCompletion.detect_language(query), expected, query) + + def test_mixed_text_is_hebrew_if_any_hebrew_character_is_present(self): + """A single Hebrew character anywhere in the string is enough — the + detector reads the whole query, not just its first word.""" + self.assertEqual(AutoCompletion.detect_language("שובר bad"), "he") + self.assertEqual(AutoCompletion.detect_language("bad שובר"), "he") + + def test_numbers_and_punctuation_never_flip_the_result(self): + self.assertEqual(AutoCompletion.detect_language("matrix 2!?"), "en") + self.assertEqual(AutoCompletion.detect_language("מטריקס 2!?"), "he") + self.assertEqual(AutoCompletion.detect_language("123 456"), "en") + + def test_empty_or_none_is_english_rather_than_an_exception(self): + """A query comes from a live keyboard; an empty string arrives, and + must not be the thing that turns a normal search into a traceback.""" + self.assertEqual(AutoCompletion.detect_language(""), "en") + self.assertEqual(AutoCompletion.detect_language(None), "en") + + +class TestResolveLanguage(unittest.TestCase): + def setUp(self): + kodistubs.settings["autocomplete_lang"] = "en" + + def test_auto_detects_from_the_query(self): + kodistubs.settings["autocomplete_lang"] = "auto" + self.assertEqual(AutoCompletion.resolve_language("שובר"), "he") + self.assertEqual(AutoCompletion.resolve_language("breaking"), "en") + + def test_auto_is_matched_case_and_space_insensitively(self): + """A value typed by hand into an advanced setting, or carried over + from an older skin, should not silently stop auto-detecting.""" + for value in ("Auto", "AUTO", " auto ", "auto"): + kodistubs.settings["autocomplete_lang"] = value + self.assertEqual(AutoCompletion.resolve_language("שובר"), "he") + + def test_a_fixed_language_is_returned_unchanged_no_matter_what_was_typed(self): + """This is the backward-compatibility guarantee: an existing install + with a real language code set must behave byte-for-byte as before - + Hebrew text typed into an `en`-fixed install still resolves to `en`.""" + for fixed in ("en", "fr", "he", "ja"): + kodistubs.settings["autocomplete_lang"] = fixed + self.assertEqual(AutoCompletion.resolve_language("שובר"), fixed) + self.assertEqual(AutoCompletion.resolve_language("breaking"), fixed) + + def test_an_empty_setting_does_not_crash_and_is_not_treated_as_auto(self): + kodistubs.settings["autocomplete_lang"] = "" + self.assertEqual(AutoCompletion.resolve_language("שובר"), "") + + +class TestProvidersUseTheResolvedLanguage(unittest.TestCase): + """The sentinel "auto" must never reach a real request URL.""" + + def test_google_hl_parameter(self): + url = AutoCompletion.GoogleProvider(language="he").build_url("x") + self.assertIn("hl=he", url) + self.assertNotIn("auto", url) + + def test_tmdb_language_parameter(self): + url = AutoCompletion.TmdbProvider(language="he").build_url("x") + self.assertIn("language=he", url) + self.assertNotIn("auto", url) + + def test_bing_is_untouched_because_it_never_used_the_language_setting(self): + """Bing's build_url never referenced self.language before this change + and must not start now - the spec says explicitly not to touch a + provider that doesn't already use the setting.""" + url = AutoCompletion.BingProvider(language="he").build_url("x") + self.assertNotIn("he", url) + self.assertNotIn("language", url) + + def test_local_dict_provider_keeps_its_own_separate_setting(self): + """LocalDictProvider has always read `autocomplete_lang_local`, a + different setting, and must keep doing so even though it now also + receives a `language` kwarg it does not ask for.""" + kodistubs.settings["autocomplete_lang_local"] = "fr" + provider = AutoCompletion.LocalDictProvider(language="he") + self.assertEqual(provider.language, "fr") + + def test_a_provider_built_with_no_language_kwarg_falls_back_to_the_setting(self): + """Nothing in this codebase constructs a provider this way anymore, + but the class is still a public contract on its own.""" + kodistubs.settings["autocomplete_lang"] = "de" + self.assertEqual(AutoCompletion.GoogleProvider().language, "de") + + +if __name__ == "__main__": + unittest.main()