From a4809d3beba4459627153b15de66dfc4d81de81e Mon Sep 17 00:00:00 2001 From: marcelsafin <179933638+marcelsafin@users.noreply.github.com> Date: Fri, 31 Jul 2026 10:25:45 +0200 Subject: [PATCH] fix(workflows): refetch non-UTF-8 catalog caches Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/specify_cli/workflows/catalog.py | 4 +-- tests/test_workflows.py | 53 ++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py index 1c7354203b..61f490631c 100644 --- a/src/specify_cli/workflows/catalog.py +++ b/src/specify_cli/workflows/catalog.py @@ -514,7 +514,7 @@ def _fetch_single_catalog( cached = json.load(f) if isinstance(cached, dict): return cached - except (json.JSONDecodeError, OSError): + except (UnicodeDecodeError, json.JSONDecodeError, OSError): # Ignore invalid/unreadable cache and fall back to fetching from source. pass @@ -1210,7 +1210,7 @@ def _fetch_single_catalog( cached = json.load(f) if isinstance(cached, dict): return cached - except (json.JSONDecodeError, OSError): + except (UnicodeDecodeError, json.JSONDecodeError, OSError): # Ignore invalid/unreadable cache and fall back to fetching from source. pass diff --git a/tests/test_workflows.py b/tests/test_workflows.py index 49a4619870..d2d7de410a 100644 --- a/tests/test_workflows.py +++ b/tests/test_workflows.py @@ -7246,6 +7246,59 @@ def geturl(self): assert catalog._fetch_single_catalog(entry) == payload + @pytest.mark.parametrize("catalog_type", ["workflow", "step"]) + def test_non_utf8_cached_catalog_is_refetched( + self, project_dir, monkeypatch, catalog_type + ): + import io + + from specify_cli.authentication import http as auth_http + from specify_cli.workflows.catalog import ( + StepCatalog, + StepCatalogEntry, + WorkflowCatalog, + WorkflowCatalogEntry, + ) + + catalog_cls = WorkflowCatalog if catalog_type == "workflow" else StepCatalog + entry_cls = ( + WorkflowCatalogEntry + if catalog_type == "workflow" + else StepCatalogEntry + ) + payload_key = "workflows" if catalog_type == "workflow" else "steps" + url = f"https://example.com/{catalog_type}.json" + catalog = catalog_cls(project_dir) + cache_path, metadata_path = catalog._get_cache_paths(url) + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(b"\xff\xfe") + metadata_path.write_text( + json.dumps({"fetched_at": 4_102_444_800}), + encoding="utf-8", + ) + payload = {"schema_version": "1.0", payload_key: {}} + + class _FakeResponse(io.BytesIO): + def geturl(self): + return url + + monkeypatch.setattr( + auth_http, + "open_url", + lambda url, timeout=30, redirect_validator=None: _FakeResponse( + json.dumps(payload).encode("utf-8") + ), + ) + entry = entry_cls( + url=url, + name="test", + priority=1, + install_allowed=True, + ) + + assert catalog._fetch_single_catalog(entry) == payload + assert json.loads(cache_path.read_text(encoding="utf-8")) == payload + def test_non_mapping_stale_workflow_catalog_is_rejected( self, project_dir, monkeypatch ):