Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions tests/test_parse_config_encoding.py
Original file line number Diff line number Diff line change
@@ -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 <path>」 の存在だけを検証します。
"""
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
35 changes: 32 additions & 3 deletions twine/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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:
Expand Down