From 278f408ddc20ad8f0fda5da022d6d6f657e311d2 Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Sat, 24 Oct 2026 21:21:31 +0200 Subject: [PATCH 1/2] Import cleanup --- birdnet_analyzer/__init__.py | 6 - birdnet_analyzer/gui/__init__.py | 4 + birdnet_analyzer/gui/localization.py | 32 ++--- birdnet_analyzer/gui/single_file.py | 65 ++++++----- birdnet_analyzer/gui/utils.py | 66 ++++++----- birdnet_analyzer/utils.py | 29 ++++- tests/gui/test_localization.py | 17 --- tests/gui/test_main.py | 159 +++++++++++++++++++++++++ tests/test_runtime.py | 169 +++++++++++++++++++++++++++ 9 files changed, 449 insertions(+), 98 deletions(-) create mode 100644 tests/gui/test_main.py create mode 100644 tests/test_runtime.py diff --git a/birdnet_analyzer/__init__.py b/birdnet_analyzer/__init__.py index fdf354432..9b25de1be 100644 --- a/birdnet_analyzer/__init__.py +++ b/birdnet_analyzer/__init__.py @@ -1,6 +1,4 @@ import logging -import os -import warnings from birdnet_analyzer.settings import apply_model_directory @@ -21,7 +19,3 @@ __version__ = "2.4.0" __all__ = ["analyze", "embeddings", "search", "segments", "species", "train"] -os.environ["TF_CPP_MIN_LOG_LEVEL"] = "3" -os.environ["WRAPT_DISABLE_EXTENSIONS"] = "true" - -warnings.filterwarnings("ignore") diff --git a/birdnet_analyzer/gui/__init__.py b/birdnet_analyzer/gui/__init__.py index badac177b..4f6424e78 100644 --- a/birdnet_analyzer/gui/__init__.py +++ b/birdnet_analyzer/gui/__init__.py @@ -1,4 +1,5 @@ def main(): + import birdnet_analyzer.gui.localization as loc import birdnet_analyzer.gui.multi_file as mfa import birdnet_analyzer.gui.segments as gs import birdnet_analyzer.gui.single_file as sfa @@ -12,8 +13,11 @@ def main(): train, ) from birdnet_analyzer.logs import setup_logging + from birdnet_analyzer.utils import configure_runtime setup_logging() + configure_runtime() + loc.load_local_state() gu.open_window( [ sfa.build_single_analysis_tab, diff --git a/birdnet_analyzer/gui/localization.py b/birdnet_analyzer/gui/localization.py index 347132dd7..0812f9b31 100644 --- a/birdnet_analyzer/gui/localization.py +++ b/birdnet_analyzer/gui/localization.py @@ -5,11 +5,26 @@ from birdnet_analyzer import settings -LANGUAGE_LOOKUP = {} -TARGET_LANGUAGE = settings.FALLBACK_LANGUAGE logger = logging.getLogger(__name__) +def available_languages() -> list[str]: + """The ids of the languages the GUI has a language file for, sorted.""" + return sorted(file.stem for file in Path(settings.LANG_DIR).glob("*.json")) + + +def _read_language_file(language: str) -> dict: + if language not in available_languages(): + raise ValueError(f"Unknown language {language!r}") + + with open(f"{settings.LANG_DIR}/{language}.json", encoding="utf-8") as f: + return json.load(f) + + +TARGET_LANGUAGE = settings.FALLBACK_LANGUAGE +LANGUAGE_LOOKUP = _read_language_file(TARGET_LANGUAGE) + + def load_local_state(): """ Loads the local language settings and populates the LANGUAGE_LOOKUP dictionary with @@ -35,19 +50,6 @@ def load_local_state(): LANGUAGE_LOOKUP = fallback | LANGUAGE_LOOKUP -def available_languages() -> list[str]: - """The ids of the languages the GUI has a language file for, sorted.""" - return sorted(file.stem for file in Path(settings.LANG_DIR).glob("*.json")) - - -def _read_language_file(language: str) -> dict: - if language not in available_languages(): - raise ValueError(f"Unknown language {language!r}") - - with open(f"{settings.LANG_DIR}/{language}.json", encoding="utf-8") as f: - return json.load(f) - - def localize(key: str) -> str: """ Translates a given key into its corresponding localized string. diff --git a/birdnet_analyzer/gui/single_file.py b/birdnet_analyzer/gui/single_file.py index 10b4adc48..3e2fc6a41 100644 --- a/birdnet_analyzer/gui/single_file.py +++ b/birdnet_analyzer/gui/single_file.py @@ -17,11 +17,24 @@ MATPLOTLIB_FIGURE_NUM = "single-file-tab-spectrogram-plot" MAX_TABLE_ROWS = 50 -HEADER_START_LBL = loc.localize("single-tab-output-header-start") -HEADER_END_LBL = loc.localize("single-tab-output-header-end") -HEADER_SCI_NAME_LBL = loc.localize("single-tab-output-header-sci-name") -HEADER_COMMON_NAME_LBL = loc.localize("single-tab-output-header-common-name") -HEADER_CONFIDENCE_LBL = loc.localize("single-tab-output-header-confidence") +_HEADER_LABELS = { + "HEADER_START_LBL": "single-tab-output-header-start", + "HEADER_END_LBL": "single-tab-output-header-end", + "HEADER_SCI_NAME_LBL": "single-tab-output-header-sci-name", + "HEADER_COMMON_NAME_LBL": "single-tab-output-header-common-name", + "HEADER_CONFIDENCE_LBL": "single-tab-output-header-confidence", +} + + +def _header_labels() -> list[str]: + return [loc.localize(key) for key in _HEADER_LABELS.values()] + + +def __getattr__(name: str) -> str: + if name in _HEADER_LABELS: + return loc.localize(_HEADER_LABELS[name]) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") @gu.gui_runtime_error_handler @@ -109,41 +122,40 @@ def convert_to_time_str(seconds: float) -> str: ) n_rows = table.shape[0] + start_lbl, end_lbl, sci_name_lbl, common_name_lbl, confidence_lbl = _header_labels() if n_rows > 0: split_species = table["species_name"].str.split("_", n=1, expand=True) - table[HEADER_SCI_NAME_LBL] = split_species[0] - table[HEADER_COMMON_NAME_LBL] = ( + table[sci_name_lbl] = split_species[0] + table[common_name_lbl] = ( split_species[1] if len(split_species.columns) > 1 else "" ) else: - table[HEADER_SCI_NAME_LBL] = [] - table[HEADER_COMMON_NAME_LBL] = [] + table[sci_name_lbl] = [] + table[common_name_lbl] = [] table[" "] = ["▶"] * n_rows table.rename( columns={ - "start_time": HEADER_START_LBL, - "end_time": HEADER_END_LBL, - "confidence": HEADER_CONFIDENCE_LBL, + "start_time": start_lbl, + "end_time": end_lbl, + "confidence": confidence_lbl, }, inplace=True, ) - table[HEADER_START_LBL] = table[HEADER_START_LBL].apply(convert_to_time_str) - table[HEADER_END_LBL] = table[HEADER_END_LBL].apply(convert_to_time_str) + table[start_lbl] = table[start_lbl].apply(convert_to_time_str) + table[end_lbl] = table[end_lbl].apply(convert_to_time_str) table = table[ [ " ", - HEADER_START_LBL, - HEADER_END_LBL, - HEADER_SCI_NAME_LBL, - HEADER_COMMON_NAME_LBL, - HEADER_CONFIDENCE_LBL, + start_lbl, + end_lbl, + sci_name_lbl, + common_name_lbl, + confidence_lbl, ] ] - table[HEADER_CONFIDENCE_LBL] = table[HEADER_CONFIDENCE_LBL].apply( - lambda x: f"{x:0.3f}" - ) + table[confidence_lbl] = table[confidence_lbl].apply(lambda x: f"{x:0.3f}") return ( table, @@ -241,14 +253,7 @@ def build_single_analysis_tab() -> gu.TAB_BUILDER_RESULT: output_dataframe = gr.Dataframe( type="pandas", - headers=[ - " ", - HEADER_START_LBL, - HEADER_END_LBL, - HEADER_SCI_NAME_LBL, - HEADER_COMMON_NAME_LBL, - HEADER_CONFIDENCE_LBL, - ], + headers=[" ", *_header_labels()], elem_id="single-file-output", interactive=False, buttons=[], diff --git a/birdnet_analyzer/gui/utils.py b/birdnet_analyzer/gui/utils.py index f14135a6e..72b3340f2 100644 --- a/birdnet_analyzer/gui/utils.py +++ b/birdnet_analyzer/gui/utils.py @@ -8,7 +8,6 @@ import platform import sys import threading -import warnings from collections.abc import Callable from contextlib import contextmanager, suppress from html import escape @@ -26,16 +25,16 @@ if TYPE_CHECKING: import webview -warnings.filterwarnings("ignore") -loc.load_local_state() - logger = logging.getLogger(__name__) SCRIPT_DIR = os.path.abspath(os.path.dirname(__file__)) -CUSTOM_SPECIES = loc.localize("species-list-radio-option-custom-list") -PREDICT_SPECIES = loc.localize("species-list-radio-option-predict-list") -CUSTOM_CLASSIFIER = loc.localize("species-list-radio-option-custom-classifier") -ALL_SPECIES = loc.localize("species-list-radio-option-all") -USE_PERCH = loc.localize("species-list-radio-option-use-perch") +# Localized on access, so they follow the language gui.main loads. +_LOCALIZED_LABELS = { + "CUSTOM_SPECIES": "species-list-radio-option-custom-list", + "PREDICT_SPECIES": "species-list-radio-option-predict-list", + "CUSTOM_CLASSIFIER": "species-list-radio-option-custom-classifier", + "ALL_SPECIES": "species-list-radio-option-all", + "USE_PERCH": "species-list-radio-option-use-perch", +} # BirdNET acoustic model choices. Not localized: the version number is the label. USE_BIRDNET_2_4 = "BirdNET 2.4" USE_BIRDNET_3_0 = "BirdNET 3.0" @@ -84,6 +83,17 @@ } +def _label(name: str) -> str: + return loc.localize(_LOCALIZED_LABELS[name]) + + +def __getattr__(name: str) -> str: + if name in _LOCALIZED_LABELS: + return _label(name) + + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + def is_birdnet_model(model_choice: str) -> bool: """Whether the selected model is an official BirdNET acoustic model.""" return model_choice in _BIRDNET_MODEL_VERSIONS @@ -98,14 +108,16 @@ def model_supports_sensitivity(model_choice: str) -> bool: """Whether the sensitivity slider applies to the selected model choice.""" from birdnet_analyzer.model_utils import supports_sensitivity - return supports_sensitivity("perch" if model_choice == USE_PERCH else "birdnet") + return supports_sensitivity( + "perch" if model_choice == _label("USE_PERCH") else "birdnet" + ) def _species_choices(is_perch: bool) -> list[str]: if is_perch: - return [ALL_SPECIES, CUSTOM_SPECIES] + return [_label("ALL_SPECIES"), _label("CUSTOM_SPECIES")] - return [ALL_SPECIES, CUSTOM_SPECIES, PREDICT_SPECIES] + return [_label("ALL_SPECIES"), _label("CUSTOM_SPECIES"), _label("PREDICT_SPECIES")] def _max_overlap(is_perch: bool) -> float: @@ -782,10 +794,11 @@ def model_choices(): """Returns the models that can be selected on the current platform.""" birdnet_models = [label for label, _ in birdnet_model_choices()] - values = [*birdnet_models, CUSTOM_CLASSIFIER, USE_PERCH] + values = [*birdnet_models, _label("CUSTOM_CLASSIFIER"), _label("USE_PERCH")] if platform.system() == "Darwin": - values.remove(USE_PERCH) # TODO: Remove when tf 2.21+ is available on macOS + # TODO: Remove when tf 2.21+ is available on macOS + values.remove(_label("USE_PERCH")) return values @@ -803,7 +816,7 @@ def sample_species_model_settings(state: TabState, opened=True): model_choice = state.get( "model_selection_radio", default_model(), choices=model_choices() ) - is_perch = model_choice == USE_PERCH + is_perch = model_choice == _label("USE_PERCH") sample_settings = sample_sliders( state, @@ -815,7 +828,7 @@ def sample_species_model_settings(state: TabState, opened=True): model_settings = model_selection(state, opened=opened) def on_model_change(value, species_choice, overlap, current_locale): - is_perch = value == USE_PERCH + is_perch = value == _label("USE_PERCH") choices = _species_choices(is_perch) max_overlap = _max_overlap(is_perch) shown = { @@ -828,7 +841,7 @@ def on_model_change(value, species_choice, overlap, current_locale): # Keep the current species selection (e.g. the one a preset was just # applied with) as long as the new model offers it. if species_choice not in choices: - fitted["species_list_radio"] = ALL_SPECIES + fitted["species_list_radio"] = _label("ALL_SPECIES") if model_supports_sensitivity(value): sensitivity_update = gr.update( @@ -1285,13 +1298,13 @@ def show_species_choice(choice: str, file_input): Column update, ] """ - if choice == CUSTOM_SPECIES: + if choice == _label("CUSTOM_SPECIES"): return [ gr.update(visible=False), gr.update(visible=True), gr.update(visible=bool(file_input)), ] - if choice == PREDICT_SPECIES: + if choice == _label("PREDICT_SPECIES"): return [ gr.update(visible=True), gr.update(visible=False), @@ -1322,7 +1335,7 @@ def model_selection(state: TabState, opened=True): selected_model = model_selection_radio.value with gr.Column( - visible=selected_model == CUSTOM_CLASSIFIER + visible=selected_model == _label("CUSTOM_CLASSIFIER") ) as custom_classifier_selector: classifier_selection_button = gr.Button( loc.localize( @@ -1406,7 +1419,7 @@ def on_custom_classifier_selection_click(): ) def on_model_selection_change(choice: str, cc_state): - if choice == CUSTOM_CLASSIFIER: + if choice == _label("CUSTOM_CLASSIFIER"): return gr.update(visible=True), gr.update(visible=cc_state is not None) return gr.update(visible=False), gr.update(visible=False) @@ -1448,7 +1461,7 @@ def species_lists( gr.Radio, accepts={"choices": _species_choices(is_perch=False)}, choices=_species_choices(is_perch), - value=ALL_SPECIES, + value=_label("ALL_SPECIES"), label=loc.localize("species-list-radio-label"), info=loc.localize("species-list-radio-info"), elem_classes="d-block", @@ -1456,7 +1469,7 @@ def species_lists( selected_species_list = species_list_radio.value with gr.Column( - visible=selected_species_list == PREDICT_SPECIES + visible=selected_species_list == _label("PREDICT_SPECIES") ) as position_row: ( lat_number, @@ -1469,7 +1482,7 @@ def species_lists( species_file_input = gr.File( file_types=[".txt"], - visible=selected_species_list == CUSTOM_SPECIES, + visible=selected_species_list == _label("CUSTOM_SPECIES"), show_label=False, ) @@ -1731,10 +1744,7 @@ def open_window( multiprocessing.freeze_support() with ( - gr.Blocks( - theme=gr.themes.Default(), - analytics_enabled=False, - ) as demo, + gr.Blocks(analytics_enabled=False) as demo, ): build_header() diff --git a/birdnet_analyzer/utils.py b/birdnet_analyzer/utils.py index 210dcae22..3fab4b062 100644 --- a/birdnet_analyzer/utils.py +++ b/birdnet_analyzer/utils.py @@ -2,6 +2,7 @@ import itertools import os +import warnings from functools import cache from pathlib import Path @@ -9,12 +10,35 @@ from birdnet_analyzer.logs import setup_logging from birdnet_analyzer.settings import write_error_log +# Raised by gradio's own gr.List/gr.Matrix defaults and event loop helpers. +_UNACTIONABLE_WARNINGS = [ + (DeprecationWarning, "gradio", "Passing a tuple to 'row_count'"), + (DeprecationWarning, "gradio", "The 'col_count' parameter will be removed"), + (UserWarning, "gradio", "The `col_count` parameter is deprecated"), + (ResourceWarning, "asyncio", "unclosed event loop"), +] + + +def configure_runtime(): + """Prepares the process of a shipped application (CLI or GUI). + + Sets defaults for the environment variables read when tensorflow is first + imported, keeping any value the user set, and hides the warnings a user cannot + act on. + """ + os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") + os.environ.setdefault("WRAPT_DISABLE_EXTENSIONS", "true") + + for category, module, message in _UNACTIONABLE_WARNINGS: + warnings.filterwarnings("ignore", message, category, module) + def runtime_error_handler(f): """Decorator preparing the runtime of the CLI entry points. - Configures logging, so messages reach the console and the error log file, and - catches runtime errors to write them to the error log. + Configures logging, so messages reach the console and the error log file, + prepares the process with configure_runtime, and catches runtime errors to + write them to the error log. Args: f: The function to be decorated. @@ -25,6 +49,7 @@ def runtime_error_handler(f): def wrapper(*args, **kwargs): setup_logging() + configure_runtime() try: return f(*args, **kwargs) diff --git a/tests/gui/test_localization.py b/tests/gui/test_localization.py index 6e197978e..1a06594e0 100644 --- a/tests/gui/test_localization.py +++ b/tests/gui/test_localization.py @@ -1,6 +1,4 @@ -import importlib import json -import sys from pathlib import Path import pytest @@ -51,18 +49,3 @@ def test_an_unknown_language_falls_back_to_the_default_strings(appdir, language) assert loc.TARGET_LANGUAGE == settings.FALLBACK_LANGUAGE assert fallback_lookup() == loc.LANGUAGE_LOOKUP - - -def test_the_gui_starts_despite_a_corrupted_settings_file(appdir, monkeypatch): - pytest.importorskip("gradio") - import birdnet_analyzer.gui - import birdnet_analyzer.gui.utils as gu - - (appdir / "gui-settings.json").write_text("{not json", encoding="utf-8") - monkeypatch.delitem(sys.modules, gu.__name__) - monkeypatch.setattr(birdnet_analyzer.gui, "utils", gu) - - fresh = importlib.import_module(gu.__name__) - - assert fresh is not gu - assert loc.TARGET_LANGUAGE == settings.FALLBACK_LANGUAGE diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py new file mode 100644 index 000000000..9f8f35fc2 --- /dev/null +++ b/tests/gui/test_main.py @@ -0,0 +1,159 @@ +"""Starting the GUI through ``gui.main``, up to the window.""" + +import json +import os +import subprocess +import sys +import warnings +from pathlib import Path +from types import SimpleNamespace +from unittest import mock + +import pytest + +gr = pytest.importorskip("gradio") + +import birdnet_analyzer.gui # noqa: E402 +import birdnet_analyzer.gui.localization as loc # noqa: E402 +import birdnet_analyzer.gui.single_file as sfa # noqa: E402 +import birdnet_analyzer.gui.utils as gu # noqa: E402 +from birdnet_analyzer import logs, settings # noqa: E402 + +LABEL_KEYS = [ + "species-list-radio-option-custom-list", + "single-tab-output-header-confidence", +] + +IMPORT_PROBE = """ +import json +import os +import sys +from pathlib import Path + +import birdnet_analyzer +from birdnet_analyzer import settings + +assert settings.APPDIR.is_relative_to(Path(sys.argv[1])), settings.APPDIR +settings.set_setting("language-id", "de") + +appdir = os.path.normcase(os.path.abspath(settings.APPDIR)) +reads = [] + + +def audit(event, args): + if event == "open" and isinstance(args[0], (str, bytes, os.PathLike)): + path = os.path.normcase(os.path.abspath(os.fsdecode(args[0]))) + if path.startswith(appdir): + reads.append(path) + + +sys.addaudithook(audit) + +import birdnet_analyzer.gui.single_file as sfa +import birdnet_analyzer.gui.utils as gu + +reads_on_import = list(reads) +on_import = [gu.CUSTOM_SPECIES, sfa.HEADER_CONFIDENCE_LBL] + +import birdnet_analyzer.gui.localization as loc + +loc.load_local_state() + +print( + json.dumps( + { + "reads": reads_on_import, + "on_import": on_import, + "loaded": [gu.CUSTOM_SPECIES, sfa.HEADER_CONFIDENCE_LBL], + } + ) +) +""" + + +def labels(language: str) -> list[str]: + path = Path(settings.LANG_DIR) / f"{language}.json" + lookup = json.loads(path.read_text(encoding="utf-8")) + + return [lookup[key] for key in LABEL_KEYS] + + +def test_importing_the_gui_reads_no_settings_and_localizes_labels_on_access(tmp_path): + env = {**os.environ, "HOME": str(tmp_path), "USERPROFILE": str(tmp_path)} + result = subprocess.run( + [sys.executable, "-c", IMPORT_PROBE, str(tmp_path)], + capture_output=True, + text=True, + env=env, + timeout=110, + check=False, + ) + assert result.returncode == 0, result.stderr + seen = json.loads(result.stdout.splitlines()[-1]) + + assert seen["reads"] == [] + assert seen["on_import"] == labels("en") + assert seen["loaded"] == labels("de") + + +@pytest.fixture +def gui_main(monkeypatch): + """Runs ``gui.main`` and undoes what it configures for the process.""" + monkeypatch.setattr(loc, "TARGET_LANGUAGE", loc.TARGET_LANGUAGE) + monkeypatch.setattr(loc, "LANGUAGE_LOOKUP", loc.LANGUAGE_LOOKUP) + + def run(): + with mock.patch.dict(os.environ), warnings.catch_warnings(): + birdnet_analyzer.gui.main() + + yield run + + logs._remove_installed_handlers() + + +def test_the_window_opens_in_the_language_of_the_settings(monkeypatch, gui_main): + settings.set_setting("language-id", "de") + seen = [] + monkeypatch.setattr( + gu, + "open_window", + lambda builders: seen.append([gu.CUSTOM_SPECIES, sfa.HEADER_CONFIDENCE_LBL]), + ) + + gui_main() + + assert seen == [labels("de")] + + +def test_the_window_opens_despite_a_corrupted_settings_file(monkeypatch, gui_main): + Path(settings.GUI_SETTINGS_PATH).write_text("{not json", encoding="utf-8") + monkeypatch.setattr(loc, "TARGET_LANGUAGE", "de") + opened = [] + monkeypatch.setattr(gu, "open_window", opened.append) + + gui_main() + + assert len(opened) == 1 + assert loc.TARGET_LANGUAGE == settings.FALLBACK_LANGUAGE + + +class WindowBuilt(Exception): + pass + + +def test_the_window_is_built_without_a_warning(monkeypatch, gui_main): + def launch_instead(*args, **kwargs): + raise WindowBuilt + + monkeypatch.setitem(sys.modules, "webview", SimpleNamespace()) + monkeypatch.setattr(gr.Blocks, "launch", launch_instead) + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + + with pytest.raises(WindowBuilt): + gui_main() + + assert not caught, "\n".join( + f"{w.category.__name__} at {w.filename}:{w.lineno}: {w.message}" for w in caught + ) diff --git a/tests/test_runtime.py b/tests/test_runtime.py new file mode 100644 index 000000000..71ac2dd36 --- /dev/null +++ b/tests/test_runtime.py @@ -0,0 +1,169 @@ +"""What the package does to the process it runs in, as a library and as an app.""" + +import asyncio +import gc +import json +import os +import subprocess +import sys +import tomllib +import warnings +from pathlib import Path +from unittest import mock + +import pytest + +from birdnet_analyzer import logs, utils + +PYPROJECT = Path(__file__).resolve().parents[1] / "pyproject.toml" +APP_ENVIRONMENT = {"TF_CPP_MIN_LOG_LEVEL": "3", "WRAPT_DISABLE_EXTENSIONS": "true"} +USER_ENVIRONMENT = {"TF_CPP_MIN_LOG_LEVEL": "1", "WRAPT_DISABLE_EXTENSIONS": "1"} + +FILTERS_PROBE = """ +import json +import sys +import warnings + +installed = [] + + +def spy(install): + def recording(*args, **kwargs): + caller = sys._getframe(1).f_globals.get("__name__", "") + if caller.partition(".")[0] == "birdnet_analyzer": + installed.append(f"{caller}: {install.__name__}{args}") + return install(*args, **kwargs) + + return recording + + +warnings.filterwarnings = spy(warnings.filterwarnings) +warnings.simplefilter = spy(warnings.simplefilter) + +__import__(sys.argv[1]) + +with warnings.catch_warnings(record=True) as caught: + warnings.warn("issued by the host", UserWarning) + +delivered = [str(w.message) for w in caught] +print(json.dumps({"installed": installed, "delivered": delivered})) +""" + +ENVIRONMENT_PROBE = """ +import json +import os + +before = dict(os.environ) + +import birdnet_analyzer + +changed = before.keys() | os.environ.keys() +print(json.dumps(sorted(k for k in changed if before.get(k) != os.environ.get(k)))) +""" + +LOADED_MODULES_PROBE = """ +import json +import sys + +for module in sys.argv[1:]: + __import__(module) + +print(json.dumps([m for m in ("tensorflow", "wrapt") if m in sys.modules])) +""" + + +def _run_probe(probe: str, tmp_path, *args: str): + env = { + **{k: v for k, v in os.environ.items() if k not in APP_ENVIRONMENT}, + "HOME": str(tmp_path), + "USERPROFILE": str(tmp_path), + } + result = subprocess.run( + [sys.executable, "-c", probe, *args], + capture_output=True, + text=True, + env=env, + timeout=110, + check=False, + ) + assert result.returncode == 0, result.stderr + + return json.loads(result.stdout.splitlines()[-1]) + + +@pytest.mark.parametrize("module", ["birdnet_analyzer", "birdnet_analyzer.gui.utils"]) +def test_importing_the_package_leaves_the_warning_filters_alone(tmp_path, module): + if module.startswith("birdnet_analyzer.gui"): + pytest.importorskip("gradio") + + seen = _run_probe(FILTERS_PROBE, tmp_path, module) + + assert seen["installed"] == [] + assert seen["delivered"] == ["issued by the host"] + + +def test_importing_the_package_sets_no_environment_variable_but_the_model_directory( + tmp_path, +): + assert set(_run_probe(ENVIRONMENT_PROBE, tmp_path)) <= {"BIRDNET_APP_DATA"} + + +def test_the_entry_points_load_neither_tensorflow_nor_wrapt_before_they_run(tmp_path): + project = tomllib.loads(PYPROJECT.read_text(encoding="utf-8"))["project"] + targets = [*project["scripts"].values(), *project["gui-scripts"].values()] + modules = [target.partition(":")[0] for target in targets] + assert modules + + assert _run_probe(LOADED_MODULES_PROBE, tmp_path, *modules) == [] + + +@pytest.fixture +def entry_point_runtime(): + """Undoes the environment and logging an entry point configures.""" + with mock.patch.dict(os.environ): + for name in APP_ENVIRONMENT: + os.environ.pop(name, None) + + yield + + logs._remove_installed_handlers() + + +@pytest.mark.parametrize( + ("preset", "expected"), + [({}, APP_ENVIRONMENT), (USER_ENVIRONMENT, USER_ENVIRONMENT)], +) +def test_an_entry_point_configures_tensorflow_unless_the_user_did( + entry_point_runtime, preset, expected +): + os.environ.update(preset) + seen = {} + + @utils.runtime_error_handler + def main(): + seen.update({name: os.environ.get(name) for name in APP_ENVIRONMENT}) + + with warnings.catch_warnings(): + main() + + assert seen == expected + + +def test_an_entry_point_silences_only_the_warnings_users_cannot_act_on( + entry_point_runtime, +): + gr = pytest.importorskip("gradio") + + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + utils.configure_runtime() + + with gr.Blocks(): + gr.List() + gr.Matrix() + + asyncio.new_event_loop() + gc.collect() + warnings.warn("issued by the host", UserWarning, stacklevel=1) + + assert [str(w.message) for w in caught] == ["issued by the host"] From 8113a6da0f49ac2fb8f503d2c629543445bdcd2c Mon Sep 17 00:00:00 2001 From: Josef Haupt Date: Sat, 24 Oct 2026 21:51:54 +0200 Subject: [PATCH 2/2] fix tests --- AGENTS.md | 18 ++++++++++-------- tests/gui/test_main.py | 1 + 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 759315a65..5aded9df1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,14 +56,16 @@ deleting that directory is how you test a first-run experience. ## Conventions that are easy to get wrong -- **The GUI test environment has no pywebview and no plotly** — `gui-tests` installs - gradio, both others are `gui`-only. So no GUI module may import `webview` or - `plotly` at module import time: `gui/utils.py` imports `webview` inside the dialog - functions and `open_window` only, and its `_WINDOW` annotation is a string under - `TYPE_CHECKING`. The tests import `gui.utils` unstubbed on purpose. A test that - builds species-list controls must stub `gu.plot_map_scatter_mapbox` (it draws a - plotly map). A local venv with the `gui` extra hides both problems, so run GUI - tests once with `webview`/`plotly` made unimportable before pushing. +- **The GUI test environment has no pywebview, no plotly and no pywin32** — + `gui-tests` installs gradio only, the others are `gui`-only, and pywin32 is + imported in one place, inside `_get_network_shortcuts`. So no GUI module may + import `webview` or `plotly` at module import time: `gui/utils.py` imports + `webview` inside the dialog functions and `open_window` only, and its `_WINDOW` + annotation is a string under `TYPE_CHECKING`. The tests import `gui.utils` + unstubbed on purpose. A test that builds species-list controls must stub + `gu.plot_map_scatter_mapbox` (it draws a plotly map). A local venv with the `gui` + extra hides these problems, so run GUI tests once with `webview`, `plotly`, + `pythoncom` and `win32com` made unimportable before pushing. - **Localization is all-or-nothing.** `lang/*.json` holds one file per language. A new UI string has to be added to *every* file with a real translation — not the English text copied over. Write them with a json load/dump round-trip diff --git a/tests/gui/test_main.py b/tests/gui/test_main.py index 9f8f35fc2..400e1cef0 100644 --- a/tests/gui/test_main.py +++ b/tests/gui/test_main.py @@ -146,6 +146,7 @@ def launch_instead(*args, **kwargs): raise WindowBuilt monkeypatch.setitem(sys.modules, "webview", SimpleNamespace()) + monkeypatch.setattr(gu, "_get_network_shortcuts", list) monkeypatch.setattr(gr.Blocks, "launch", launch_instead) with warnings.catch_warnings(record=True) as caught: