From 23467a9c4e72654633e7d5d8598a9da050c3cb5d Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Mon, 6 Apr 2026 14:55:07 +0800 Subject: [PATCH 01/10] feat: add pre commit config and fix issues --- .pre-commit-config.yaml | 30 +++++++++++++++++++ CHANGELOG.rst | 6 ++-- docs/query.rst | 2 +- .../README.rst | 2 +- pyproject.toml | 3 ++ tests/fields/type_checks.py | 2 +- tests/test_model_methods.py | 2 +- tortoise/models.py | 2 +- tortoise/queryset.py | 2 +- 9 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..0b74748b1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,30 @@ +ci: + autoupdate_schedule: monthly +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v6.0.0 + hooks: + - id: check-added-large-files + - id: check-toml + - id: check-yaml + args: + - --unsafe + - id: end-of-file-fixer + files: tortoise/ + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 'v0.15.9' + hooks: + - id: ruff + args: [--fix, --exit-non-zero-on-fix, --show-fixes, tortoise/, examples/, tests/, conftest.py] + pass_filenames: false + - id: ruff-format + args: [tortoise/, examples/, tests/, conftest.py] + pass_filenames: false + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.2 + hooks: + - id: codespell # See pyproject.toml for args + additional_dependencies: + - tomli diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 68d944bea..c7842a0a7 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1049,7 +1049,7 @@ Removals: 0.15.15 ------- -- Add ability to suppply a ``to_field=`` parameter for FK/O2O to a non-PK but still uniquely indexed remote field. (#287) +- Add ability to supply a ``to_field=`` parameter for FK/O2O to a non-PK but still uniquely indexed remote field. (#287) 0.15.14 ------- @@ -1600,7 +1600,7 @@ Docs/examples: 0.10.9 ------ -- Uses macros on SQLite driver to minimise syncronisation. ``aiosqlite>=0.7.0`` +- Uses macros on SQLite driver to minimise synchronisation. ``aiosqlite>=0.7.0`` - Uses prepared statements for insert, large insert performance increase. - Pre-generate base pypika query object per model, providing general purpose speedup. @@ -1720,7 +1720,7 @@ Docs/examples: - Fixed ``DatetimeField`` and ``DateField`` to work as expected on SQLite. - Added ``PyLint`` plugin. -- Added test class to mange DB state for testing isolation. +- Added test class to manage DB state for testing isolation. 0.8.0 ----- diff --git a/docs/query.rst b/docs/query.rst index 75b9dee6f..2038e1067 100644 --- a/docs/query.rst +++ b/docs/query.rst @@ -122,7 +122,7 @@ You could do it using ``.prefetch_related()``: # This will fetch tournament with their events and teams for each event tournament_list = await Tournament.all().prefetch_related('events__participants') - # Fetched result for m2m and backward fk relations are stored in list-like containe#r + # Fetched result for m2m and backward fk relations are stored in list-like container for tournament in tournament_list: print([e.name for e in tournament.events]) diff --git a/examples/comprehensive_migrations_project/README.rst b/examples/comprehensive_migrations_project/README.rst index 3c54366e6..b7ee5b719 100644 --- a/examples/comprehensive_migrations_project/README.rst +++ b/examples/comprehensive_migrations_project/README.rst @@ -3,7 +3,7 @@ Comprehensive Migrations Project =================================== This example demonstrates Tortoise ORM's complete migration system through a realistic ERP schema that evolves -through 14 migrations. It covers all field types, migration operations (CreateModel, AddField, AlterField, +through 14 migrations. It covers all field types, migration operations (CreateModel, AddField, AlterField, RemoveField, RenameField, RunPython, RunSQL, indexes, constraints), and fully reversible migrations. Usage diff --git a/pyproject.toml b/pyproject.toml index 806ec66a1..69b8ba3b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -236,3 +236,6 @@ exclude_dirs = [ "examples/postgres_full_text_search.py", "examples/postgres.py", ] + +[tool.codespell] +ignore-words-list = "notin,NotIn,brin,BRIN,astroid" diff --git a/tests/fields/type_checks.py b/tests/fields/type_checks.py index ab8fec130..769035931 100644 --- a/tests/fields/type_checks.py +++ b/tests/fields/type_checks.py @@ -92,7 +92,7 @@ class TypeTestModel(Model): char_enum_field_non_null = CharEnumField(enum_type=Color, max_length=10, null=False) char_enum_field_nullable = CharEnumField(enum_type=Color, max_length=10, null=True) - # inhereted fields + # inherited fields inhereted_int_field_non_null = InheretedFromIntField(null=False) inhereted_int_field_nullable = InheretedFromIntField(null=True) diff --git a/tests/test_model_methods.py b/tests/test_model_methods.py index 7887af55f..cef244b89 100644 --- a/tests/test_model_methods.py +++ b/tests/test_model_methods.py @@ -266,7 +266,7 @@ async def test_update_or_create_with_defaults(tournament_model): assert created is False assert defaults["desc"] == updated_mdl.desc assert mdl.desc != updated_mdl.desc - # Hint query: use defauts to update without checking conflict + # Hint query: use defaults to update without checking conflict mdl2, created = await Tournament.update_or_create( id=oldid, desc=desc, defaults=dict(mdl_dict, desc="new desc") ) diff --git a/tortoise/models.py b/tortoise/models.py index aa20fdbd6..21d4e4d51 100644 --- a/tortoise/models.py +++ b/tortoise/models.py @@ -873,7 +873,7 @@ def __iter__(self) -> Iterable[tuple]: yield field, getattr(self, field) def __eq__(self, other: object) -> bool: - return type(other) is type(self) and self.pk == other.pk # type: ignore + return type(other) is type(self) and self.pk == other.pk def _get_pk_val(self) -> Any: return getattr(self, self._meta.pk_attr, None) diff --git a/tortoise/queryset.py b/tortoise/queryset.py index ef4b5b568..3d2849e19 100644 --- a/tortoise/queryset.py +++ b/tortoise/queryset.py @@ -1111,7 +1111,7 @@ def _resolve_only(self, only_lookup_expressions: tuple[str, ...]) -> None: fetch_to_fields = defaultdict(list) # the order is important here, we need to process the shallowest fields first # because we want to populate _select_related_idx with actual items that need to be - # selected, not "filler" items tha just tell the executor that an empty instance has + # selected, not "filler" items than just tell the executor that an empty instance has # to be created for expression in sorted(only_lookup_expressions, key=lambda x: x.count("__")): fetch_fields_lookup, __, field_name = expression.rpartition("__") From e656ce9959c4435ee98b65b7661137a52b88533b Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Mon, 6 Apr 2026 14:58:37 +0800 Subject: [PATCH 02/10] Fix mypy complaint --- tortoise/models.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tortoise/models.py b/tortoise/models.py index 21d4e4d51..aa20fdbd6 100644 --- a/tortoise/models.py +++ b/tortoise/models.py @@ -873,7 +873,7 @@ def __iter__(self) -> Iterable[tuple]: yield field, getattr(self, field) def __eq__(self, other: object) -> bool: - return type(other) is type(self) and self.pk == other.pk + return type(other) is type(self) and self.pk == other.pk # type: ignore def _get_pk_val(self) -> Any: return getattr(self, self._meta.pk_attr, None) From b1e1093fa464522e7b58d8659b41e91a133bde1a Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Mon, 6 Apr 2026 15:16:02 +0800 Subject: [PATCH 03/10] feat: expose classmethod to build tortoise config --- tortoise/config.py | 39 ++++++++++++++++++++++++++++++++++++++- tortoise/context.py | 21 +-------------------- 2 files changed, 39 insertions(+), 21 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index 8c2be9d16..5f41694fd 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -2,10 +2,14 @@ from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Any +from typing import TYPE_CHECKING, Any from tortoise.exceptions import ConfigurationError +if TYPE_CHECKING: + from collections.abc import Iterable + from types import ModuleType + @dataclass(frozen=True) class DBUrlConfig: @@ -202,3 +206,36 @@ def from_dict(cls, data: Mapping[str, Any]) -> TortoiseConfig: use_tz=data.get("use_tz"), timezone=data.get("timezone"), ) + + @classmethod + def merge_args( + cls, + config: dict[str, Any] | TortoiseConfig | None = None, + config_file: str | None = None, + db_url: str | None = None, + modules: dict[str, Iterable[str | ModuleType]] | None = None, + ) -> TortoiseConfig: + # Handle config_file: load it as config dict + if config_file is not None: + from tortoise import Tortoise + + if config is not None: + raise ConfigurationError("Cannot specify both 'config' and 'config_file'") + config = Tortoise._get_config_from_config_file(config_file) + + # Convert input to TortoiseConfig for typed access + typed_config: TortoiseConfig + if config is None: + from tortoise.backends.base.config_generator import generate_config + + if db_url is None or modules is None: + raise ConfigurationError( + "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" + ) + config_dict = generate_config(db_url, app_modules=modules) + typed_config = cls.from_dict(config_dict) + elif isinstance(config, dict): + typed_config = cls.from_dict(config) + else: + typed_config = config + return typed_config diff --git a/tortoise/context.py b/tortoise/context.py index bc274b215..f0031f371 100644 --- a/tortoise/context.py +++ b/tortoise/context.py @@ -303,26 +303,7 @@ async def init( """ from tortoise.apps import Apps - # Handle config_file: load it as config dict - if config_file is not None: - if config is not None: - raise ConfigurationError("Cannot specify both 'config' and 'config_file'") - config = self._get_config_from_config_file(config_file) - - # Convert input to TortoiseConfig for typed access - typed_config: TortoiseConfig - if config is None: - if db_url is None or modules is None: - raise ConfigurationError( - "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" - ) - config_dict = generate_config(db_url, app_modules=modules) - typed_config = TortoiseConfig.from_dict(config_dict) - elif isinstance(config, TortoiseConfig): - typed_config = config - else: - typed_config = TortoiseConfig.from_dict(config) - + typed_config = TortoiseConfig.merge_args(config, config_file, db_url, modules) config_dict = typed_config.to_dict() connections_config = config_dict["connections"] apps_config = config_dict["apps"] From d7d62d50e2a0511835dbf5be973eb533b5d86e1a Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 10 Apr 2026 00:46:21 +0800 Subject: [PATCH 04/10] refactor: move config file parse logic to TortoiseConfig --- tortoise/__init__.py | 20 +---------- tortoise/cli/cli.py | 11 +++--- tortoise/config.py | 48 ++++++++++++++++----------- tortoise/context.py | 20 ----------- tortoise/migrations/api/migrate.py | 4 +-- tortoise/migrations/api/plan.py | 4 +-- tortoise/migrations/api/sqlmigrate.py | 4 +-- 7 files changed, 41 insertions(+), 70 deletions(-) diff --git a/tortoise/__init__.py b/tortoise/__init__.py index cd3ca65f2..45758228c 100644 --- a/tortoise/__init__.py +++ b/tortoise/__init__.py @@ -1,7 +1,6 @@ from __future__ import annotations import importlib -import json import logging import os import warnings @@ -271,23 +270,6 @@ def _init_apps( validate_connections=validate_connections, ) - @classmethod - def _get_config_from_config_file(cls, config_file: str) -> dict: - _, 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" - ) - return config - @classmethod def _build_initial_querysets(cls) -> None: if cls.apps: @@ -408,7 +390,7 @@ async def init( # Normalize config: handle config_file case normalized_config: dict[str, Any] | TortoiseConfig | None = config if config_file: - normalized_config = cls._get_config_from_config_file(config_file) + normalized_config = TortoiseConfig._get_config_from_config_file(config_file) # Debug logging if logger.isEnabledFor(logging.DEBUG) and normalized_config is not None: diff --git a/tortoise/cli/cli.py b/tortoise/cli/cli.py index b3269de49..0f57f6bbf 100644 --- a/tortoise/cli/cli.py +++ b/tortoise/cli/cli.py @@ -177,14 +177,13 @@ def _load_config(ctx: CLIContext) -> TortoiseConfig: config_value = ctx.config config_file = ctx.config_file if config_file: - config_dict = Tortoise._get_config_from_config_file(config_file) - return TortoiseConfig.from_dict(config_dict) + return TortoiseConfig._get_config_from_config_file(config_file) if not config_value: config_value = utils.tortoise_orm_config() - if not config_value: - raise utils.CLIUsageError( - "You must specify TORTOISE_ORM in option or env, or pyproject.toml [tool.tortoise]", - ) + if not config_value: + raise utils.CLIUsageError( + "You must specify TORTOISE_ORM in option or env, or pyproject.toml [tool.tortoise]", + ) return utils.get_tortoise_config(config_value) diff --git a/tortoise/config.py b/tortoise/config.py index 5f41694fd..48afdfa61 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import os from collections.abc import Mapping from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any @@ -215,27 +217,35 @@ def merge_args( db_url: str | None = None, modules: dict[str, Iterable[str | ModuleType]] | None = None, ) -> TortoiseConfig: - # Handle config_file: load it as config dict - if config_file is not None: - from tortoise import Tortoise - - if config is not None: + if config is not None: + if config_file is not None: raise ConfigurationError("Cannot specify both 'config' and 'config_file'") - config = Tortoise._get_config_from_config_file(config_file) - - # Convert input to TortoiseConfig for typed access - typed_config: TortoiseConfig - if config is None: + return cls.from_dict(config) if isinstance(config, dict) else config + elif config_file is not None: + return cls._get_config_from_config_file(config_file) + elif db_url is None or modules is None: + raise ConfigurationError( + "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" + ) + else: from tortoise.backends.base.config_generator import generate_config - if db_url is None or modules is None: - raise ConfigurationError( - "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" - ) config_dict = generate_config(db_url, app_modules=modules) - typed_config = cls.from_dict(config_dict) - elif isinstance(config, dict): - typed_config = cls.from_dict(config) + return cls.from_dict(config_dict) + + @classmethod + def _get_config_from_config_file(cls, config_file: str) -> TortoiseConfig: + _, 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: - typed_config = config - return typed_config + raise ConfigurationError( + f"Unknown config extension {extension}, only .yml and .json are supported" + ) + return cls.from_dict(config) diff --git a/tortoise/context.py b/tortoise/context.py index f0031f371..2e32a323e 100644 --- a/tortoise/context.py +++ b/tortoise/context.py @@ -236,26 +236,6 @@ def routers(self) -> list[type]: """ return self._routers - def _get_config_from_config_file(self, config_file: str) -> dict: - """Load configuration from a JSON or YAML file.""" - import json - import os - - _, 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" - ) - return config - async def init( self, config: dict[str, Any] | TortoiseConfig | None = None, diff --git a/tortoise/migrations/api/migrate.py b/tortoise/migrations/api/migrate.py index 508f8cea3..5fb74f772 100644 --- a/tortoise/migrations/api/migrate.py +++ b/tortoise/migrations/api/migrate.py @@ -23,10 +23,10 @@ async def migrate( progress: Callable[[str, str, str], object] | None = None, ) -> None: """Run migrations for configured apps.""" + if config_file: + config = TortoiseConfig._get_config_from_config_file(config_file) if isinstance(config, TortoiseConfig): config = config.to_dict() - if config_file: - config = Tortoise._get_config_from_config_file(config_file) if not config: raise ValueError("migrate requires a config or config_file") diff --git a/tortoise/migrations/api/plan.py b/tortoise/migrations/api/plan.py index 2dce67f56..8157b8c9e 100644 --- a/tortoise/migrations/api/plan.py +++ b/tortoise/migrations/api/plan.py @@ -19,10 +19,10 @@ async def plan( """ Print an ordered migration plan and return the formatted lines. """ + if config_file: + config = TortoiseConfig._get_config_from_config_file(config_file) if isinstance(config, TortoiseConfig): config = config.to_dict() - if config_file: - config = Tortoise._get_config_from_config_file(config_file) if not config: raise ValueError("plan requires a config or config_file") diff --git a/tortoise/migrations/api/sqlmigrate.py b/tortoise/migrations/api/sqlmigrate.py index 37ecaeb0f..0ddb133e2 100644 --- a/tortoise/migrations/api/sqlmigrate.py +++ b/tortoise/migrations/api/sqlmigrate.py @@ -43,10 +43,10 @@ async def sqlmigrate( Returns: A list of SQL strings (including descriptive comment annotations). """ + if config_file: + config = TortoiseConfig._get_config_from_config_file(config_file) if isinstance(config, TortoiseConfig): config = config.to_dict() - if config_file: - config = Tortoise._get_config_from_config_file(config_file) if not config: raise ValueError("sqlmigrate requires a config or config_file") From 260de752475e1cb546b90d35aea339833e9343f1 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Mon, 13 Apr 2026 21:45:04 +0800 Subject: [PATCH 05/10] Use Self --- tortoise/config.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index 48afdfa61..d8be1eb6c 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -9,9 +9,15 @@ from tortoise.exceptions import ConfigurationError if TYPE_CHECKING: + import sys from collections.abc import Iterable from types import ModuleType + if sys.version_info >= (3, 11): + from typing import Self + else: + from typing_extensions import Self + @dataclass(frozen=True) class DBUrlConfig: @@ -165,7 +171,7 @@ def to_dict(self) -> dict[str, Any]: return config @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> TortoiseConfig: + def from_dict(cls, data: Mapping[str, Any]) -> Self: if not isinstance(data, Mapping): raise ConfigurationError("TortoiseConfig must be created from a mapping") @@ -212,11 +218,11 @@ def from_dict(cls, data: Mapping[str, Any]) -> TortoiseConfig: @classmethod def merge_args( cls, - config: dict[str, Any] | TortoiseConfig | None = None, + config: dict[str, Any] | Self | None = None, config_file: str | None = None, db_url: str | None = None, modules: dict[str, Iterable[str | ModuleType]] | None = None, - ) -> TortoiseConfig: + ) -> Self: if config is not None: if config_file is not None: raise ConfigurationError("Cannot specify both 'config' and 'config_file'") @@ -228,13 +234,17 @@ def merge_args( "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" ) else: - from tortoise.backends.base.config_generator import generate_config + return cls.generate_config(db_url, modules) + + @classmethod + def generate_config(cls, db_url: str, modules: dict[str, Iterable[str | ModuleType]]) -> Self: + from tortoise.backends.base.config_generator import generate_config - config_dict = generate_config(db_url, app_modules=modules) - return cls.from_dict(config_dict) + config_dict = generate_config(db_url, app_modules=modules) + return cls.from_dict(config_dict) @classmethod - def _get_config_from_config_file(cls, config_file: str) -> TortoiseConfig: + def _get_config_from_config_file(cls, config_file: str) -> Self: _, extension = os.path.splitext(config_file) if extension in (".yml", ".yaml"): import yaml # pylint: disable=C0415 From 5f71748daa33629084c738d547109bb963c1f421 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Mon, 13 Apr 2026 23:25:12 +0800 Subject: [PATCH 06/10] refactor: reduce duplicated --- tortoise/__init__.py | 2 +- tortoise/cli/cli.py | 2 +- tortoise/config.py | 4 ++-- tortoise/migrations/api/migrate.py | 5 +---- tortoise/migrations/api/plan.py | 5 +---- tortoise/migrations/api/sqlmigrate.py | 5 +---- 6 files changed, 7 insertions(+), 16 deletions(-) diff --git a/tortoise/__init__.py b/tortoise/__init__.py index 45758228c..118db4a55 100644 --- a/tortoise/__init__.py +++ b/tortoise/__init__.py @@ -390,7 +390,7 @@ async def init( # Normalize config: handle config_file case normalized_config: dict[str, Any] | TortoiseConfig | None = config if config_file: - normalized_config = TortoiseConfig._get_config_from_config_file(config_file) + normalized_config = TortoiseConfig.get_config_from_config_file(config_file) # Debug logging if logger.isEnabledFor(logging.DEBUG) and normalized_config is not None: diff --git a/tortoise/cli/cli.py b/tortoise/cli/cli.py index 0f57f6bbf..d5753a544 100644 --- a/tortoise/cli/cli.py +++ b/tortoise/cli/cli.py @@ -177,7 +177,7 @@ def _load_config(ctx: CLIContext) -> TortoiseConfig: config_value = ctx.config config_file = ctx.config_file if config_file: - return TortoiseConfig._get_config_from_config_file(config_file) + return TortoiseConfig.get_config_from_config_file(config_file) if not config_value: config_value = utils.tortoise_orm_config() if not config_value: diff --git a/tortoise/config.py b/tortoise/config.py index d8be1eb6c..a780016eb 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -228,7 +228,7 @@ def merge_args( raise ConfigurationError("Cannot specify both 'config' and 'config_file'") return cls.from_dict(config) if isinstance(config, dict) else config elif config_file is not None: - return cls._get_config_from_config_file(config_file) + return cls.get_config_from_config_file(config_file) elif db_url is None or modules is None: raise ConfigurationError( "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" @@ -244,7 +244,7 @@ def generate_config(cls, db_url: str, modules: dict[str, Iterable[str | ModuleTy return cls.from_dict(config_dict) @classmethod - def _get_config_from_config_file(cls, config_file: str) -> Self: + def get_config_from_config_file(cls, config_file: str) -> Self: _, extension = os.path.splitext(config_file) if extension in (".yml", ".yaml"): import yaml # pylint: disable=C0415 diff --git a/tortoise/migrations/api/migrate.py b/tortoise/migrations/api/migrate.py index 5fb74f772..32b14c6d4 100644 --- a/tortoise/migrations/api/migrate.py +++ b/tortoise/migrations/api/migrate.py @@ -23,10 +23,7 @@ async def migrate( progress: Callable[[str, str, str], object] | None = None, ) -> None: """Run migrations for configured apps.""" - if config_file: - config = TortoiseConfig._get_config_from_config_file(config_file) - if isinstance(config, TortoiseConfig): - config = config.to_dict() + config = TortoiseConfig.merge_args(config, config_file).to_dict() if not config: raise ValueError("migrate requires a config or config_file") diff --git a/tortoise/migrations/api/plan.py b/tortoise/migrations/api/plan.py index 8157b8c9e..51be2ee50 100644 --- a/tortoise/migrations/api/plan.py +++ b/tortoise/migrations/api/plan.py @@ -19,10 +19,7 @@ async def plan( """ Print an ordered migration plan and return the formatted lines. """ - if config_file: - config = TortoiseConfig._get_config_from_config_file(config_file) - if isinstance(config, TortoiseConfig): - config = config.to_dict() + config = TortoiseConfig.merge_args(config, config_file).to_dict() if not config: raise ValueError("plan requires a config or config_file") diff --git a/tortoise/migrations/api/sqlmigrate.py b/tortoise/migrations/api/sqlmigrate.py index 0ddb133e2..a39c8174e 100644 --- a/tortoise/migrations/api/sqlmigrate.py +++ b/tortoise/migrations/api/sqlmigrate.py @@ -43,10 +43,7 @@ async def sqlmigrate( Returns: A list of SQL strings (including descriptive comment annotations). """ - if config_file: - config = TortoiseConfig._get_config_from_config_file(config_file) - if isinstance(config, TortoiseConfig): - config = config.to_dict() + config = TortoiseConfig.merge_args(config, config_file).to_dict() if not config: raise ValueError("sqlmigrate requires a config or config_file") From 3b78e1efc57673cb2a505e56786da835553bfbe1 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 17 Apr 2026 23:42:01 +0800 Subject: [PATCH 07/10] refactor: change function names and add docs --- tortoise/__init__.py | 2 +- tortoise/cli/cli.py | 2 +- tortoise/config.py | 109 ++++++++++++++++++++------ tortoise/context.py | 2 +- tortoise/migrations/api/migrate.py | 2 +- tortoise/migrations/api/plan.py | 2 +- tortoise/migrations/api/sqlmigrate.py | 2 +- 7 files changed, 89 insertions(+), 32 deletions(-) diff --git a/tortoise/__init__.py b/tortoise/__init__.py index 118db4a55..d84aaef36 100644 --- a/tortoise/__init__.py +++ b/tortoise/__init__.py @@ -390,7 +390,7 @@ async def init( # Normalize config: handle config_file case normalized_config: dict[str, Any] | TortoiseConfig | None = config if config_file: - normalized_config = TortoiseConfig.get_config_from_config_file(config_file) + normalized_config = TortoiseConfig.from_config_file(config_file) # Debug logging if logger.isEnabledFor(logging.DEBUG) and normalized_config is not None: diff --git a/tortoise/cli/cli.py b/tortoise/cli/cli.py index d5753a544..ca3cc8c4c 100644 --- a/tortoise/cli/cli.py +++ b/tortoise/cli/cli.py @@ -177,7 +177,7 @@ def _load_config(ctx: CLIContext) -> TortoiseConfig: config_value = ctx.config config_file = ctx.config_file if config_file: - return TortoiseConfig.get_config_from_config_file(config_file) + return TortoiseConfig.from_config_file(config_file) if not config_value: config_value = utils.tortoise_orm_config() if not config_value: diff --git a/tortoise/config.py b/tortoise/config.py index a780016eb..4d64b123c 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -216,35 +216,19 @@ def from_dict(cls, data: Mapping[str, Any]) -> Self: ) @classmethod - def merge_args( - cls, - config: dict[str, Any] | Self | None = None, - config_file: str | None = None, - db_url: str | None = None, - modules: dict[str, Iterable[str | ModuleType]] | None = None, - ) -> Self: - if config is not None: - if config_file is not None: - raise ConfigurationError("Cannot specify both 'config' and 'config_file'") - return cls.from_dict(config) if isinstance(config, dict) else config - elif config_file is not None: - return cls.get_config_from_config_file(config_file) - elif db_url is None or modules is None: - raise ConfigurationError( - "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" - ) - else: - return cls.generate_config(db_url, modules) + def from_config_file(cls, config_file: str) -> Self: + """ + Load configuration from a YAML or JSON file. - @classmethod - def generate_config(cls, db_url: str, modules: dict[str, Iterable[str | ModuleType]]) -> Self: - from tortoise.backends.base.config_generator import generate_config + Args: + config_file (str): Path to the configuration file. Supported extensions: .yml, .yaml, .json. - config_dict = generate_config(db_url, app_modules=modules) - return cls.from_dict(config_dict) + Returns: + Self: The constructed TortoiseConfig. - @classmethod - def get_config_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 @@ -259,3 +243,76 @@ def get_config_from_config_file(cls, config_file: str) -> Self: f"Unknown config extension {extension}, only .yml and .json are supported" ) return cls.from_dict(config) + + @classmethod + def from_db_url_and_modules( + cls, db_url: str, modules: dict[str, Iterable[str | ModuleType]] + ) -> Self: + """ + Create a TortoiseConfig instance using a database URL and app modules mapping. + + This factory method builds a configuration dictionary using the provided database URL and modules, + and returns a TortoiseConfig instance based on that configuration. + + Args: + db_url: Database connection URL as a string. + modules: + A mapping where keys are app names, and values are iterables of Python module names + (as strings or Python module types) containing ORM models. + + Returns: + Self: The constructed TortoiseConfig instance. + + Raises: + ConfigurationError: If the generated config is invalid. + """ + from tortoise.backends.base.config_generator import generate_config + + config_dict = generate_config(db_url, app_modules=modules) + return cls.from_dict(config_dict) + + @classmethod + def resolve_args( + cls, + config: dict[str, Any] | Self | None = None, + config_file: str | None = None, + db_url: str | None = None, + modules: dict[str, Iterable[str | ModuleType]] | None = None, + ) -> Self: + """ + Parse and resolve multiple configuration argument sources into a unified TortoiseConfig instance. + + Accepts (in order of priority): + - `config` dict or TortoiseConfig instance, + - `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. + db_url: Database URL for config generation. + modules: App modules for config generation. + + Returns: + TortoiseConfig instance with resolved configuration. + + Raises: + ConfigurationError: If arguments are invalid or conflicting. + """ + if config is not None: + if config_file is not None: + raise ConfigurationError("Cannot specify both 'config' and 'config_file'") + return cls.from_dict(config) if isinstance(config, dict) else config + elif config_file is not None: + return cls.from_config_file(config_file) + elif db_url is None or modules is None: + raise ConfigurationError( + "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" + ) + else: + return cls.from_db_url_and_modules(db_url, modules) diff --git a/tortoise/context.py b/tortoise/context.py index 2e32a323e..9b9a14014 100644 --- a/tortoise/context.py +++ b/tortoise/context.py @@ -283,7 +283,7 @@ async def init( """ from tortoise.apps import Apps - typed_config = TortoiseConfig.merge_args(config, config_file, db_url, modules) + typed_config = TortoiseConfig.resolve_args(config, config_file, db_url, modules) config_dict = typed_config.to_dict() connections_config = config_dict["connections"] apps_config = config_dict["apps"] diff --git a/tortoise/migrations/api/migrate.py b/tortoise/migrations/api/migrate.py index 32b14c6d4..7576e880a 100644 --- a/tortoise/migrations/api/migrate.py +++ b/tortoise/migrations/api/migrate.py @@ -23,7 +23,7 @@ async def migrate( progress: Callable[[str, str, str], object] | None = None, ) -> None: """Run migrations for configured apps.""" - config = TortoiseConfig.merge_args(config, config_file).to_dict() + config = TortoiseConfig.resolve_args(config, config_file).to_dict() if not config: raise ValueError("migrate requires a config or config_file") diff --git a/tortoise/migrations/api/plan.py b/tortoise/migrations/api/plan.py index 51be2ee50..957e4d0cb 100644 --- a/tortoise/migrations/api/plan.py +++ b/tortoise/migrations/api/plan.py @@ -19,7 +19,7 @@ async def plan( """ Print an ordered migration plan and return the formatted lines. """ - config = TortoiseConfig.merge_args(config, config_file).to_dict() + config = TortoiseConfig.resolve_args(config, config_file).to_dict() if not config: raise ValueError("plan requires a config or config_file") diff --git a/tortoise/migrations/api/sqlmigrate.py b/tortoise/migrations/api/sqlmigrate.py index a39c8174e..2be4a2bcc 100644 --- a/tortoise/migrations/api/sqlmigrate.py +++ b/tortoise/migrations/api/sqlmigrate.py @@ -43,7 +43,7 @@ async def sqlmigrate( Returns: A list of SQL strings (including descriptive comment annotations). """ - config = TortoiseConfig.merge_args(config, config_file).to_dict() + config = TortoiseConfig.resolve_args(config, config_file).to_dict() if not config: raise ValueError("sqlmigrate requires a config or config_file") From 5167053faddd66ce939dbf79a6f6e922d6c7cdbe Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Sat, 18 Apr 2026 00:00:13 +0800 Subject: [PATCH 08/10] refactor: remove else --- tortoise/config.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index 4d64b123c..da9369168 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -310,9 +310,8 @@ def resolve_args( return cls.from_dict(config) if isinstance(config, dict) else config elif config_file is not None: return cls.from_config_file(config_file) - elif db_url is None or modules is None: - raise ConfigurationError( - "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" - ) - else: + elif db_url is not None and modules is not None: return cls.from_db_url_and_modules(db_url, modules) + raise ConfigurationError( + "Must provide either 'config', 'config_file', or both 'db_url' and 'modules'" + ) From cb9f3f4863d0d95ac9c27754bff7127c5fac7b04 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Fri, 24 Apr 2026 19:37:10 +0800 Subject: [PATCH 09/10] refactor: reduce lines and indent --- tortoise/cli/cli.py | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/tortoise/cli/cli.py b/tortoise/cli/cli.py index ca3cc8c4c..5d1f69c59 100644 --- a/tortoise/cli/cli.py +++ b/tortoise/cli/cli.py @@ -174,16 +174,13 @@ def _load_config(ctx: CLIContext) -> TortoiseConfig: Returns: TortoiseConfig: Validated configuration object """ - config_value = ctx.config - config_file = ctx.config_file - if config_file: + if config_file := ctx.config_file: return TortoiseConfig.from_config_file(config_file) + config_value = ctx.config or utils.tortoise_orm_config() if not config_value: - config_value = utils.tortoise_orm_config() - if not config_value: - raise utils.CLIUsageError( - "You must specify TORTOISE_ORM in option or env, or pyproject.toml [tool.tortoise]", - ) + raise utils.CLIUsageError( + "You must specify TORTOISE_ORM in option or env, or pyproject.toml [tool.tortoise]", + ) return utils.get_tortoise_config(config_value) From 9ea070b33334c35ee0e97d4ef2fd5682b819bf12 Mon Sep 17 00:00:00 2001 From: Waket Zheng Date: Sat, 25 Apr 2026 00:33:17 +0800 Subject: [PATCH 10/10] Move import to top and use Self instead of class name for type hints --- tortoise/config.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tortoise/config.py b/tortoise/config.py index da9369168..f7527093b 100644 --- a/tortoise/config.py +++ b/tortoise/config.py @@ -6,6 +6,7 @@ from dataclasses import dataclass, field from typing import TYPE_CHECKING, Any +from tortoise.backends.base.config_generator import generate_config from tortoise.exceptions import ConfigurationError if TYPE_CHECKING: @@ -58,7 +59,7 @@ def to_config(self) -> str | dict[str, Any]: return {"engine": self.engine, "credentials": self.credentials} @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> ConnectionConfig: + def from_dict(cls, data: Mapping[str, Any]) -> Self: if not isinstance(data, Mapping): raise ConfigurationError("ConnectionConfig must be created from a mapping") credentials = data.get("credentials", {}) @@ -97,7 +98,7 @@ def to_dict(self) -> dict[str, Any]: return data @classmethod - def from_dict(cls, data: Mapping[str, Any]) -> AppConfig: + 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: @@ -266,8 +267,6 @@ def from_db_url_and_modules( Raises: ConfigurationError: If the generated config is invalid. """ - from tortoise.backends.base.config_generator import generate_config - config_dict = generate_config(db_url, app_modules=modules) return cls.from_dict(config_dict)