model-generator-kit 0.1.3__tar.gz → 0.1.4__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/PKG-INFO +2 -2
  2. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/README.md +1 -1
  3. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/pyproject.toml +1 -1
  4. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/__init__.py +1 -1
  5. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/request.py.j2 +1 -1
  6. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +61 -1
  7. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
  8. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_generators.py +105 -0
  9. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/LICENSE +0 -0
  10. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/setup.cfg +0 -0
  11. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generate.py +0 -0
  12. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/__init__.py +0 -0
  13. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/api.py +0 -0
  14. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/constraints.py +0 -0
  15. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/database.py +0 -0
  16. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/enums.py +0 -0
  17. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/infrastructure.py +0 -0
  18. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/generators/migrations.py +0 -0
  19. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/py.typed +0 -0
  20. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/schema/model.schema.json +0 -0
  21. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/config.yaml +0 -0
  22. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
  23. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
  24. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
  25. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
  26. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
  27. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/init.py.j2 +0 -0
  28. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +0 -0
  29. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/response.py.j2 +0 -0
  30. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/route.py.j2 +0 -0
  31. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +0 -0
  32. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +0 -0
  33. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +0 -0
  34. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/init.py.j2 +0 -0
  35. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/model.py.j2 +0 -0
  36. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +0 -0
  37. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +0 -0
  38. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +0 -0
  39. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +0 -0
  40. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +0 -0
  41. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +0 -0
  42. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +0 -0
  43. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
  44. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +0 -0
  45. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +0 -0
  46. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +0 -0
  47. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +0 -0
  48. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +0 -0
  49. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +0 -0
  50. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +0 -0
  51. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
  52. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/migrations/script.py.mako.j2 +0 -0
  53. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/tests/conftest_root.py.j2 +0 -0
  54. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/tests/contract.py.j2 +0 -0
  55. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/__init__.py +0 -0
  56. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/conftest_generator.py +0 -0
  57. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/constants.py +0 -0
  58. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/loaders.py +0 -0
  59. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/parser.py +0 -0
  60. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/quality.py +0 -0
  61. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/utils/templates.py +0 -0
  62. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/validate.py +0 -0
  63. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/__init__.py +0 -0
  64. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/__init__.py +0 -0
  65. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/clean.py +0 -0
  66. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/generate.py +0 -0
  67. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/project_setup.py +0 -0
  68. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/test_runner.py +0 -0
  69. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/menu.py +0 -0
  70. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator/wizard/prompts.py +0 -0
  71. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/SOURCES.txt +0 -0
  72. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
  73. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
  74. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/requires.txt +0 -0
  75. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/top_level.txt +0 -0
  76. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_cleanup.py +0 -0
  77. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_cli.py +0 -0
  78. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_edge_cases.py +0 -0
  79. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_enum_examples.py +0 -0
  80. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_full_generation.py +0 -0
  81. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_integration.py +0 -0
  82. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_template_utils.py +0 -0
  83. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_utils.py +0 -0
  84. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_validate.py +0 -0
  85. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_validation.py +0 -0
  86. {model_generator_kit-0.1.3 → model_generator_kit-0.1.4}/tests/test_wizard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model-generator-kit
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: One-shot bootstrap generator for database models, API models, routes, and tests
5
5
  Author-email: nuncaeslupus <imarcos@gmail.com>
6
6
  License: MIT
@@ -140,4 +140,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
140
140
 
141
141
  ---
142
142
 
143
- **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.4
@@ -104,4 +104,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
104
104
 
105
105
  ---
106
106
 
107
- **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
107
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.4
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model-generator-kit"
7
- version = "0.1.3"
7
+ version = "0.1.4"
8
8
  description = "One-shot bootstrap generator for database models, API models, routes, and tests"
9
9
  readme = "README.md"
10
10
  license = {text = "MIT"}
@@ -3,4 +3,4 @@
3
3
  # Single source of truth is pyproject.toml's [project].version. `make
4
4
  # version-sync` propagates that value here; `make check-version-sync` (run in
5
5
  # CI) fails the build if they drift. Do not edit by hand.
6
- __version__ = "0.1.3"
6
+ __version__ = "0.1.4"
@@ -210,7 +210,7 @@ class Create{{ entity_name }}Request(BaseModel):
210
210
  {% elif field.type == 'text' and db_name == 'username' %}
211
211
  "{{ field_name }}": "john_doe",
212
212
  {% elif field.type == 'text' and 'password' in db_name.lower() %}
213
- "{{ field_name }}": "SecureP@ssw0rd!",
213
+ "{{ field_name }}": "your-password-here",
214
214
  {% elif field.type == 'text' and 'name' in db_name.lower() %}
215
215
  "{{ field_name }}": "{{ db_name.replace('_', ' ').title() }}",
216
216
  {% elif field.type == 'text' %}
@@ -10,6 +10,7 @@
10
10
  {{ generated_marker() }}
11
11
  """Alembic environment configuration."""
12
12
  from logging.config import fileConfig
13
+ from typing import Any
13
14
 
14
15
  from sqlalchemy import engine_from_config
15
16
  from sqlalchemy import pool
@@ -32,6 +33,63 @@ if config.config_file_name is not None:
32
33
  from {{ config.paths.database_models | path_to_import }}.base import Base
33
34
  target_metadata = Base.metadata
34
35
 
36
+ # Project custom SQLAlchemy types live here. Autogenerate must import them into
37
+ # the migrations it writes (see render_item below), otherwise the generated
38
+ # migration references e.g. PortableUuid() without an import and fails to load.
39
+ _CUSTOM_TYPE_MODULE = "{{ config.paths.database_models.rsplit('/', 1)[0] | path_to_import }}.types"
40
+ _CUSTOM_TYPE_NAMES = ("PortableNumeric", "PortableUuid")
41
+
42
+ # Async drivers map to their sync equivalents. Alembic runs migrations
43
+ # synchronously, so an async DATABASE_URL (e.g. postgresql+asyncpg://) must be
44
+ # coerced to a sync driver before an Engine is built.
45
+ _ASYNC_TO_SYNC_DRIVERS = {
46
+ "+asyncpg": "+psycopg2",
47
+ "+aiosqlite": "",
48
+ "+aiomysql": "+pymysql",
49
+ "+asyncmy": "+pymysql",
50
+ "+aioodbc": "+pyodbc",
51
+ }
52
+
53
+
54
+ def _coerce_sync_driver(url: str) -> str:
55
+ """Return ``url`` with any known async driver in the *scheme* swapped for a
56
+ sync one. Only the scheme (before "://") is touched, so a credential or
57
+ database name that happens to contain a driver substring is never mangled.
58
+ """
59
+ scheme, sep, rest = url.partition("://")
60
+ if not sep:
61
+ return url
62
+ for async_driver, sync_driver in _ASYNC_TO_SYNC_DRIVERS.items():
63
+ if scheme.endswith(async_driver):
64
+ scheme = scheme[: -len(async_driver)] + sync_driver
65
+ break
66
+ return scheme + sep + rest
67
+
68
+
69
+ def render_item(type_: str, obj: Any, autogen_context: Any) -> str | bool:
70
+ """Render the project's custom column types with their import.
71
+
72
+ Without this, autogenerate emits ``PortableUuid()`` / ``PortableNumeric()``
73
+ in the migration body but never imports them, so the migration raises
74
+ ``NameError`` on load. Returning ``False`` falls back to Alembic's default
75
+ rendering for everything else.
76
+ """
77
+ if type_ == "type" and type(obj).__name__ in _CUSTOM_TYPE_NAMES:
78
+ cls = type(obj).__name__
79
+ autogen_context.imports.add(f"from {_CUSTOM_TYPE_MODULE} import {cls}")
80
+ if cls == "PortableNumeric":
81
+ args = [
82
+ f"{name}={value}"
83
+ for name, value in (
84
+ ("precision", obj.precision),
85
+ ("scale", obj.scale),
86
+ )
87
+ if value is not None
88
+ ]
89
+ return f"{cls}({', '.join(args)})"
90
+ return f"{cls}()"
91
+ return False
92
+
35
93
  # other values from the config, defined by the needs of env.py,
