From 116db50bc2d3aba8f471d4da894c07fb1773bc3c Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 30 Aug 2025 20:26:39 +0900 Subject: [PATCH 01/10] handle multiple encodings when reading config files Fixes UnicodeDecodeError by trying utf-8, euc-kr, gbk, cp949, latin1 encodings sequentially until successful decode. --- starlette/config.py | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/starlette/config.py b/starlette/config.py index 091f857f3..f3f331013 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -1,5 +1,6 @@ from __future__ import annotations +import contextlib import os import warnings from collections.abc import Iterator, Mapping, MutableMapping @@ -109,15 +110,18 @@ def get( def _read_file(self, file_name: str | Path) -> dict[str, str]: file_values: dict[str, str] = {} - with open(file_name) as input_file: - for line in input_file.readlines(): - line = line.strip() - if "=" in line and not line.startswith("#"): - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip("\"'") - file_values[key] = value - return file_values + encodings: list[str] = ['utf-8', 'euc-kr', 'gbk', 'cp949', 'latin1'] + for encoding in encodings: + with contextlib.suppress(UnicodeDecodeError): + with open(file_name, encoding=encoding) as input_file: + for line in input_file.readlines(): + line = line.strip() + if "=" in line and not line.startswith("#"): + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + file_values[key] = value + return file_values def _perform_cast( self, From 86a7343b4fa59c710c4254ff5b62d6e3f6af5cb7 Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 30 Aug 2025 20:44:09 +0900 Subject: [PATCH 02/10] Apply ruff code formatting to config --- starlette/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/config.py b/starlette/config.py index f3f331013..7d414f225 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -110,7 +110,7 @@ def get( def _read_file(self, file_name: str | Path) -> dict[str, str]: file_values: dict[str, str] = {} - encodings: list[str] = ['utf-8', 'euc-kr', 'gbk', 'cp949', 'latin1'] + encodings: list[str] = ["utf-8", "euc-kr", "gbk", "cp949", "latin1"] for encoding in encodings: with contextlib.suppress(UnicodeDecodeError): with open(file_name, encoding=encoding) as input_file: From 435a262bfe0528133ff124bdae18e2b513a17866 Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 30 Aug 2025 20:50:33 +0900 Subject: [PATCH 03/10] Fix missing return statement in _read_file method --- starlette/config.py | 1 + 1 file changed, 1 insertion(+) diff --git a/starlette/config.py b/starlette/config.py index 7d414f225..5991b3493 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -122,6 +122,7 @@ def _read_file(self, file_name: str | Path) -> dict[str, str]: value = value.strip().strip("\"'") file_values[key] = value return file_values + return file_values def _perform_cast( self, From a49f8f4080aee939ff0fb301ff0c17582d7f789a Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 30 Aug 2025 13:54:47 +0000 Subject: [PATCH 04/10] Fix missing return statement in _read_file method --- starlette/config.py | 1 - 1 file changed, 1 deletion(-) diff --git a/starlette/config.py b/starlette/config.py index 5991b3493..9185104d8 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -121,7 +121,6 @@ def _read_file(self, file_name: str | Path) -> dict[str, str]: key = key.strip() value = value.strip().strip("\"'") file_values[key] = value - return file_values return file_values def _perform_cast( From 05ab680956dc9e120ad7232cd970ec3431b77b79 Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 30 Aug 2025 13:55:04 +0000 Subject: [PATCH 05/10] Refactor test_config to handle multiple encodings for environment files --- tests/test_config.py | 127 ++++++++++++++++++++++--------------------- 1 file changed, 65 insertions(+), 62 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index c256ffc68..917c06f02 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,68 +38,71 @@ def cast_to_int(v: Any) -> int: def test_config(tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - path = os.path.join(tmpdir, ".env") - with open(path, "w") as file: - file.write("# Do not commit to source control\n") - file.write("DATABASE_URL=postgres://user:pass@localhost/dbname\n") - file.write("REQUEST_HOSTNAME=example.com\n") - file.write("SECRET_KEY=12345\n") - file.write("BOOL_AS_INT=0\n") - file.write("\n") - file.write("\n") - - config = Config(path, environ={"DEBUG": "true"}) - - def cast_to_int(v: Any) -> int: - return int(v) - - DEBUG = config("DEBUG", cast=bool) - DATABASE_URL = config("DATABASE_URL", cast=URL) - REQUEST_TIMEOUT = config("REQUEST_TIMEOUT", cast=int, default=10) - REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") - MAIL_HOSTNAME = config("MAIL_HOSTNAME", default=None) - SECRET_KEY = config("SECRET_KEY", cast=Secret) - UNSET_SECRET = config("UNSET_SECRET", cast=Secret, default=None) - EMPTY_SECRET = config("EMPTY_SECRET", cast=Secret, default="") - assert config("BOOL_AS_INT", cast=bool) is False - assert config("BOOL_AS_INT", cast=cast_to_int) == 0 - assert config("DEFAULTED_BOOL", cast=cast_to_int, default=True) == 1 - - assert DEBUG is True - assert DATABASE_URL.path == "/dbname" - assert DATABASE_URL.password == "pass" - assert DATABASE_URL.username == "user" - assert REQUEST_TIMEOUT == 10 - assert REQUEST_HOSTNAME == "example.com" - assert MAIL_HOSTNAME is None - assert repr(SECRET_KEY) == "Secret('**********')" - assert str(SECRET_KEY) == "12345" - assert bool(SECRET_KEY) - assert not bool(EMPTY_SECRET) - assert not bool(UNSET_SECRET) - - with pytest.raises(KeyError): - config.get("MISSING") - - with pytest.raises(ValueError): - config.get("DEBUG", cast=int) - - with pytest.raises(ValueError): - config.get("REQUEST_HOSTNAME", cast=bool) - - config = Config(Path(path)) - REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") - assert REQUEST_HOSTNAME == "example.com" - - config = Config() - monkeypatch.setenv("STARLETTE_EXAMPLE_TEST", "123") - monkeypatch.setenv("BOOL_AS_INT", "1") - assert config.get("STARLETTE_EXAMPLE_TEST", cast=int) == 123 - assert config.get("BOOL_AS_INT", cast=bool) is True - - monkeypatch.setenv("BOOL_AS_INT", "2") - with pytest.raises(ValueError): - config.get("BOOL_AS_INT", cast=bool) + encodings = ["utf-8", "euc-kr", "gbk", "cp949", "latin1"] + + for encoding in encodings: + path = os.path.join(tmpdir, f".env_{encoding}") + with open(path, "w", encoding=encoding) as file: + file.write("# Do not commit to source control\n") + file.write("DATABASE_URL=postgres://user:pass@localhost/dbname\n") + file.write("REQUEST_HOSTNAME=example.com\n") + file.write("SECRET_KEY=12345\n") + file.write("BOOL_AS_INT=0\n") + file.write("\n") + file.write("\n") + + config = Config(path, environ={"DEBUG": "true"}) + + def cast_to_int(v: Any) -> int: + return int(v) + + DEBUG = config("DEBUG", cast=bool) + DATABASE_URL = config("DATABASE_URL", cast=URL) + REQUEST_TIMEOUT = config("REQUEST_TIMEOUT", cast=int, default=10) + REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") + MAIL_HOSTNAME = config("MAIL_HOSTNAME", default=None) + SECRET_KEY = config("SECRET_KEY", cast=Secret) + UNSET_SECRET = config("UNSET_SECRET", cast=Secret, default=None) + EMPTY_SECRET = config("EMPTY_SECRET", cast=Secret, default="") + assert config("BOOL_AS_INT", cast=bool) is False + assert config("BOOL_AS_INT", cast=cast_to_int) == 0 + assert config("DEFAULTED_BOOL", cast=cast_to_int, default=True) == 1 + + assert DEBUG is True + assert DATABASE_URL.path == "/dbname" + assert DATABASE_URL.password == "pass" + assert DATABASE_URL.username == "user" + assert REQUEST_TIMEOUT == 10 + assert REQUEST_HOSTNAME == "example.com" + assert MAIL_HOSTNAME is None + assert repr(SECRET_KEY) == "Secret('**********')" + assert str(SECRET_KEY) == "12345" + assert bool(SECRET_KEY) + assert not bool(EMPTY_SECRET) + assert not bool(UNSET_SECRET) + + with pytest.raises(KeyError): + config.get("MISSING") + + with pytest.raises(ValueError): + config.get("DEBUG", cast=int) + + with pytest.raises(ValueError): + config.get("REQUEST_HOSTNAME", cast=bool) + + config = Config(Path(path)) + REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") + assert REQUEST_HOSTNAME == "example.com" + + config = Config() + monkeypatch.setenv("STARLETTE_EXAMPLE_TEST", "123") + monkeypatch.setenv("BOOL_AS_INT", "1") + assert config.get("STARLETTE_EXAMPLE_TEST", cast=int) == 123 + assert config.get("BOOL_AS_INT", cast=bool) is True + + monkeypatch.setenv("BOOL_AS_INT", "2") + with pytest.raises(ValueError): + config.get("BOOL_AS_INT", cast=bool) def test_missing_env_file_raises(tmpdir: Path) -> None: From dc716a74978996d958021236f72bbc4aad8fd74e Mon Sep 17 00:00:00 2001 From: HONGJAEHYEONG Date: Thu, 4 Sep 2025 10:04:11 +0900 Subject: [PATCH 06/10] Refactor _read_file method to accept a custom encoding parameter and simplify file reading logic --- starlette/config.py | 22 +++----- tests/test_config.py | 127 +++++++++++++++++++++---------------------- 2 files changed, 71 insertions(+), 78 deletions(-) diff --git a/starlette/config.py b/starlette/config.py index 9185104d8..3f9243bbf 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib import os import warnings from collections.abc import Iterator, Mapping, MutableMapping @@ -108,19 +107,16 @@ def get( return self._perform_cast(key, default, cast) raise KeyError(f"Config '{key}' is missing, and has no default.") - def _read_file(self, file_name: str | Path) -> dict[str, str]: + def _read_file(self, file_name: str | Path, encoding: str = "utf-8") -> dict[str, str]: file_values: dict[str, str] = {} - encodings: list[str] = ["utf-8", "euc-kr", "gbk", "cp949", "latin1"] - for encoding in encodings: - with contextlib.suppress(UnicodeDecodeError): - with open(file_name, encoding=encoding) as input_file: - for line in input_file.readlines(): - line = line.strip() - if "=" in line and not line.startswith("#"): - key, value = line.split("=", 1) - key = key.strip() - value = value.strip().strip("\"'") - file_values[key] = value + with open(file_name, encoding=encoding) as input_file: + for line in input_file.readlines(): + line = line.strip() + if "=" in line and not line.startswith("#"): + key, value = line.split("=", 1) + key = key.strip() + value = value.strip().strip("\"'") + file_values[key] = value return file_values def _perform_cast( diff --git a/tests/test_config.py b/tests/test_config.py index 917c06f02..c256ffc68 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -38,71 +38,68 @@ def cast_to_int(v: Any) -> int: def test_config(tmpdir: Path, monkeypatch: pytest.MonkeyPatch) -> None: - encodings = ["utf-8", "euc-kr", "gbk", "cp949", "latin1"] - - for encoding in encodings: - path = os.path.join(tmpdir, f".env_{encoding}") - with open(path, "w", encoding=encoding) as file: - file.write("# Do not commit to source control\n") - file.write("DATABASE_URL=postgres://user:pass@localhost/dbname\n") - file.write("REQUEST_HOSTNAME=example.com\n") - file.write("SECRET_KEY=12345\n") - file.write("BOOL_AS_INT=0\n") - file.write("\n") - file.write("\n") - - config = Config(path, environ={"DEBUG": "true"}) - - def cast_to_int(v: Any) -> int: - return int(v) - - DEBUG = config("DEBUG", cast=bool) - DATABASE_URL = config("DATABASE_URL", cast=URL) - REQUEST_TIMEOUT = config("REQUEST_TIMEOUT", cast=int, default=10) - REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") - MAIL_HOSTNAME = config("MAIL_HOSTNAME", default=None) - SECRET_KEY = config("SECRET_KEY", cast=Secret) - UNSET_SECRET = config("UNSET_SECRET", cast=Secret, default=None) - EMPTY_SECRET = config("EMPTY_SECRET", cast=Secret, default="") - assert config("BOOL_AS_INT", cast=bool) is False - assert config("BOOL_AS_INT", cast=cast_to_int) == 0 - assert config("DEFAULTED_BOOL", cast=cast_to_int, default=True) == 1 - - assert DEBUG is True - assert DATABASE_URL.path == "/dbname" - assert DATABASE_URL.password == "pass" - assert DATABASE_URL.username == "user" - assert REQUEST_TIMEOUT == 10 - assert REQUEST_HOSTNAME == "example.com" - assert MAIL_HOSTNAME is None - assert repr(SECRET_KEY) == "Secret('**********')" - assert str(SECRET_KEY) == "12345" - assert bool(SECRET_KEY) - assert not bool(EMPTY_SECRET) - assert not bool(UNSET_SECRET) - - with pytest.raises(KeyError): - config.get("MISSING") - - with pytest.raises(ValueError): - config.get("DEBUG", cast=int) - - with pytest.raises(ValueError): - config.get("REQUEST_HOSTNAME", cast=bool) - - config = Config(Path(path)) - REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") - assert REQUEST_HOSTNAME == "example.com" - - config = Config() - monkeypatch.setenv("STARLETTE_EXAMPLE_TEST", "123") - monkeypatch.setenv("BOOL_AS_INT", "1") - assert config.get("STARLETTE_EXAMPLE_TEST", cast=int) == 123 - assert config.get("BOOL_AS_INT", cast=bool) is True - - monkeypatch.setenv("BOOL_AS_INT", "2") - with pytest.raises(ValueError): - config.get("BOOL_AS_INT", cast=bool) + path = os.path.join(tmpdir, ".env") + with open(path, "w") as file: + file.write("# Do not commit to source control\n") + file.write("DATABASE_URL=postgres://user:pass@localhost/dbname\n") + file.write("REQUEST_HOSTNAME=example.com\n") + file.write("SECRET_KEY=12345\n") + file.write("BOOL_AS_INT=0\n") + file.write("\n") + file.write("\n") + + config = Config(path, environ={"DEBUG": "true"}) + + def cast_to_int(v: Any) -> int: + return int(v) + + DEBUG = config("DEBUG", cast=bool) + DATABASE_URL = config("DATABASE_URL", cast=URL) + REQUEST_TIMEOUT = config("REQUEST_TIMEOUT", cast=int, default=10) + REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") + MAIL_HOSTNAME = config("MAIL_HOSTNAME", default=None) + SECRET_KEY = config("SECRET_KEY", cast=Secret) + UNSET_SECRET = config("UNSET_SECRET", cast=Secret, default=None) + EMPTY_SECRET = config("EMPTY_SECRET", cast=Secret, default="") + assert config("BOOL_AS_INT", cast=bool) is False + assert config("BOOL_AS_INT", cast=cast_to_int) == 0 + assert config("DEFAULTED_BOOL", cast=cast_to_int, default=True) == 1 + + assert DEBUG is True + assert DATABASE_URL.path == "/dbname" + assert DATABASE_URL.password == "pass" + assert DATABASE_URL.username == "user" + assert REQUEST_TIMEOUT == 10 + assert REQUEST_HOSTNAME == "example.com" + assert MAIL_HOSTNAME is None + assert repr(SECRET_KEY) == "Secret('**********')" + assert str(SECRET_KEY) == "12345" + assert bool(SECRET_KEY) + assert not bool(EMPTY_SECRET) + assert not bool(UNSET_SECRET) + + with pytest.raises(KeyError): + config.get("MISSING") + + with pytest.raises(ValueError): + config.get("DEBUG", cast=int) + + with pytest.raises(ValueError): + config.get("REQUEST_HOSTNAME", cast=bool) + + config = Config(Path(path)) + REQUEST_HOSTNAME = config("REQUEST_HOSTNAME") + assert REQUEST_HOSTNAME == "example.com" + + config = Config() + monkeypatch.setenv("STARLETTE_EXAMPLE_TEST", "123") + monkeypatch.setenv("BOOL_AS_INT", "1") + assert config.get("STARLETTE_EXAMPLE_TEST", cast=int) == 123 + assert config.get("BOOL_AS_INT", cast=bool) is True + + monkeypatch.setenv("BOOL_AS_INT", "2") + with pytest.raises(ValueError): + config.get("BOOL_AS_INT", cast=bool) def test_missing_env_file_raises(tmpdir: Path) -> None: From 441b99ec49c028232273514ebc3a92964a62f772 Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 6 Sep 2025 22:19:01 +0900 Subject: [PATCH 07/10] Pass encoding parameter to _read_file method in Config class for improved file reading flexibility --- starlette/config.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/starlette/config.py b/starlette/config.py index 3f9243bbf..0df6177b2 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -52,6 +52,7 @@ def __init__( env_file: str | Path | None = None, environ: Mapping[str, str] = environ, env_prefix: str = "", + encoding: str = "utf-8", ) -> None: self.environ = environ self.env_prefix = env_prefix @@ -60,7 +61,7 @@ def __init__( if not os.path.isfile(env_file): warnings.warn(f"Config file '{env_file}' not found.") else: - self.file_values = self._read_file(env_file) + self.file_values = self._read_file(env_file, encoding) @overload def __call__(self, key: str, *, default: None) -> str | None: ... From 5dea9fdf9c2d9a6e5bf6148372c7fb13fa60b363 Mon Sep 17 00:00:00 2001 From: secrett2633 Date: Sat, 6 Sep 2025 22:24:50 +0900 Subject: [PATCH 08/10] Add documentation for custom encoding support in environment files --- docs/config.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/config.md b/docs/config.md index d7faa281a..25bc1de18 100644 --- a/docs/config.md +++ b/docs/config.md @@ -120,6 +120,18 @@ DEBUG = config('DEBUG') # lookups APP_DEBUG, returns "yes" ENVIRONMENT = config('ENVIRONMENT') # lookups APP_ENVIRONMENT, raises KeyError as variable is not defined ``` +## Custom encoding for environment files + +By default, Starlette reads environment files using UTF-8 encoding. +You can specify a different encoding by setting `encoding` argument. + +```python title="myproject/settings.py" +from starlette.config import Config + +# Using custom encoding for .env file +config = Config(".env", encoding="latin-1") +``` + ## A full example Structuring large applications can be complex. You need proper separation of From 1aeb2d86385e0b6ed957f9809b606e07a8c2a6c7 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sat, 20 Sep 2025 04:59:23 -0700 Subject: [PATCH 09/10] Update starlette/config.py --- starlette/config.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/starlette/config.py b/starlette/config.py index 0df6177b2..7f839eebb 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -108,7 +108,7 @@ def get( return self._perform_cast(key, default, cast) raise KeyError(f"Config '{key}' is missing, and has no default.") - def _read_file(self, file_name: str | Path, encoding: str = "utf-8") -> dict[str, str]: + def _read_file(self, file_name: str | Path, encoding: str) -> dict[str, str]: file_values: dict[str, str] = {} with open(file_name, encoding=encoding) as input_file: for line in input_file.readlines(): From 897914dbac54a743ef7a7c0a312e8bb968e31002 Mon Sep 17 00:00:00 2001 From: Marcelo Trylesinski Date: Sun, 26 Oct 2025 17:28:26 +0100 Subject: [PATCH 10/10] Add test --- tests/test_config.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/test_config.py b/tests/test_config.py index c256ffc68..c15b6727e 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -140,3 +140,10 @@ def test_config_with_env_prefix(tmpdir: Path, monkeypatch: pytest.MonkeyPatch) - with pytest.raises(KeyError): config.get("ENVIRONMENT") + + +def test_config_with_encoding(tmpdir: Path) -> None: + path = tmpdir / ".env" + path.write_text("MESSAGE=Hello 世界\n", encoding="utf-8") + config = Config(path, encoding="utf-8") + assert config.get("MESSAGE") == "Hello 世界"