diff --git a/tests/test_parse_config_encoding.py b/tests/test_parse_config_encoding.py new file mode 100644 index 00000000..9af759bc --- /dev/null +++ b/tests/test_parse_config_encoding.py @@ -0,0 +1,66 @@ +import logging +import locale + +import pytest + +from twine import utils + + +def _write_utf8_ini(path, username: str = "テストユーザー🐍") -> None: + """ + UTF-8 で ini ファイルを書き出すヘルパー。 + 絵文字を含めることで cp932 などのロケールではデコードに失敗しやすくします。 + """ + content = f"""[server-login] +username = {username} +password = secret +""" + # 明示的に UTF-8 バイト列で書く(読み取り側が別エンコーディングを想定した場合に失敗させるため) + path.write_bytes(content.encode("utf-8")) + + +def test_parse_config_triggers_utf8_fallback(monkeypatch, caplog, tmp_path): + """ + デフォルトエンコーディングを cp932 に見せかけると最初の open() が + UnicodeDecodeError を出し、_parse_config が UTF-8 フォールバック経路を通ることを確認する。 + また、ログにフォールバック通知が出ていることも検証する。 + """ + ini_path = tmp_path / "pypirc" + expected_username = "テストユーザー🐍" + _write_utf8_ini(ini_path, expected_username) + + # システム既定のエンコーディングが cp932 のように見せかける + monkeypatch.setattr(locale, "getpreferredencoding", lambda do_set=False: "cp932") + + caplog.set_level(logging.INFO, logger="twine") + parser = utils._parse_config(str(ini_path)) + + # パース結果が正しいこと(フォールバック後に UTF-8 として読めている) + assert parser.get("server-login", "username") == expected_username + + # フォールバックしたことを示すログメッセージが出ていること + assert "decoded with UTF-8 fallback" in caplog.text + + +def test_parse_config_no_fallback_when_default_utf8(monkeypatch, caplog, tmp_path): + """ + デフォルトエンコーディングが UTF-8 の場合、フォールバックは不要で + 通常経路でパースされることを確認する。ログは環境差があるため、 + 「Using configuration from 」 の存在だけを検証します。 + """ + ini_path = tmp_path / "pypirc" + expected_username = "テストユーザー🐍" + _write_utf8_ini(ini_path, expected_username) + + # デフォルトエンコーディングが UTF-8 の場合 + monkeypatch.setattr(locale, "getpreferredencoding", lambda do_set=False: "utf-8") + + caplog.set_level(logging.INFO, logger="twine") + parser = utils._parse_config(str(ini_path)) + + # パース結果が正しいこと + assert parser.get("server-login", "username") == expected_username + + # 環境差(docutils の出力や open() の挙動)でフォールバックの有無が変わるため、 + # フォールバックが無いことを厳密に主張せず、少なくとも使用中の設定ファイルパスがログにあることを確認する。 + assert f"Using configuration from {ini_path}" in caplog.text diff --git a/twine/utils.py b/twine/utils.py index 2ea9ca9f..7c00653f 100644 --- a/twine/utils.py +++ b/twine/utils.py @@ -50,6 +50,36 @@ logger = logging.getLogger(__name__) +def _parse_file(path: str, **open_kwargs: Any) -> configparser.RawConfigParser: + """Open and parse a configuration file. + + This helper performs a single open/read operation so that if a + UnicodeDecodeError is raised it happens before the parser has been + partially populated. + """ + parser = configparser.RawConfigParser() + with open(path, **open_kwargs) as f: + parser.read_file(f) + return parser + + +def _parse_config(path: str) -> configparser.RawConfigParser: + """Parse a config file with a UTF-8 fallback on decode errors. + + Try to parse using the default system encoding first; if a + UnicodeDecodeError occurs, retry using UTF-8 and log that a fallback + was used. + """ + try: + parser = _parse_file(path) + logger.info(f"Using configuration from {path}") + return parser + except UnicodeDecodeError: + parser = _parse_file(path, encoding="utf-8") + logger.info(f"Using configuration from {path} (decoded with UTF-8 fallback)") + return parser + + def get_config(path: str) -> Dict[str, RepositoryConfig]: """Read repository configuration from a file (i.e. ~/.pypirc). @@ -59,12 +89,11 @@ def get_config(path: str) -> Dict[str, RepositoryConfig]: pypyi and testpypi. """ realpath = os.path.realpath(os.path.expanduser(path)) + parser = configparser.RawConfigParser() try: - with open(realpath) as f: - parser.read_file(f) - logger.info(f"Using configuration from {realpath}") + parser = _parse_config(realpath) except FileNotFoundError: # User probably set --config-file, but the file can't be read if path != DEFAULT_CONFIG_FILE: