diff --git a/.pylintrc b/.pylintrc index e1b9b88..f3468ac 100644 --- a/.pylintrc +++ b/.pylintrc @@ -11,6 +11,15 @@ ignore-paths=docs/requirements\.txt # C0114/C0115/C0116 — docstring requirements are enforced by review, not lint. # C0415 — ``import-outside-toplevel`` is used deliberately for # lazy imports of heavy / optional modules (see CLAUDE.md). +# R0401 — ``cyclic-import``; the cloud-backend packages depend on +# ``core.action_registry`` for the ``ActionRegistry`` type +# while the registry lazy-imports the backend packages +# inside ``_register_cloud_backends``. The runtime flow is +# sound; pylint's static analysis flags it as a cycle. +# R0801 — ``duplicate-code``; the remote backend client wrappers +# (S3 / Azure / Dropbox / FTP / SFTP) intentionally share +# the "lazy ``later_init`` + ``require_client``" skeleton. +# A shared base class would add indirection without value. # R0903 — too-few-public-methods; dataclasses and frozen option # objects are allowed to have no methods. # W0511 — TODO/FIXME markers; tracked via issues, not lint. @@ -19,6 +28,8 @@ disable= C0115, C0116, C0415, + R0401, + R0801, R0903, W0511 diff --git a/automation_file/core/config.py b/automation_file/core/config.py index c482a71..b9f4961 100644 --- a/automation_file/core/config.py +++ b/automation_file/core/config.py @@ -38,11 +38,6 @@ from pathlib import Path from typing import Any -if sys.version_info >= (3, 11): - import tomllib -else: # pragma: no cover - exercised only on Python 3.10 runners - import tomli as tomllib - from automation_file.core.secrets import ( ChainedSecretProvider, default_provider, @@ -59,6 +54,17 @@ ) +def _load_tomllib() -> Any: + if sys.version_info >= (3, 11): + import tomllib + + return tomllib + # pragma: no cover - exercised only on Python 3.10 runners + import tomli # pylint: disable=import-error # declared in *.toml for Python<3.11 + + return tomli + + class ConfigException(FileAutomationException): """Raised when the config file is missing, unparseable, or malformed.""" @@ -81,6 +87,7 @@ def load( config_path = Path(path) if not config_path.is_file(): raise ConfigException(f"config file not found: {config_path}") + tomllib = _load_tomllib() try: raw = tomllib.loads(config_path.read_text(encoding="utf-8")) except (OSError, tomllib.TOMLDecodeError) as err: diff --git a/automation_file/core/dag_executor.py b/automation_file/core/dag_executor.py index 2f84a55..021f7e4 100644 --- a/automation_file/core/dag_executor.py +++ b/automation_file/core/dag_executor.py @@ -129,6 +129,7 @@ def execute_action_dag( def _run_action(action: list) -> Any: # Use the single-action path so exceptions surface as real exceptions # rather than being swallowed by execute_action's per-action try/except. + # pylint: disable=protected-access # intra-package call to the documented Template Method return default_executor._execute_event(action) diff --git a/tests/test_audit.py b/tests/test_audit.py index ede0676..5babb86 100644 --- a/tests/test_audit.py +++ b/tests/test_audit.py @@ -80,7 +80,7 @@ def test_purge_removes_old_rows(tmp_path: Path) -> None: # Backdate the row so it's older than the cutoff. import sqlite3 - with sqlite3.connect(log._db_path) as conn: + with sqlite3.connect(log._db_path) as conn: # pylint: disable=protected-access conn.execute("UPDATE audit SET ts = ? WHERE action = 'old'", (time.time() - 3600,)) conn.commit() log.record("fresh", {}) diff --git a/tests/test_callback_executor.py b/tests/test_callback_executor.py index 7ce3d69..bce3c04 100644 --- a/tests/test_callback_executor.py +++ b/tests/test_callback_executor.py @@ -16,6 +16,7 @@ def test_callback_runs_after_trigger() -> None: result = executor.callback_function( trigger_function_name="trigger", + # pylint: disable-next=unnecessary-lambda # kwargs-style call; list.append rejects kwargs callback_function=lambda tag: seen.append(tag), callback_function_param={"tag": "done"}, value="hi", diff --git a/tests/test_config.py b/tests/test_config.py index a42f297..ee87e05 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -151,7 +151,7 @@ def test_empty_config_yields_no_sinks(tmp_path: Path) -> None: path = tmp_path / "c.toml" _write_toml(path, "# nothing configured\n") config = AutomationConfig.load(path) - assert config.notification_sinks() == [] + assert not config.notification_sinks() def test_file_secret_provider_resolved_from_config(tmp_path: Path) -> None: diff --git a/tests/test_cross_backend.py b/tests/test_cross_backend.py index 4188afe..156fc9f 100644 --- a/tests/test_cross_backend.py +++ b/tests/test_cross_backend.py @@ -4,6 +4,8 @@ local-to-local round trips, and error paths. """ +# pylint: disable=protected-access # tests deliberately probe _split / _split_bucket + from __future__ import annotations from pathlib import Path diff --git a/tests/test_dir_ops.py b/tests/test_dir_ops.py index dd4beb7..bc81d97 100644 --- a/tests/test_dir_ops.py +++ b/tests/test_dir_ops.py @@ -45,7 +45,7 @@ def test_rename_dir_missing_source(tmp_path: Path) -> None: dir_ops.rename_dir(str(tmp_path / "missing"), str(tmp_path / "out")) -def test_remove_dir_tree(tmp_path: Path, sample_dir: Path) -> None: +def test_remove_dir_tree(sample_dir: Path) -> None: assert dir_ops.remove_dir_tree(str(sample_dir)) is True assert not sample_dir.exists() diff --git a/tests/test_executor_extras.py b/tests/test_executor_extras.py index 549934c..e9e4705 100644 --- a/tests/test_executor_extras.py +++ b/tests/test_executor_extras.py @@ -46,7 +46,7 @@ def test_validate_first_aborts_before_execution() -> None: [["count"], ["count"], ["does_not_exist"]], validate_first=True, ) - assert calls == [] # nothing ran because validation failed first + assert not calls # nothing ran because validation failed first def test_dry_run_does_not_invoke_commands() -> None: @@ -54,7 +54,7 @@ def test_dry_run_does_not_invoke_commands() -> None: calls: list[int] = [] executor.registry.register("count", lambda: calls.append(1) or 1) results = executor.execute_action([["count"], ["count"]], dry_run=True) - assert calls == [] + assert not calls assert all(value.startswith("dry_run:") for value in results.values()) diff --git a/tests/test_fast_find.py b/tests/test_fast_find.py index c700a0c..479525a 100644 --- a/tests/test_fast_find.py +++ b/tests/test_fast_find.py @@ -69,7 +69,7 @@ def test_scandir_find_returns_absolute_paths(tmp_path: Path) -> None: def test_scandir_find_handles_missing_root(tmp_path: Path) -> None: - assert list(scandir_find(tmp_path / "does-not-exist")) == [] + assert not list(scandir_find(tmp_path / "does-not-exist")) def test_fast_find_falls_back_to_scandir_when_index_disabled(tmp_path: Path) -> None: @@ -79,7 +79,7 @@ def test_fast_find_falls_back_to_scandir_when_index_disabled(tmp_path: Path) -> def test_fast_find_returns_empty_for_missing_root(tmp_path: Path) -> None: - assert fast_find(tmp_path / "missing") == [] + assert not fast_find(tmp_path / "missing") def test_fast_find_respects_limit(tmp_path: Path) -> None: @@ -152,6 +152,7 @@ def fake_capture(argv: list[str]) -> list[str]: (tmp_path / "a.log").write_text("a", encoding="utf-8") monkeypatch.setattr(ff, "_capture", fake_capture) + # pylint: disable-next=protected-access # exercises the indexer fallback helper result = ff._run_indexer(indexer, tmp_path, "*.log", True, None) assert result == [str(tmp_path / "a.log")] assert captured["argv"][0] == indexer diff --git a/tests/test_file_ops.py b/tests/test_file_ops.py index dd25d1b..05d47ff 100644 --- a/tests/test_file_ops.py +++ b/tests/test_file_ops.py @@ -53,7 +53,7 @@ def test_copy_all_file_to_dir_missing(tmp_path: Path) -> None: file_ops.copy_all_file_to_dir(str(tmp_path / "missing"), str(tmp_path)) -def test_rename_file_unique_names(tmp_path: Path, sample_dir: Path) -> None: +def test_rename_file_unique_names(sample_dir: Path) -> None: """Regression: original impl renamed every match to the same name, overwriting.""" assert file_ops.rename_file(str(sample_dir), "renamed", file_extension="txt") is True root_names = sorted(p.name for p in sample_dir.iterdir() if p.is_file()) diff --git a/tests/test_fim.py b/tests/test_fim.py index 87ffd18..6b97540 100644 --- a/tests/test_fim.py +++ b/tests/test_fim.py @@ -80,7 +80,7 @@ def test_extras_ignored_by_default(tmp_path: Path) -> None: summary = monitor.check_once() assert "new.txt" in summary["extra"] assert summary["ok"] is True - assert recorder.messages == [] + assert not recorder.messages def test_alert_on_extra_flag(tmp_path: Path) -> None: diff --git a/tests/test_ftp_ops.py b/tests/test_ftp_ops.py index ce48129..15658b2 100644 --- a/tests/test_ftp_ops.py +++ b/tests/test_ftp_ops.py @@ -49,8 +49,8 @@ def nlst(self, path: str) -> list[str]: return self.listings.get(path, []) -@pytest.fixture -def fake_ftp(monkeypatch: pytest.MonkeyPatch) -> _FakeFTP: +@pytest.fixture(name="fake_ftp") +def _fake_ftp(monkeypatch: pytest.MonkeyPatch) -> _FakeFTP: fake = _FakeFTP() monkeypatch.setattr(ftp_instance, "_ftp", fake, raising=False) return fake @@ -94,6 +94,7 @@ def test_upload_file_missing_source_raises(fake_ftp: _FakeFTP, tmp_path: Path) - missing = tmp_path / "nope.txt" with pytest.raises(FileNotExistsException): upload_ops.ftp_upload_file(str(missing), "remote/nope.txt") + assert not fake_ftp.stored def test_upload_file_stores_payload(fake_ftp: _FakeFTP, tmp_path: Path) -> None: @@ -110,12 +111,17 @@ def test_upload_dir_uploads_all_files(fake_ftp: _FakeFTP, tmp_path: Path) -> Non (tmp_path / "sub" / "b.txt").write_bytes(b"B") uploaded = upload_ops.ftp_upload_dir(str(tmp_path), "root") assert sorted(uploaded) == ["root/a.txt", "root/sub/b.txt"] + assert {cmd for cmd, _ in fake_ftp.stored} == { + "STOR root/a.txt", + "STOR root/sub/b.txt", + } def test_download_file_writes_target(fake_ftp: _FakeFTP, tmp_path: Path) -> None: target = tmp_path / "out" / "file.bin" assert download_ops.ftp_download_file("remote/file.bin", str(target)) is True assert target.read_bytes() == b"payload" + assert fake_ftp.retrieved == ["RETR remote/file.bin"] def test_delete_path_calls_delete(fake_ftp: _FakeFTP) -> None: @@ -124,7 +130,7 @@ def test_delete_path_calls_delete(fake_ftp: _FakeFTP) -> None: def test_list_dir_returns_names(fake_ftp: _FakeFTP) -> None: - assert list_ops.ftp_list_dir(".") == ["a.txt", "b.txt"] + assert list_ops.ftp_list_dir(".") == fake_ftp.listings["."] def test_close_on_fresh_client_is_noop() -> None: diff --git a/tests/test_grep.py b/tests/test_grep.py index 94442da..bb131da 100644 --- a/tests/test_grep.py +++ b/tests/test_grep.py @@ -7,8 +7,8 @@ from automation_file import GrepException, build_default_registry, grep_files, iter_grep -@pytest.fixture -def sample_tree(tmp_path: Path) -> Path: +@pytest.fixture(name="sample_tree") +def _sample_tree(tmp_path: Path) -> Path: (tmp_path / "a.txt").write_text("hello world\nfoo bar\nHELLO AGAIN\n", encoding="utf-8") (tmp_path / "b.log").write_text("nothing to see here\n", encoding="utf-8") sub = tmp_path / "sub" @@ -46,7 +46,7 @@ def test_grep_invalid_regex_raises(sample_tree: Path) -> None: def test_grep_glob_filter(sample_tree: Path) -> None: hits = grep_files(str(sample_tree), "hello", glob="*.log") - assert hits == [] + assert not hits def test_grep_empty_pattern_raises(sample_tree: Path) -> None: diff --git a/tests/test_http_download_resume.py b/tests/test_http_download_resume.py index 350c55e..2774844 100644 --- a/tests/test_http_download_resume.py +++ b/tests/test_http_download_resume.py @@ -21,11 +21,12 @@ def __init__(self, chunks: list[bytes]) -> None: def raise_for_status(self) -> None: ... def iter_content(self, chunk_size: int) -> list[bytes]: + del chunk_size # matches requests.Response API; value not needed in the fake return list(self._chunks) -@pytest.fixture -def patch_validator(monkeypatch: pytest.MonkeyPatch) -> None: +@pytest.fixture(name="patch_validator") +def _patch_validator(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(http_download, "validate_http_url", lambda _url: None, raising=True) @@ -42,8 +43,9 @@ def fake_get(url: str, **kwargs: Any) -> _FakeResponse: monkeypatch.setattr(requests, "get", fake_get, raising=True) +@pytest.mark.usefixtures("patch_validator") def test_download_resume_sends_range_header_and_appends( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: target = tmp_path / "big.bin" part = tmp_path / "big.bin.part" @@ -60,8 +62,9 @@ def test_download_resume_sends_range_header_and_appends( assert captured["headers"] == {"Range": "bytes=8-"} +@pytest.mark.usefixtures("patch_validator") def test_download_without_resume_skips_part_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: target = tmp_path / "big.bin" captured: dict[str, Any] = {} @@ -75,8 +78,9 @@ def test_download_without_resume_skips_part_file( assert captured["headers"] is None +@pytest.mark.usefixtures("patch_validator") def test_download_fresh_resume_without_existing_part( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: target = tmp_path / "big.bin" captured: dict[str, Any] = {} @@ -89,9 +93,8 @@ def test_download_fresh_resume_without_existing_part( assert captured["headers"] is None # no Range since nothing pre-downloaded -def test_download_verifies_sha256_match( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None -) -> None: +@pytest.mark.usefixtures("patch_validator") +def test_download_verifies_sha256_match(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: payload = b"verified" target = tmp_path / "v.bin" captured: dict[str, Any] = {} @@ -106,8 +109,9 @@ def test_download_verifies_sha256_match( assert target.exists() +@pytest.mark.usefixtures("patch_validator") def test_download_checksum_mismatch_removes_file( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch, patch_validator: None + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: target = tmp_path / "v.bin" captured: dict[str, Any] = {} diff --git a/tests/test_http_server.py b/tests/test_http_server.py index 4bef7fa..ab759e2 100644 --- a/tests/test_http_server.py +++ b/tests/test_http_server.py @@ -24,7 +24,8 @@ def _post(url: str, payload: object, headers: dict[str, str] | None = None) -> t data = json.dumps(payload).encode("utf-8") request = urllib.request.Request(url, data=data, headers=headers or {}, method="POST") try: - with urllib.request.urlopen(request, timeout=3) as resp: # nosec B310 - URL built from a loopback test server address + # B310 suppressed: URL built from a loopback test server address. + with urllib.request.urlopen(request, timeout=3) as resp: # nosec B310 return resp.status, resp.read().decode("utf-8") except urllib.error.HTTPError as error: return error.code, error.read().decode("utf-8") diff --git a/tests/test_json_edit.py b/tests/test_json_edit.py index bd1caf6..7f65947 100644 --- a/tests/test_json_edit.py +++ b/tests/test_json_edit.py @@ -14,8 +14,8 @@ ) -@pytest.fixture -def sample_json(tmp_path: Path) -> Path: +@pytest.fixture(name="sample_json") +def _sample_json(tmp_path: Path) -> Path: path = tmp_path / "config.json" path.write_text( json.dumps( diff --git a/tests/test_manifest.py b/tests/test_manifest.py index ee5dd1f..c81af5e 100644 --- a/tests/test_manifest.py +++ b/tests/test_manifest.py @@ -35,8 +35,8 @@ def test_write_and_verify_round_trip(tmp_path: Path) -> None: result = verify_manifest(root, manifest_path) assert result["ok"] is True assert set(result["matched"]) == {"a.txt", "nested/b.txt"} - assert result["missing"] == [] - assert result["modified"] == [] + assert not result["missing"] + assert not result["modified"] def test_verify_detects_modified_file(tmp_path: Path) -> None: diff --git a/tests/test_metrics.py b/tests/test_metrics.py index fcb1b6d..a332c31 100644 --- a/tests/test_metrics.py +++ b/tests/test_metrics.py @@ -1,5 +1,7 @@ """Tests for the Prometheus metrics exporter.""" +# pylint: disable=protected-access # prometheus_client exposes counter state via ._value + from __future__ import annotations import urllib.request @@ -82,8 +84,12 @@ def test_metrics_server_returns_404_for_other_paths() -> None: host, port = server.server_address try: url = insecure_url("http", f"{host}:{port}/other") - with pytest.raises(urllib.error.HTTPError) as info: - urllib.request.urlopen(url, timeout=3) # nosec B310 - loopback test server + with ( + pytest.raises(urllib.error.HTTPError) as info, + # nosec B310 - loopback test server + urllib.request.urlopen(url, timeout=3), # nosec B310 + ): + pass assert info.value.code == 404 finally: server.shutdown() diff --git a/tests/test_rotate.py b/tests/test_rotate.py index afe00f1..43b781a 100644 --- a/tests/test_rotate.py +++ b/tests/test_rotate.py @@ -15,8 +15,8 @@ def _make_file(path: Path, days_ago: int) -> None: os.utime(path, (when, when)) -@pytest.fixture -def backup_dir(tmp_path: Path) -> Path: +@pytest.fixture(name="backup_dir") +def _backup_dir(tmp_path: Path) -> Path: # Create 30 daily backups for day in range(30): _make_file(tmp_path / f"backup-{day:02d}.zip", days_ago=day) diff --git a/tests/test_scheduler.py b/tests/test_scheduler.py index cab2b29..8851d1f 100644 --- a/tests/test_scheduler.py +++ b/tests/test_scheduler.py @@ -175,6 +175,7 @@ def test_scheduler_skips_overlap_by_default() -> None: action_list=[["FA_schedule_list"]], ) job.running = True # pretend previous run is still in flight + # pylint: disable-next=protected-access # exercises the overlap-guard path engine._dispatch(job, dt.datetime(2026, 4, 21, 12, 0)) assert job.skipped == 1 assert job.runs == 0 @@ -189,6 +190,7 @@ def test_scheduler_allows_overlap_when_opted_in() -> None: allow_overlap=True, ) job.running = True + # pylint: disable-next=protected-access # exercises the overlap-allow path engine._dispatch(job, dt.datetime(2026, 4, 21, 12, 0)) assert job.runs == 1 assert job.skipped == 0 diff --git a/tests/test_substitution.py b/tests/test_substitution.py index d145f8d..af9237c 100644 --- a/tests/test_substitution.py +++ b/tests/test_substitution.py @@ -5,7 +5,6 @@ import os import re import uuid -from pathlib import Path import pytest @@ -39,7 +38,7 @@ def test_substitute_cwd() -> None: assert substitute("${cwd}") == os.getcwd() -def test_substitute_nested_structures(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: +def test_substitute_nested_structures(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setenv("FA_NESTED", "nested-value") payload = [ ["FA_create_file", {"file_path": "${env:FA_NESTED}"}], diff --git a/tests/test_sync_ops.py b/tests/test_sync_ops.py index 5a4798e..9ea48c2 100644 --- a/tests/test_sync_ops.py +++ b/tests/test_sync_ops.py @@ -26,7 +26,7 @@ def test_sync_copies_new_files(tmp_path: Path) -> None: result = sync_dir(src, dst) assert set(result["copied"]) == {"a.txt", "nested/b.txt"} - assert result["skipped"] == [] + assert not result["skipped"] assert (dst / "a.txt").read_text(encoding="utf-8") == "one" assert (dst / "nested" / "b.txt").read_text(encoding="utf-8") == "two" @@ -39,7 +39,7 @@ def test_sync_skips_unchanged_files(tmp_path: Path) -> None: sync_dir(src, dst) # mtime tolerance means a second pass is a no-op. result = sync_dir(src, dst) - assert result["copied"] == [] + assert not result["copied"] assert result["skipped"] == ["a.txt"] @@ -68,7 +68,7 @@ def test_sync_detects_checksum_change_when_size_matches(tmp_path: Path) -> None: os.utime(src / "a.txt", (stat.st_atime, stat.st_mtime)) size_result = sync_dir(src, dst, compare="size_mtime") - assert size_result["copied"] == [] + assert not size_result["copied"] checksum_result = sync_dir(src, dst, compare="checksum") assert checksum_result["copied"] == ["a.txt"] @@ -111,7 +111,7 @@ def test_sync_delete_disabled_by_default(tmp_path: Path) -> None: _touch(dst / "old.txt", "remove") result = sync_dir(src, dst) - assert result["deleted"] == [] + assert not result["deleted"] assert (dst / "old.txt").exists() @@ -140,7 +140,7 @@ def test_sync_mtime_tolerance_absorbs_small_drift(tmp_path: Path) -> None: stat = (src / "a.txt").stat() os.utime(src / "a.txt", (stat.st_atime, stat.st_mtime + 1.0)) result = sync_dir(src, dst) - assert result["copied"] == [] + assert not result["copied"] def test_sync_fa_action_registered() -> None: diff --git a/tests/test_tar_ops.py b/tests/test_tar_ops.py index c58178b..5392d07 100644 --- a/tests/test_tar_ops.py +++ b/tests/test_tar_ops.py @@ -14,8 +14,8 @@ from automation_file.exceptions import PathTraversalException -@pytest.fixture -def sample_dir(tmp_path: Path) -> Path: +@pytest.fixture(name="sample_dir") +def _sample_dir(tmp_path: Path) -> Path: src = tmp_path / "src" src.mkdir() (src / "a.txt").write_text("hello\n", encoding="utf-8") diff --git a/tests/test_tcp_server.py b/tests/test_tcp_server.py index ca52088..c8075d7 100644 --- a/tests/test_tcp_server.py +++ b/tests/test_tcp_server.py @@ -33,8 +33,8 @@ def _recv_until_marker(sock: socket.socket, timeout: float = 5.0) -> bytes: return bytes(buffer) -@pytest.fixture -def server(): +@pytest.fixture(name="server") +def _server(): port = _free_port() srv = start_autocontrol_socket_server(host=_HOST, port=port) try: diff --git a/tests/test_ui_smoke.py b/tests/test_ui_smoke.py index 4befaf1..c890152 100644 --- a/tests/test_ui_smoke.py +++ b/tests/test_ui_smoke.py @@ -16,8 +16,8 @@ os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") -@pytest.fixture(scope="module") -def qt_app(): +@pytest.fixture(name="qt_app", scope="module") +def _qt_app(): from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) @@ -34,6 +34,7 @@ def test_launch_ui_is_lazy_facade_attr() -> None: def test_main_window_constructs(qt_app) -> None: from automation_file.ui.main_window import MainWindow + assert qt_app is not None window = MainWindow() try: assert window.windowTitle() == "automation_file" @@ -66,6 +67,7 @@ def test_each_tab_constructs(qt_app, tab_name: str) -> None: from automation_file.ui import tabs from automation_file.ui.log_widget import LogPanel + assert qt_app is not None pool = QThreadPool.globalInstance() log = LogPanel() tab_cls = getattr(tabs, tab_name)