36
94
  # can be acquired:
37
95
  # my_important_option = config.get_main_option("my_important_option")
@@ -60,7 +118,7 @@ def get_url() -> str:
60
118
  "alembic.ini, must be set"
61
119
  )
62
120
  raise RuntimeError(msg)
63
- return url
121
+ return _coerce_sync_driver(url)
64
122
 
65
123
 
66
124
  def run_migrations_offline() -> None:
@@ -83,6 +141,7 @@ def run_migrations_offline() -> None:
83
141
  dialect_opts={"paramstyle": "named"},
84
142
  compare_type=True,
85
143
  compare_server_default=True,
144
+ render_item=render_item,
86
145
  )
87
146
 
88
147
  with context.begin_transaction():
@@ -113,6 +172,7 @@ def run_migrations_online() -> None:
113
172
  target_metadata=target_metadata,
114
173
  compare_type=True,
115
174
  compare_server_default=True,
175
+ render_item=render_item,
116
176
  )
117
177
 
118
178
  with context.begin_transaction():
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model-generator-kit
3
- Version: 0.1.3
3
+ Version: 0.1.4
4
4
  Summary: One-shot bootstrap generator for database models, API models, routes, and tests
5
5
  Author-email: nuncaeslupus <imarcos@gmail.com>
6
6
  License: MIT
@@ -140,4 +140,4 @@ Define specifications once in JSON. Generate production-ready scaffolds. Then ma
140
140
 
141
141
  ---
142
142
 
143
- **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.4
@@ -1105,6 +1105,36 @@ class TestApiModelsGeneratorPerEntity:
1105
1105
  assert "class UpdateAuthorRequest(BaseModel):" in author_req
1106
1106
  assert "CreatePostRequest" not in author_req
1107
1107
 
1108
+ def test_password_example_is_not_a_secret(
1109
+ self, project_env_per_entity: Any
1110
+ ) -> None:
1111
+ """The OpenAPI example for a password field must be a non-secret
1112
+ placeholder so secret scanners (GitGuardian) don't flag generated repos."""
1113
+ project_root, config, env = project_env_per_entity
1114
+ model = {
1115
+ "domain": "accounts",
1116
+ "entities": {
1117
+ "User": {
1118
+ "table": "users",
1119
+ "fields": {
1120
+ "id": {
1121
+ "type": "uuid",
1122
+ "primary_key": True,
1123
+ "auto_generate": True,
1124
+ },
1125
+ "password": {"type": "text", "required": True},
1126
+ },
1127
+ }
1128
+ },
1129
+ }
1130
+ result = generate_api_models(model, config, env, project_root)
1131
+ assert isinstance(result, list)
1132
+ content = next(
1133
+ r["content"] for r in result if r["path"].name == "user_requests.py"
1134
+ )
1135
+ assert "SecureP@ssw0rd!" not in content
1136
+ assert '"password": "your-password-here"' in content
1137
+
1108
1138
 
1109
1139
  @pytest.fixture
1110
1140
  def financial_model() -> dict[str, Any]:
@@ -3045,6 +3075,81 @@ class TestMigrationGenerator:
3045
3075
  # config.paths.database_models is used in the import path
3046
3076
  assert "src.database.models" in env_py["content"]
3047
3077
 
