From 6af5f2627eccc688b0827137b12df901a9520630 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Thu, 3 Sep 2026 15:53:02 -0400 Subject: [PATCH 1/5] Warn when a restricted Hugging Face download resolves no token download_huggingface_dataset keeps passing token=None through to hf_hub_download when a repo that requires authentication (private, or gated since #538) yields no HUGGING_FACE_TOKEN (per #422: huggingface_hub then applies its own cached token, so `hf auth login` users keep working), but now emits a UserWarning first so that the bare 401 huggingface_hub raises when that fallback is empty too can be traced to the missing or unapproved token (#529). Co-Authored-By: Claude Fable 5.1 --- changelog.d/warn-hf-no-token.changed.md | 1 + policyengine_core/tools/hugging_face.py | 25 ++++ tests/core/tools/test_hugging_face.py | 187 ++++++++++++++++++++++++ 3 files changed, 213 insertions(+) create mode 100644 changelog.d/warn-hf-no-token.changed.md diff --git a/changelog.d/warn-hf-no-token.changed.md b/changelog.d/warn-hf-no-token.changed.md new file mode 100644 index 00000000..9373a1f5 --- /dev/null +++ b/changelog.d/warn-hf-no-token.changed.md @@ -0,0 +1 @@ +Warn when a Hugging Face repo that requires authentication is downloaded with no HUGGING_FACE_TOKEN available, so that a 401 raised after huggingface_hub's own cached-token fallback (HF_TOKEN or the `hf auth login` file) comes up empty is easy to trace back to the missing or unapproved token. diff --git a/policyengine_core/tools/hugging_face.py b/policyengine_core/tools/hugging_face.py index 0f8a114f..d1ea1cbc 100644 --- a/policyengine_core/tools/hugging_face.py +++ b/policyengine_core/tools/hugging_face.py @@ -73,6 +73,13 @@ def download_huggingface_dataset( Returns: Path to the downloaded local file. + + Warns: + UserWarning: If the repo requires authentication but no + HUGGING_FACE_TOKEN was available. The download still runs with + token=None, so huggingface_hub applies its own cached token + (HF_TOKEN or the `hf auth login` file) if it has one; the + warning explains a 401 that follows when it does not. """ # Attempt connection to Hugging Face model_info endpoint # (https://huggingface.co/docs/huggingface_hub/v0.26.5/en/package_reference/hf_api#huggingface_hub.HfApi.model_info) @@ -104,6 +111,24 @@ def download_huggingface_dataset( authentication_token: str | None = None if requires_authentication: authentication_token = get_or_prompt_hf_token() + if authentication_token is None: + # Deliberately not an error: huggingface_hub resolves its own + # cached token when token=None, so `hf auth login` users still + # work. Warn so that the bare 401 huggingface_hub raises when + # that fallback is empty too can be traced back here (#529). + warnings.warn( + f"Hugging Face repo '{repo}' requires authentication, but no " + "HUGGING_FACE_TOKEN was available (the environment variable " + "is unset or empty, and no token was entered at a prompt). " + "huggingface_hub will use its own cached token instead if one " + "exists (the HF_TOKEN environment variable or the " + "`hf auth login` file). A 401 on the download that follows " + "(RepositoryNotFoundError or GatedRepoError) means neither " + "token was set, or the token in use is not approved for this " + "repo. Set HUGGING_FACE_TOKEN to a token whose account has " + "access.", + stacklevel=2, + ) return hf_hub_download( repo_id=repo, diff --git a/tests/core/tools/test_hugging_face.py b/tests/core/tools/test_hugging_face.py index d9905adf..69f4a09c 100644 --- a/tests/core/tools/test_hugging_face.py +++ b/tests/core/tools/test_hugging_face.py @@ -1,4 +1,5 @@ import os +import warnings import pytest from unittest.mock import patch, MagicMock from huggingface_hub import ModelInfo @@ -406,3 +407,189 @@ def test_deep_subdirectory(self): def test_invalid_url_too_short(self): with pytest.raises(ValueError, match="Invalid hf:// URL format"): parse_hf_url("hf://owner/repo") + + +class TestNoTokenWarning: + """download_huggingface_dataset warns when it passes token=None for a + repo that needs authentication. + + Core deliberately does not raise or prompt in that case (#422): with + token=None, huggingface_hub falls back to its own cached token (HF_TOKEN + or the `hf auth login` file) and raises its own 401 if that is missing + too. The warning is what makes that 401 traceable to a missing or + unapproved HUGGING_FACE_TOKEN (#529). + """ + + repo = "test_owner/test_repo" + filename = "test_filename" + version = "test_version" + local_dir = "test_dir" + + def _download(self): + return download_huggingface_dataset( + self.repo, self.filename, self.version, self.local_dir + ) + + def _assert_downloaded_with(self, mock_download, token): + mock_download.assert_called_once_with( + repo_id=self.repo, + repo_type="model", + filename=self.filename, + revision=self.version, + token=token, + local_dir=self.local_dir, + ) + + @staticmethod + def _lookup_response(lookup): + """Configure model_info for the given repo visibility. + + "public": the repo is public, so no token is ever needed. + "private-flag": model_info answers with private=True. + "not-found": model_info raises RepositoryNotFoundError, which core + treats as "probably private". + """ + if lookup == "public": + return {"return_value": ModelInfo(id="test_repo", private=False)} + if lookup == "private-flag": + return {"return_value": ModelInfo(id="test_repo", private=True)} + assert lookup == "not-found" + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.headers = {} + return { + "side_effect": RepositoryNotFoundError("Test error", response=mock_response) + } + + @pytest.mark.parametrize("lookup", ["private-flag", "not-found"]) + @pytest.mark.parametrize( + "environ", + [{}, {"HUGGING_FACE_TOKEN": ""}, {"HF_TOKEN": "hf_cached_token"}], + ids=["token-unset", "token-empty", "hf-token-only"], + ) + def test_warns_when_no_token_resolved_non_interactively(self, lookup, environ): + """No HUGGING_FACE_TOKEN, no TTY: warn, then pass token=None through. + + The hf-token-only case pins that the warning still fires when only + huggingface_hub's own HF_TOKEN is set: core resolved nothing, and + the warning itself says the fallback will be used if present. + """ + model_info_config = self._lookup_response(lookup) + + with patch.dict(os.environ, environ, clear=True): + with patch("os.isatty", return_value=False): + with patch( + "policyengine_core.tools.hugging_face.getpass" + ) as mock_getpass: + mock_getpass.return_value = "prompted_token" + with patch( + "policyengine_core.tools.hugging_face.hf_hub_download" + ) as mock_download: + with patch( + "policyengine_core.tools.hugging_face.model_info", + **model_info_config, + ): + with pytest.warns( + UserWarning, match="no HUGGING_FACE_TOKEN" + ) as record: + result = self._download() + + # Behaviour is unchanged: no prompt, no raise, token=None passed on. + assert result is mock_download.return_value + mock_getpass.assert_not_called() + self._assert_downloaded_with(mock_download, token=None) + + # Exactly one warning, naming the repo, the fallback, and the 401. + assert len(record) == 1 + message = str(record[0].message) + assert self.repo in message + assert "HF_TOKEN" in message + assert "hf auth login" in message + assert "401" in message + # stacklevel=2: the warning points at the caller, not at core. + assert record[0].filename == __file__ + + def test_warns_when_interactive_prompt_left_empty(self): + """TTY present but the user enters nothing: same warning, token=None.""" + with patch.dict(os.environ, {}, clear=True): + with patch("os.isatty", return_value=True): + with patch( + "policyengine_core.tools.hugging_face.getpass", + return_value="", + ) as mock_getpass: + with patch( + "policyengine_core.tools.hugging_face.hf_hub_download" + ) as mock_download: + with patch( + "policyengine_core.tools.hugging_face.model_info", + **self._lookup_response("private-flag"), + ): + with pytest.warns( + UserWarning, match="no HUGGING_FACE_TOKEN" + ): + self._download() + + mock_getpass.assert_called_once() + self._assert_downloaded_with(mock_download, token=None) + + @pytest.mark.parametrize( + ("lookup", "environ", "isatty", "prompted", "expected_token"), + [ + pytest.param("public", {}, False, None, None, id="public-repo"), + pytest.param( + "public", + {"HUGGING_FACE_TOKEN": "env_token"}, + False, + None, + None, + id="public-repo-ignores-env-token", + ), + pytest.param( + "private-flag", + {"HUGGING_FACE_TOKEN": "env_token"}, + False, + None, + "env_token", + id="private-flag-env-token", + ), + pytest.param( + "not-found", + {"HUGGING_FACE_TOKEN": "env_token"}, + False, + None, + "env_token", + id="not-found-env-token", + ), + pytest.param( + "private-flag", + {}, + True, + "prompted_token", + "prompted_token", + id="private-flag-prompted-token", + ), + ], + ) + def test_no_warning_when_a_token_is_passed_or_not_needed( + self, lookup, environ, isatty, prompted, expected_token + ): + """Public repos pass token=None without warning; a resolved token + never warns. Guards against warning on every public download.""" + with patch.dict(os.environ, environ, clear=True): + with patch("os.isatty", return_value=isatty): + with patch( + "policyengine_core.tools.hugging_face.getpass", + return_value=prompted, + ): + with patch( + "policyengine_core.tools.hugging_face.hf_hub_download" + ) as mock_download: + with patch( + "policyengine_core.tools.hugging_face.model_info", + **self._lookup_response(lookup), + ): + with warnings.catch_warnings(): + warnings.simplefilter("error", UserWarning) + self._download() + + self._assert_downloaded_with(mock_download, token=expected_token) From acef2a43b4cf4049f9291b15f5ca16382305c518 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 09:54:01 -0400 Subject: [PATCH 2/5] Cover gated repos in the no-token warning tests With #538, a public but gated repo (private=False, gated="manual", as policyengine/policyengine-uk-data-private is) also requires authentication, so it takes the same path that now warns when no HUGGING_FACE_TOKEN resolves. Add a "gated" lookup to TestNoTokenWarning: the three non-interactive no-token environments must warn, and an environment token must reach hf_hub_download without a warning. With the private-only predicate restored, the four new gated cases fail; with the warning removed, all ten warning-asserting cases fail. Co-Authored-By: Claude Opus 5.5 --- tests/core/tools/test_hugging_face.py | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/tests/core/tools/test_hugging_face.py b/tests/core/tools/test_hugging_face.py index 69f4a09c..3d4c458d 100644 --- a/tests/core/tools/test_hugging_face.py +++ b/tests/core/tools/test_hugging_face.py @@ -446,6 +446,8 @@ def _lookup_response(lookup): "public": the repo is public, so no token is ever needed. "private-flag": model_info answers with private=True. + "gated": model_info answers with private=False, gated="manual", as + policyengine/policyengine-uk-data-private does. "not-found": model_info raises RepositoryNotFoundError, which core treats as "probably private". """ @@ -453,6 +455,10 @@ def _lookup_response(lookup): return {"return_value": ModelInfo(id="test_repo", private=False)} if lookup == "private-flag": return {"return_value": ModelInfo(id="test_repo", private=True)} + if lookup == "gated": + return { + "return_value": ModelInfo(id="test_repo", private=False, gated="manual") + } assert lookup == "not-found" mock_response = MagicMock() mock_response.status_code = 404 @@ -461,7 +467,7 @@ def _lookup_response(lookup): "side_effect": RepositoryNotFoundError("Test error", response=mock_response) } - @pytest.mark.parametrize("lookup", ["private-flag", "not-found"]) + @pytest.mark.parametrize("lookup", ["private-flag", "gated", "not-found"]) @pytest.mark.parametrize( "environ", [{}, {"HUGGING_FACE_TOKEN": ""}, {"HF_TOKEN": "hf_cached_token"}], @@ -552,6 +558,14 @@ def test_warns_when_interactive_prompt_left_empty(self): "env_token", id="private-flag-env-token", ), + pytest.param( + "gated", + {"HUGGING_FACE_TOKEN": "env_token"}, + False, + None, + "env_token", + id="gated-env-token", + ), pytest.param( "not-found", {"HUGGING_FACE_TOKEN": "env_token"}, From 2b977a07ece5a1a397f0ebf91f962e64eaab7e47 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 13:05:09 -0400 Subject: [PATCH 3/5] Tighten the no-token warning text and pin the token contract exhaustively Fold in the non-blocking findings from the independent review of #540: - Say a 401 means no token was sent and a 403 means a gated repo has not approved the token; huggingface_hub raises GatedRepoError for both and documents the unapproved case as 403. - Say the fallback to huggingface_hub's cached token is what normally happens: HF_HUB_DISABLE_IMPLICIT_TOKEN turns it off. - Name `huggingface-cli login` as well as `hf auth login`: the `hf` command first ships in huggingface_hub 0.34, and pyproject still allows 0.25.1. - Assert the warning in the two older no-token tests that now emit it, and give the ungated never-prompts test's getpass mock a string return value so a widened predicate fails on the assertion, not on os.environ. - Reword the changelog fragment: after #538 the repo may be private or gated, and a cached token that is not approved gives a 403. Add TestTokenRoutingInvariants, which runs every combination of repo state (ungated, gated None, fields missing, gated auto, gated manual, private, not found), environment (token unset, empty, only HF_TOKEN, set), TTY and prompt entry (112 cases) through the real function and checks it against a spec written out in the test: the token passed on, whether getpass is called, that the token is never "", and that exactly one warning fires when a repo requiring authentication ends up with no token. Co-Authored-By: Claude Opus 5.5 --- changelog.d/warn-hf-no-token.changed.md | 2 +- policyengine_core/tools/hugging_face.py | 21 ++-- tests/core/tools/test_hugging_face.py | 136 ++++++++++++++++++++++-- 3 files changed, 143 insertions(+), 16 deletions(-) diff --git a/changelog.d/warn-hf-no-token.changed.md b/changelog.d/warn-hf-no-token.changed.md index 9373a1f5..bc20e829 100644 --- a/changelog.d/warn-hf-no-token.changed.md +++ b/changelog.d/warn-hf-no-token.changed.md @@ -1 +1 @@ -Warn when a Hugging Face repo that requires authentication is downloaded with no HUGGING_FACE_TOKEN available, so that a 401 raised after huggingface_hub's own cached-token fallback (HF_TOKEN or the `hf auth login` file) comes up empty is easy to trace back to the missing or unapproved token. +Warn when a Hugging Face repo that requires authentication (private or gated) is downloaded with no HUGGING_FACE_TOKEN available, so that a 401 from a missing token, or a 403 from a cached token that is not approved for a gated repo, is easy to trace. diff --git a/policyengine_core/tools/hugging_face.py b/policyengine_core/tools/hugging_face.py index d1ea1cbc..3075b504 100644 --- a/policyengine_core/tools/hugging_face.py +++ b/policyengine_core/tools/hugging_face.py @@ -78,8 +78,10 @@ def download_huggingface_dataset( UserWarning: If the repo requires authentication but no HUGGING_FACE_TOKEN was available. The download still runs with token=None, so huggingface_hub applies its own cached token - (HF_TOKEN or the `hf auth login` file) if it has one; the - warning explains a 401 that follows when it does not. + (HF_TOKEN or the `hf auth login` file) if it has one, unless + HF_HUB_DISABLE_IMPLICIT_TOKEN is set; the warning explains a + 401 that follows when there is no such token, or a 403 when a + gated repo has not approved it. """ # Attempt connection to Hugging Face model_info endpoint # (https://huggingface.co/docs/huggingface_hub/v0.26.5/en/package_reference/hf_api#huggingface_hub.HfApi.model_info) @@ -120,13 +122,14 @@ def download_huggingface_dataset( f"Hugging Face repo '{repo}' requires authentication, but no " "HUGGING_FACE_TOKEN was available (the environment variable " "is unset or empty, and no token was entered at a prompt). " - "huggingface_hub will use its own cached token instead if one " - "exists (the HF_TOKEN environment variable or the " - "`hf auth login` file). A 401 on the download that follows " - "(RepositoryNotFoundError or GatedRepoError) means neither " - "token was set, or the token in use is not approved for this " - "repo. Set HUGGING_FACE_TOKEN to a token whose account has " - "access.", + "huggingface_hub normally falls back to its own cached token " + "if it has one (the HF_TOKEN environment variable, or the file " + "written by `hf auth login`, which is `huggingface-cli login` " + "before huggingface_hub 0.34). If the download that follows " + "fails with RepositoryNotFoundError or GatedRepoError (a 401 " + "when no token was sent, or a 403 when a gated repo has not " + "approved the token), set HUGGING_FACE_TOKEN to a token whose " + "account has access.", stacklevel=2, ) diff --git a/tests/core/tools/test_hugging_face.py b/tests/core/tools/test_hugging_face.py index 3d4c458d..a1edb761 100644 --- a/tests/core/tools/test_hugging_face.py +++ b/tests/core/tools/test_hugging_face.py @@ -1,3 +1,4 @@ +import itertools import os import warnings import pytest @@ -127,9 +128,12 @@ def test_download_private_repo_no_token(self, environ): "Test error", response=mock_response ) - result = download_huggingface_dataset( - test_repo, test_filename, test_version, test_dir - ) + with pytest.warns( + UserWarning, match="no HUGGING_FACE_TOKEN" + ): + result = download_huggingface_dataset( + test_repo, test_filename, test_version, test_dir + ) assert result is mock_download.return_value mock_getpass.assert_not_called() @@ -234,9 +238,12 @@ def test_download_gated_repo_non_interactive_without_token(self): id=test_repo, private=False, gated="manual" ) - download_huggingface_dataset( - test_repo, test_filename, test_version, test_dir - ) + with pytest.warns( + UserWarning, match="no HUGGING_FACE_TOKEN" + ): + download_huggingface_dataset( + test_repo, test_filename, test_version, test_dir + ) mock_getpass.assert_not_called() mock_download.assert_called_once_with( @@ -265,6 +272,10 @@ def test_download_public_ungated_repo_never_prompts(self, gated): with patch( "policyengine_core.tools.hugging_face.getpass" ) as mock_getpass: + # A string, so that a widened predicate fails on + # assert_not_called() below rather than on storing a + # MagicMock in os.environ. + mock_getpass.return_value = "prompted_token" with patch( "policyengine_core.tools.hugging_face.hf_hub_download" ) as mock_download: @@ -607,3 +618,116 @@ def test_no_warning_when_a_token_is_passed_or_not_needed( self._download() self._assert_downloaded_with(mock_download, token=expected_token) + + +class TestTokenRoutingInvariants: + """Exhaustive check of download_huggingface_dataset's token contract. + + Every combination of repo state, environment, TTY and prompt entry is run + through the real function (with model_info, hf_hub_download and getpass + mocked) and compared with the spec written out in _expected(): + + - Only private, gated or not-found repos require authentication. + - Such a repo gets HUGGING_FACE_TOKEN if it is non-empty; otherwise a + prompt on a TTY; otherwise None. A public, ungated repo always gets + None and never prompts. + - The token passed on is None or a non-empty string, never "". + - Exactly one no-token warning fires when a repo requiring + authentication ends up with None, and none fires otherwise. + """ + + repo = "test_owner/test_repo" + filename = "test_filename" + + REPO_STATES = { + "ungated": dict(private=False, gated=False), + "gated-none": dict(private=False, gated=None), + "fields-missing": dict(), + "gated-auto": dict(private=False, gated="auto"), + "gated-manual": dict(private=False, gated="manual"), + "private": dict(private=True, gated=False), + "not-found": None, + } + ENVIRONS = { + "token-unset": {}, + "token-empty": {"HUGGING_FACE_TOKEN": ""}, + "hf-token-only": {"HF_TOKEN": "hf_cached_token"}, + "token-set": {"HUGGING_FACE_TOKEN": "env_token"}, + } + # itertools.product, not nested fors: only a comprehension's first + # iterable can see class attributes. + CASES = [ + pytest.param(state, environ, isatty, entry, id=f"{state}-{environ}-{tty}-{e}") + for state, environ, (isatty, tty), (entry, e) in itertools.product( + REPO_STATES, + ENVIRONS, + [(False, "no-tty"), (True, "tty")], + [("", "empty-entry"), ("prompted_token", "entry")], + ) + ] + + @staticmethod + def _expected(state, environ, isatty, entry): + requires_authentication = state in ( + "gated-auto", + "gated-manual", + "private", + "not-found", + ) + if not requires_authentication: + return None, False, 0 + env_token = environ.get("HUGGING_FACE_TOKEN") or None + if env_token is not None: + return env_token, False, 0 + if isatty: + token = entry or None + return token, True, int(token is None) + return None, False, 1 + + @pytest.mark.parametrize(("state", "environ", "isatty", "entry"), CASES) + def test_token_routing_matches_spec(self, state, environ, isatty, entry): + fields = self.REPO_STATES[state] + if fields is None: + mock_response = MagicMock() + mock_response.status_code = 404 + mock_response.headers = {} + model_info_config = { + "side_effect": RepositoryNotFoundError( + "Test error", response=mock_response + ) + } + else: + model_info_config = {"return_value": ModelInfo(id="test_repo", **fields)} + env = self.ENVIRONS[environ] + + with patch.dict(os.environ, env, clear=True): + with patch("os.isatty", return_value=isatty): + with patch( + "policyengine_core.tools.hugging_face.getpass", + return_value=entry, + ) as mock_getpass: + with patch( + "policyengine_core.tools.hugging_face.hf_hub_download" + ) as mock_download: + with patch( + "policyengine_core.tools.hugging_face.model_info", + **model_info_config, + ): + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + result = download_huggingface_dataset( + self.repo, self.filename + ) + + expected_token, expected_prompt, expected_warnings = self._expected( + state, env, isatty, entry + ) + assert result is mock_download.return_value + mock_download.assert_called_once() + token = mock_download.call_args.kwargs["token"] + assert token == expected_token + assert token is None or (isinstance(token, str) and token != "") + assert mock_getpass.called == expected_prompt + user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] + assert len(user_warnings) == expected_warnings + assert all("no HUGGING_FACE_TOKEN" in str(w.message) for w in user_warnings) From 7b8e76cbd1f8bb1cc1a2d387f67563eb66473dd4 Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 20:01:38 -0400 Subject: [PATCH 4/5] Cover private-and-gated repos and count prompts in the token grid Add a "private-gated" repo state (private=True, gated="manual") to TestTokenRoutingInvariants, so a predicate that is true for private or gated but false for both (for example an xor) now fails 16 cases instead of passing all of them. Assert getpass's call_count rather than whether it was called, so a double prompt fails 30 grid cases, not one side test. The grid grows from 112 to 128 cases. Also give the Warns: docstring the same `huggingface-cli login` caveat the runtime warning already carries. Co-Authored-By: Claude Opus 5.5 --- policyengine_core/tools/hugging_face.py | 9 +++++---- tests/core/tools/test_hugging_face.py | 4 +++- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/policyengine_core/tools/hugging_face.py b/policyengine_core/tools/hugging_face.py index 3075b504..a47615fc 100644 --- a/policyengine_core/tools/hugging_face.py +++ b/policyengine_core/tools/hugging_face.py @@ -78,10 +78,11 @@ def download_huggingface_dataset( UserWarning: If the repo requires authentication but no HUGGING_FACE_TOKEN was available. The download still runs with token=None, so huggingface_hub applies its own cached token - (HF_TOKEN or the `hf auth login` file) if it has one, unless - HF_HUB_DISABLE_IMPLICIT_TOKEN is set; the warning explains a - 401 that follows when there is no such token, or a 403 when a - gated repo has not approved it. + (HF_TOKEN, or the file written by `hf auth login`, which is + `huggingface-cli login` before huggingface_hub 0.34) if it has + one, unless HF_HUB_DISABLE_IMPLICIT_TOKEN is set; the warning + explains a 401 that follows when there is no such token, or a + 403 when a gated repo has not approved it. """ # Attempt connection to Hugging Face model_info endpoint # (https://huggingface.co/docs/huggingface_hub/v0.26.5/en/package_reference/hf_api#huggingface_hub.HfApi.model_info) diff --git a/tests/core/tools/test_hugging_face.py b/tests/core/tools/test_hugging_face.py index a1edb761..3c9cac8b 100644 --- a/tests/core/tools/test_hugging_face.py +++ b/tests/core/tools/test_hugging_face.py @@ -646,6 +646,7 @@ class TestTokenRoutingInvariants: "gated-auto": dict(private=False, gated="auto"), "gated-manual": dict(private=False, gated="manual"), "private": dict(private=True, gated=False), + "private-gated": dict(private=True, gated="manual"), "not-found": None, } ENVIRONS = { @@ -672,6 +673,7 @@ def _expected(state, environ, isatty, entry): "gated-auto", "gated-manual", "private", + "private-gated", "not-found", ) if not requires_authentication: @@ -727,7 +729,7 @@ def test_token_routing_matches_spec(self, state, environ, isatty, entry): token = mock_download.call_args.kwargs["token"] assert token == expected_token assert token is None or (isinstance(token, str) and token != "") - assert mock_getpass.called == expected_prompt + assert mock_getpass.call_count == int(expected_prompt) user_warnings = [w for w in caught if issubclass(w.category, UserWarning)] assert len(user_warnings) == expected_warnings assert all("no HUGGING_FACE_TOKEN" in str(w.message) for w in user_warnings) From 1de9bc61e7ff64282670c2984f16d47505c1029a Mon Sep 17 00:00:00 2001 From: Max Ghenis Date: Tue, 29 Sep 2026 21:16:46 -0400 Subject: [PATCH 5/5] Pin the 403, GatedRepoError and huggingface-cli parts of the warning text The non-interactive warning test now also asserts that the message names RepositoryNotFoundError, GatedRepoError, the 403 case and the pre-0.34 `huggingface-cli login` command. Each of those clauses could previously be deleted without failing a test; each deletion now fails 9 of 167. The TestNoTokenWarning docstring no longer calls the 401 "missing or unapproved": with an empty fallback there is no token to be unapproved. Co-Authored-By: Claude Opus 5.5 --- tests/core/tools/test_hugging_face.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/tests/core/tools/test_hugging_face.py b/tests/core/tools/test_hugging_face.py index 3c9cac8b..9c127d3d 100644 --- a/tests/core/tools/test_hugging_face.py +++ b/tests/core/tools/test_hugging_face.py @@ -425,10 +425,10 @@ class TestNoTokenWarning: repo that needs authentication. Core deliberately does not raise or prompt in that case (#422): with - token=None, huggingface_hub falls back to its own cached token (HF_TOKEN - or the `hf auth login` file) and raises its own 401 if that is missing - too. The warning is what makes that 401 traceable to a missing or - unapproved HUGGING_FACE_TOKEN (#529). + token=None, huggingface_hub falls back to its own cached token (for + example HF_TOKEN or the `hf auth login` file) and raises its own 401 if + that is missing too. The warning is what makes that 401 traceable to a + missing HUGGING_FACE_TOKEN (#529). """ repo = "test_owner/test_repo" @@ -516,13 +516,19 @@ def test_warns_when_no_token_resolved_non_interactively(self, lookup, environ): mock_getpass.assert_not_called() self._assert_downloaded_with(mock_download, token=None) - # Exactly one warning, naming the repo, the fallback, and the 401. + # Exactly one warning, naming the repo, the fallback (including the + # pre-0.34 login command), and both errors that can follow with + # their 401 and 403 causes. assert len(record) == 1 message = str(record[0].message) assert self.repo in message assert "HF_TOKEN" in message assert "hf auth login" in message + assert "huggingface-cli login" in message + assert "RepositoryNotFoundError" in message + assert "GatedRepoError" in message assert "401" in message + assert "403" in message # stacklevel=2: the warning points at the caller, not at core. assert record[0].filename == __file__