From aac371f89694cb15b549eef9dad55467b9f65c80 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 1 May 2026 13:02:54 +0800 Subject: [PATCH 1/5] feat: support pathlib.Path as argument of `TortoiseConfig.from_config_file` --- tortoise/config.py | 57 +++++++++++++++++++++++----------------------- 1 file changed, 29 insertions(+), 28 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index f7527093b..ed9b9ac52 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -1,10 +1,10 @@ from __future__ import annotations import json -import os from collections.abc import Mapping from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import TYPE_CHECKING, Any, TypeGuard from tortoise.backends.base.config_generator import generate_config from tortoise.exceptions import ConfigurationError @@ -20,6 +20,10 @@ from typing_extensions import Self +def is_string_list(val: list) -> TypeGuard[list[str]]: + return isinstance(val, list) and all(isinstance(x, str) for x in val) + + @dataclass(frozen=True) class DBUrlConfig: url: str @@ -101,12 +105,14 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Mapping[str, Any]) -> Self: if not isinstance(data, Mapping): raise ConfigurationError("AppConfig must be created from a mapping") - if "models" not in data: - raise ConfigurationError('AppConfig requires "models"') - if not isinstance(data["models"], list): + try: + models = data["models"] + except KeyError as e: + raise ConfigurationError('AppConfig requires "models"') from e + if not is_string_list(models): raise ConfigurationError("AppConfig.models must be a list of strings") return cls( - models=list(data["models"]), + models=models, default_connection=data.get("default_connection"), migrations=data.get("migrations"), ) @@ -217,12 +223,12 @@ def from_dict(cls, data: Mapping[str, Any]) -> Self: ) @classmethod - def from_config_file(cls, config_file: str) -> Self: + def from_config_file(cls, config_file: str | Path) -> Self: """ Load configuration from a YAML or JSON file. Args: - config_file (str): Path to the configuration file. Supported extensions: .yml, .yaml, .json. + config_file: Path to the configuration file. Supported extensions: .yml, .yaml, .json. Returns: Self: The constructed TortoiseConfig. @@ -230,19 +236,19 @@ def from_config_file(cls, config_file: str) -> Self: Raises: ConfigurationError: If the file is missing, unsupported, or contents are invalid. """ - _, extension = os.path.splitext(config_file) - if extension in (".yml", ".yaml"): - import yaml # pylint: disable=C0415 - - with open(config_file) as f: - config = yaml.safe_load(f) - elif extension == ".json": - with open(config_file) as f: - config = json.load(f) - else: - raise ConfigurationError( - f"Unknown config extension {extension}, only .yml and .json are supported" - ) + config_path = Path(config_file) + match config_path.suffix: + case ".yml" | ".yaml": + import yaml # pylint: disable=C0415 + + with open(config_file) as f: + config = yaml.safe_load(f) + case ".json": + config = json.loads(config_path.read_bytes()) + case _ as extension: + raise ConfigurationError( + f"Unknown config extension {extension}, only .yml and .json are supported" + ) return cls.from_dict(config) @classmethod @@ -274,7 +280,7 @@ def from_db_url_and_modules( def resolve_args( cls, config: dict[str, Any] | Self | None = None, - config_file: str | None = None, + config_file: Path | str | None = None, db_url: str | None = None, modules: dict[str, Iterable[str | ModuleType]] | None = None, ) -> Self: @@ -286,14 +292,9 @@ def resolve_args( - `config_file` path, - or both `db_url` and `modules`. - Args: - config (dict[str, Any] | TortoiseConfig | None): - config_file (str | None): Path to a config YAML or JSON file. - db_url (str | None): Database URL for config generation. - modules (dict[str, Iterable[str | ModuleType]] | None): App modules for config generation. Args: config: A configuration dict or TortoiseConfig instance. - config_file: Path to config file. + config_file: Path to a config YAML or JSON file. db_url: Database URL for config generation. modules: App modules for config generation. From aa1d4784c70e50f1e577d5c9567217cf4623ff63 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 1 May 2026 23:59:31 +0800 Subject: [PATCH 2/5] Rollback unnecessary changes --- tortoise/config.py | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index ed9b9ac52..c0d334f35 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from dataclasses import dataclass, field from pathlib import Path -from typing import TYPE_CHECKING, Any, TypeGuard +from typing import TYPE_CHECKING, Any from tortoise.backends.base.config_generator import generate_config from tortoise.exceptions import ConfigurationError @@ -20,10 +20,6 @@ from typing_extensions import Self -def is_string_list(val: list) -> TypeGuard[list[str]]: - return isinstance(val, list) and all(isinstance(x, str) for x in val) - - @dataclass(frozen=True) class DBUrlConfig: url: str @@ -105,14 +101,12 @@ def to_dict(self) -> dict[str, Any]: def from_dict(cls, data: Mapping[str, Any]) -> Self: if not isinstance(data, Mapping): raise ConfigurationError("AppConfig must be created from a mapping") - try: - models = data["models"] - except KeyError as e: - raise ConfigurationError('AppConfig requires "models"') from e - if not is_string_list(models): + if "models" not in data: + raise ConfigurationError('AppConfig requires "models"') + if not isinstance(data["models"], list): raise ConfigurationError("AppConfig.models must be a list of strings") return cls( - models=models, + models=list(data["models"]), default_connection=data.get("default_connection"), migrations=data.get("migrations"), ) @@ -223,7 +217,7 @@ def from_dict(cls, data: Mapping[str, Any]) -> Self: ) @classmethod - def from_config_file(cls, config_file: str | Path) -> Self: + def from_config_file(cls, config_file: Path | str) -> Self: """ Load configuration from a YAML or JSON file. From 60755b6a748714292a24abcc8ad58dcbd893dc37 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Sat, 2 May 2026 12:37:03 +0800 Subject: [PATCH 3/5] tests: add tests for TortoiseConfig --- tests/test_config.py | 167 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 167 insertions(+) create mode 100644 tests/test_config.py diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 000000000..26f6ef9de --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,167 @@ +""" +Tests for tortoise.config module - TortoiseConfig class. +""" + +from pathlib import Path + +import orjson +import pytest +import yaml + +from tortoise.backends.base.config_generator import expand_db_url +from tortoise.config import TortoiseConfig +from tortoise.exceptions import ConfigurationError + + +class TestTortoiseConfig: + def test_from_invalid_dict(self): + with pytest.raises( + ConfigurationError, match="TortoiseConfig must be created from a mapping" + ): + TortoiseConfig.from_dict([]) + with pytest.raises(ConfigurationError, match='Config must define "connections" section'): + TortoiseConfig.from_dict({}) + with pytest.raises(ConfigurationError, match='Config must define "apps" section'): + TortoiseConfig.from_dict({"connections": ""}) + with pytest.raises(ConfigurationError, match='Config "connections" must be a mapping'): + TortoiseConfig.from_dict({"connections": "", "apps": ""}) + with pytest.raises(ConfigurationError, match="Connection values must be mapping or string"): + TortoiseConfig.from_dict({"connections": {"default": []}, "apps": ""}) + with pytest.raises(ConfigurationError, match="DBUrlConfig.url must be a non-empty string"): + TortoiseConfig.from_dict({"connections": {"default": ""}, "apps": ""}) + with pytest.raises(ConfigurationError, match='Config "apps" must be a mapping'): + TortoiseConfig.from_dict({"connections": {"default": "db.sqlite3"}, "apps": ""}) + with pytest.raises(ConfigurationError, match="App values must be mappings"): + TortoiseConfig.from_dict( + {"connections": {"default": "db.sqlite3"}, "apps": {"auth": ""}} + ) + with pytest.raises(ConfigurationError, match='AppConfig requires "models"'): + TortoiseConfig.from_dict( + {"connections": {"default": "db.sqlite3"}, "apps": {"auth": {}}, "routers": {}} + ) + with pytest.raises( + ConfigurationError, match="AppConfig.models must be a non-empty list of strings" + ): + TortoiseConfig.from_dict( + { + "connections": {"default": "db.sqlite3"}, + "apps": {"auth": {"models": []}}, + "routers": {}, + } + ) + with pytest.raises( + ConfigurationError, match="TortoiseConfig.routers must be a list or None" + ): + TortoiseConfig.from_dict( + { + "connections": {"default": "db.sqlite3"}, + "apps": {"auth": {"models": ["models"]}}, + "routers": "", + } + ) + + def test_from_dict(self): + simple = { + "connections": {"default": "sqlite://db.sqlite3"}, + "apps": {"app": {"models": ["app.models"]}}, + } + assert TortoiseConfig.from_dict(simple) is not None + full = { + "connections": { + "default": "sqlite://db.sqlite3", + "second": "sqlite://db2.sqlite3", + }, + "apps": { + "app1": {"models": ["app1.models"]}, + "app2": { + "models": ["app2.models"], + "default_connection": "second", + }, + }, + "routers": ["path.Router"], + "use_tz": True, + "timezone": "UTC", + } + assert TortoiseConfig.from_dict(full) is not None + + def test_from_config_file(self, tmp_path: Path): + simple = { + "connections": {"default": "sqlite://db.sqlite3"}, + "apps": {"app": {"models": ["app.models"]}}, + } + file = tmp_path / "tortoise_conf.json" + file.write_bytes(orjson.dumps(simple)) + filename: str = file.as_posix() + assert ( + TortoiseConfig.from_config_file(file) + == TortoiseConfig.from_config_file(filename) + == TortoiseConfig.from_dict(simple) + == TortoiseConfig.resolve_args(config_file=file) + ) + + yaml_file = file.with_suffix(".yml") + with yaml_file.open("w") as f: + yaml.safe_dump(dict(simple), f, default_flow_style=False) + yaml_file_2 = file.with_suffix(".yaml") + with yaml_file_2.open("w") as f2: + yaml.safe_dump(simple, f2, default_flow_style=False) + assert ( + TortoiseConfig.from_config_file(yaml_file) + == TortoiseConfig.from_config_file(str(yaml_file)) + == TortoiseConfig.from_config_file(yaml_file_2) + == TortoiseConfig.from_config_file(file) + == TortoiseConfig.resolve_args(config_file=yaml_file) + ) + + def test_from_db_url_and_modules(self): + simple = { + "connections": {"default": "sqlite://db.sqlite3"}, + "apps": { + "app": { + "models": ["app.models"], + "default_connection": "default", + } + }, + } + db_url = simple["connections"]["default"] + modules = {"app": simple["apps"]["app"]["models"]} + typed_config = TortoiseConfig.from_db_url_and_modules(db_url, modules) + assert typed_config == TortoiseConfig.resolve_args(db_url=db_url, modules=modules) + assert typed_config.apps == TortoiseConfig.from_dict(simple).apps + + def test_resolve_args(self, tmp_path: Path): + with pytest.raises( + ConfigurationError, + match="Must provide either 'config', 'config_file', or both 'db_url' and 'modules'", + ): + TortoiseConfig.resolve_args() + with pytest.raises( + ConfigurationError, + match="Must provide either 'config', 'config_file', or both 'db_url' and 'modules'", + ): + TortoiseConfig.resolve_args(db_url="") + with pytest.raises( + ConfigurationError, match="Cannot specify both 'config' and 'config_file'" + ): + TortoiseConfig.resolve_args(config={}, config_file="a.json") + db_url = "sqlite://db.sqlite3" + config = { + "connections": {"default": db_url}, + "apps": { + "app": { + "models": ["app.models"], + "default_connection": "default", + } + }, + } + config_file = tmp_path / "config.json" + config_file.write_bytes(orjson.dumps(config)) + typed_config = TortoiseConfig.resolve_args(config) + assert typed_config == TortoiseConfig.resolve_args(config_file=config_file) + + typed_config_2 = TortoiseConfig.resolve_args(db_url=db_url, modules={"app": ["app.models"]}) + assert typed_config.apps == typed_config_2.apps + assert ( + expand_db_url(str(typed_config.connections["default"].to_config())) + == typed_config_2.connections["default"].to_config() + ) From ab187e8cd8d1488c5ab3bc75d07fb7cb194929b3 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 15 May 2026 01:30:29 +0800 Subject: [PATCH 4/5] tests: use pytest parametrize --- tests/test_config.py | 109 +++++++++++++++++++++++-------------------- 1 file changed, 58 insertions(+), 51 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 26f6ef9de..3b570321f 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -14,51 +14,54 @@ class TestTortoiseConfig: - def test_from_invalid_dict(self): - with pytest.raises( - ConfigurationError, match="TortoiseConfig must be created from a mapping" - ): - TortoiseConfig.from_dict([]) - with pytest.raises(ConfigurationError, match='Config must define "connections" section'): - TortoiseConfig.from_dict({}) - with pytest.raises(ConfigurationError, match='Config must define "apps" section'): - TortoiseConfig.from_dict({"connections": ""}) - with pytest.raises(ConfigurationError, match='Config "connections" must be a mapping'): - TortoiseConfig.from_dict({"connections": "", "apps": ""}) - with pytest.raises(ConfigurationError, match="Connection values must be mapping or string"): - TortoiseConfig.from_dict({"connections": {"default": []}, "apps": ""}) - with pytest.raises(ConfigurationError, match="DBUrlConfig.url must be a non-empty string"): - TortoiseConfig.from_dict({"connections": {"default": ""}, "apps": ""}) - with pytest.raises(ConfigurationError, match='Config "apps" must be a mapping'): - TortoiseConfig.from_dict({"connections": {"default": "db.sqlite3"}, "apps": ""}) - with pytest.raises(ConfigurationError, match="App values must be mappings"): - TortoiseConfig.from_dict( - {"connections": {"default": "db.sqlite3"}, "apps": {"auth": ""}} - ) - with pytest.raises(ConfigurationError, match='AppConfig requires "models"'): - TortoiseConfig.from_dict( - {"connections": {"default": "db.sqlite3"}, "apps": {"auth": {}}, "routers": {}} - ) - with pytest.raises( - ConfigurationError, match="AppConfig.models must be a non-empty list of strings" - ): - TortoiseConfig.from_dict( + @pytest.mark.parametrize( + "config,msg", + [ + ([], "TortoiseConfig must be created from a mapping"), + ({}, 'Config must define "connections" section'), + ({"connections": ""}, 'Config must define "apps" section'), + ({"connections": "", "apps": ""}, 'Config "connections" must be a mapping'), + ( + {"connections": {"default": []}, "apps": ""}, + "Connection values must be mapping or string", + ), + ( + {"connections": {"default": ""}, "apps": ""}, + "DBUrlConfig.url must be a non-empty string", + ), + ( + {"connections": {"default": "db.sqlite3"}, "apps": ""}, + 'Config "apps" must be a mapping', + ), + ( + {"connections": {"default": "db.sqlite3"}, "apps": {"auth": ""}}, + "App values must be mappings", + ), + ( + {"connections": {"default": "db.sqlite3"}, "apps": {"auth": {}}, "routers": {}}, + 'AppConfig requires "models"', + ), + ( { "connections": {"default": "db.sqlite3"}, "apps": {"auth": {"models": []}}, "routers": {}, - } - ) - with pytest.raises( - ConfigurationError, match="TortoiseConfig.routers must be a list or None" - ): - TortoiseConfig.from_dict( + }, + "AppConfig.models must be a non-empty list of strings", + ), + ( { "connections": {"default": "db.sqlite3"}, "apps": {"auth": {"models": ["models"]}}, "routers": "", - } - ) + }, + "TortoiseConfig.routers must be a list or None", + ), + ], + ) + def test_from_invalid_dict(self, config: list | dict, msg: str): + with pytest.raises(ConfigurationError, match=msg): + TortoiseConfig.from_dict(config) # type: ignore def test_from_dict(self): simple = { @@ -129,21 +132,25 @@ def test_from_db_url_and_modules(self): assert typed_config == TortoiseConfig.resolve_args(db_url=db_url, modules=modules) assert typed_config.apps == TortoiseConfig.from_dict(simple).apps + @pytest.mark.parametrize( + "config,msg", + [ + ({}, "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'"), + ( + dict(db_url=""), + "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'", + ), + ( + dict(config={}, config_file="a.json"), + "Cannot specify both 'config' and 'config_file'", + ), + ], + ) + def test_resolve_args_invalid(self, config: dict, msg: str): + with pytest.raises(ConfigurationError, match=msg): + TortoiseConfig.resolve_args(**config) + def test_resolve_args(self, tmp_path: Path): - with pytest.raises( - ConfigurationError, - match="Must provide either 'config', 'config_file', or both 'db_url' and 'modules'", - ): - TortoiseConfig.resolve_args() - with pytest.raises( - ConfigurationError, - match="Must provide either 'config', 'config_file', or both 'db_url' and 'modules'", - ): - TortoiseConfig.resolve_args(db_url="") - with pytest.raises( - ConfigurationError, match="Cannot specify both 'config' and 'config_file'" - ): - TortoiseConfig.resolve_args(config={}, config_file="a.json") db_url = "sqlite://db.sqlite3" config = { "connections": {"default": db_url}, From a6316e1373a9331199a9441ba6ddced3fc87f570 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 15 May 2026 01:59:39 +0800 Subject: [PATCH 5/5] tests: reduce duplicated --- tests/test_config.py | 110 +++++++++++++++++++++++++------------------ 1 file changed, 65 insertions(+), 45 deletions(-) diff --git a/tests/test_config.py b/tests/test_config.py index 3b570321f..5fcec96d6 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -2,6 +2,7 @@ Tests for tortoise.config module - TortoiseConfig class. """ +import shutil from pathlib import Path import orjson @@ -9,11 +10,27 @@ import yaml from tortoise.backends.base.config_generator import expand_db_url -from tortoise.config import TortoiseConfig +from tortoise.config import AppConfig, DBUrlConfig, TortoiseConfig from tortoise.exceptions import ConfigurationError class TestTortoiseConfig: + @pytest.fixture + def db_url(self) -> str: + return "sqlite://db.sqlite3" + + @pytest.fixture + def simple_config(self, db_url: str) -> dict: + return { + "connections": {"default": db_url}, + "apps": { + "app": { + "models": ["app.models"], + "default_connection": "default", + } + }, + } + @pytest.mark.parametrize( "config,msg", [ @@ -63,51 +80,74 @@ def test_from_invalid_dict(self, config: list | dict, msg: str): with pytest.raises(ConfigurationError, match=msg): TortoiseConfig.from_dict(config) # type: ignore - def test_from_dict(self): - simple = { - "connections": {"default": "sqlite://db.sqlite3"}, - "apps": {"app": {"models": ["app.models"]}}, - } - assert TortoiseConfig.from_dict(simple) is not None + def test_from_dict(self, simple_config: dict): + assert TortoiseConfig.from_dict(simple_config) == TortoiseConfig( + connections={"default": DBUrlConfig(url="sqlite://db.sqlite3")}, + apps={ + "app": AppConfig( + models=["app.models"], default_connection="default", migrations=None + ) + }, + routers=None, + use_tz=None, + timezone=None, + ) full = { "connections": { "default": "sqlite://db.sqlite3", "second": "sqlite://db2.sqlite3", }, "apps": { - "app1": {"models": ["app1.models"]}, + "app1": { + "models": ["app1.models"], + "migrations": "app1.migrations", + }, "app2": { "models": ["app2.models"], "default_connection": "second", + "migrations": "app2.migrations", }, }, "routers": ["path.Router"], "use_tz": True, "timezone": "UTC", } - assert TortoiseConfig.from_dict(full) is not None + assert TortoiseConfig.from_dict(full) == TortoiseConfig( + connections={ + "default": DBUrlConfig(url="sqlite://db.sqlite3"), + "second": DBUrlConfig(url="sqlite://db2.sqlite3"), + }, + apps={ + "app1": AppConfig( + models=["app1.models"], default_connection=None, migrations="app1.migrations" + ), + "app2": AppConfig( + models=["app2.models"], + default_connection="second", + migrations="app2.migrations", + ), + }, + routers=["path.Router"], + use_tz=True, + timezone="UTC", + ) - def test_from_config_file(self, tmp_path: Path): - simple = { - "connections": {"default": "sqlite://db.sqlite3"}, - "apps": {"app": {"models": ["app.models"]}}, - } + def test_from_config_file(self, tmp_path: Path, simple_config: dict): file = tmp_path / "tortoise_conf.json" - file.write_bytes(orjson.dumps(simple)) + file.write_bytes(orjson.dumps(simple_config)) filename: str = file.as_posix() assert ( TortoiseConfig.from_config_file(file) == TortoiseConfig.from_config_file(filename) - == TortoiseConfig.from_dict(simple) + == TortoiseConfig.from_dict(simple_config) == TortoiseConfig.resolve_args(config_file=file) ) yaml_file = file.with_suffix(".yml") with yaml_file.open("w") as f: - yaml.safe_dump(dict(simple), f, default_flow_style=False) + yaml.safe_dump(simple_config, f, default_flow_style=False) yaml_file_2 = file.with_suffix(".yaml") - with yaml_file_2.open("w") as f2: - yaml.safe_dump(simple, f2, default_flow_style=False) + shutil.copy(yaml_file, yaml_file_2) assert ( TortoiseConfig.from_config_file(yaml_file) == TortoiseConfig.from_config_file(str(yaml_file)) @@ -116,21 +156,11 @@ def test_from_config_file(self, tmp_path: Path): == TortoiseConfig.resolve_args(config_file=yaml_file) ) - def test_from_db_url_and_modules(self): - simple = { - "connections": {"default": "sqlite://db.sqlite3"}, - "apps": { - "app": { - "models": ["app.models"], - "default_connection": "default", - } - }, - } - db_url = simple["connections"]["default"] - modules = {"app": simple["apps"]["app"]["models"]} + def test_from_db_url_and_modules(self, simple_config: dict, db_url: str): + modules = {"app": simple_config["apps"]["app"]["models"]} typed_config = TortoiseConfig.from_db_url_and_modules(db_url, modules) assert typed_config == TortoiseConfig.resolve_args(db_url=db_url, modules=modules) - assert typed_config.apps == TortoiseConfig.from_dict(simple).apps + assert typed_config.apps == TortoiseConfig.from_dict(simple_config).apps @pytest.mark.parametrize( "config,msg", @@ -150,20 +180,10 @@ def test_resolve_args_invalid(self, config: dict, msg: str): with pytest.raises(ConfigurationError, match=msg): TortoiseConfig.resolve_args(**config) - def test_resolve_args(self, tmp_path: Path): - db_url = "sqlite://db.sqlite3" - config = { - "connections": {"default": db_url}, - "apps": { - "app": { - "models": ["app.models"], - "default_connection": "default", - } - }, - } + def test_resolve_args(self, tmp_path: Path, db_url: str, simple_config: dict): config_file = tmp_path / "config.json" - config_file.write_bytes(orjson.dumps(config)) - typed_config = TortoiseConfig.resolve_args(config) + config_file.write_bytes(orjson.dumps(simple_config)) + typed_config = TortoiseConfig.resolve_args(simple_config) assert typed_config == TortoiseConfig.resolve_args(config_file=config_file) typed_config_2 = TortoiseConfig.resolve_args(db_url=db_url, modules={"app": ["app.models"]})