3078
+ def _env_py(self, minimal_model: dict[str, Any], project_env: Any) -> str:
3079
+ project_root, config, env = project_env
3080
+ result = generate_migration_init(minimal_model, config, env, project_root)
3081
+ assert isinstance(result, list)
3082
+ env_py = next(
3083
+ r for r in result if r["path"] == project_root / "alembic" / "env.py"
3084
+ )
3085
+ content = env_py["content"]
3086
+ assert isinstance(content, str)
3087
+ return content
3088
+
3089
+ def test_env_py_is_valid_python(
3090
+ self, minimal_model: dict[str, Any], project_env: Any
3091
+ ) -> None:
3092
+ """The rendered env.py must parse — guards the new helper blocks."""
3093
+ import ast
3094
+
3095
+ ast.parse(self._env_py(minimal_model, project_env))
3096
+
3097
+ def test_env_py_coerces_async_drivers_to_sync(
3098
+ self, minimal_model: dict[str, Any], project_env: Any
3099
+ ) -> None:
3100
+ """Alembic runs sync, so get_url() must coerce async drivers."""
3101
+ content = self._env_py(minimal_model, project_env)
3102
+ assert "def _coerce_sync_driver(url: str) -> str:" in content
3103
+ assert '"+asyncpg": "+psycopg2",' in content
3104
+ assert '"+aiosqlite": "",' in content
3105
+ # get_url returns the coerced URL, not the raw one.
3106
+ assert "return _coerce_sync_driver(url)" in content
3107
+ # Only the scheme is rewritten, so credentials/db names are never mangled.
3108
+ assert 'scheme, sep, rest = url.partition("://")' in content
3109
+ assert "scheme.endswith(async_driver)" in content
3110
+
3111
+ def test_coerce_sync_driver_behaviour(
3112
+ self, minimal_model: dict[str, Any], project_env: Any
3113
+ ) -> None:
3114
+ """Exercise the generated _coerce_sync_driver in isolation (executing
3115
+ the whole module would run migrations)."""
3116
+ import ast
3117
+
3118
+ content = self._env_py(minimal_model, project_env)
3119
+ tree = ast.parse(content)
3120
+ wanted = {"_ASYNC_TO_SYNC_DRIVERS", "_coerce_sync_driver"}
3121
+ nodes: list[ast.stmt] = [
3122
+ n
3123
+ for n in tree.body
3124
+ if (isinstance(n, ast.FunctionDef) and n.name in wanted)
3125
+ or (
3126
+ isinstance(n, ast.Assign)
3127
+ and any(getattr(t, "id", None) in wanted for t in n.targets)
3128
+ )
3129
+ ]
3130
+ ns: dict[str, Any] = {}
3131
+ exec(compile(ast.Module(body=nodes, type_ignores=[]), "<env>", "exec"), ns)
3132
+ coerce = ns["_coerce_sync_driver"]
3133
+ assert (
3134
+ coerce("postgresql+asyncpg://u:p@h/db") == "postgresql+psycopg2://u:p@h/db"
3135
+ )
3136
+ assert coerce("sqlite+aiosqlite:///x.db") == "sqlite:///x.db"
3137
+ # A driver substring inside credentials must NOT be rewritten.
3138
+ assert coerce("postgresql://u:+asyncpg@h/db") == "postgresql://u:+asyncpg@h/db"
3139
+ # Already-sync URLs pass through untouched.
3140
+ assert coerce("postgresql://u:p@h/db") == "postgresql://u:p@h/db"
3141
+
3142
+ def test_env_py_renders_custom_types_with_import(
3143
+ self, minimal_model: dict[str, Any], project_env: Any
3144
+ ) -> None:
3145
+ """render_item must be wired into both configure() calls and import
3146
+ the project's custom column types into generated migrations."""
3147
+ content = self._env_py(minimal_model, project_env)
3148
+ assert "def render_item(type_: str, obj: Any, autogen_context: Any)" in content
3149
+ assert '_CUSTOM_TYPE_MODULE = "src.database.types"' in content
3150
+ # Wired into offline and online configure() calls.
3151
+ assert content.count("render_item=render_item,") == 2
3152
+
3048
3153
  def test_custom_migrations_path(
3049
3154
  self, minimal_model: dict[str, Any], project_env: Any
3050
3155
  ) -> None: