model-generator-kit 0.1.2__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.2 → model_generator_kit-0.1.4}/PKG-INFO +2 -2
  2. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/README.md +1 -1
  3. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/pyproject.toml +1 -1
  4. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/__init__.py +1 -1
  5. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/request.py.j2 +22 -22
  6. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/response.py.j2 +14 -2
  7. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +17 -0
  8. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +61 -1
  9. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
  10. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_generators.py +292 -0
  11. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/LICENSE +0 -0
  12. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/setup.cfg +0 -0
  13. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generate.py +0 -0
  14. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/__init__.py +0 -0
  15. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/api.py +0 -0
  16. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/constraints.py +0 -0
  17. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/database.py +0 -0
  18. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/enums.py +0 -0
  19. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/infrastructure.py +0 -0
  20. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/generators/migrations.py +0 -0
  21. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/py.typed +0 -0
  22. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/schema/model.schema.json +0 -0
  23. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/config.yaml +0 -0
  24. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
  25. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
  26. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
  27. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
  28. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
  29. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/init.py.j2 +0 -0
  30. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +0 -0
  31. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/api/route.py.j2 +0 -0
  32. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +0 -0
  33. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +0 -0
  34. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +0 -0
  35. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/init.py.j2 +0 -0
  36. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/database/model.py.j2 +0 -0
  37. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +0 -0
  38. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +0 -0
  39. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +0 -0
  40. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +0 -0
  41. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +0 -0
  42. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +0 -0
  43. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +0 -0
  44. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
  45. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +0 -0
  46. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +0 -0
  47. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +0 -0
  48. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +0 -0
  49. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +0 -0
  50. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +0 -0
  51. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
  52. {model_generator_kit-0.1.2 → 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.2 → 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.2 → 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.2 → model_generator_kit-0.1.4}/src/model_generator/utils/__init__.py +0 -0
  56. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/conftest_generator.py +0 -0
  57. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/constants.py +0 -0
  58. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/loaders.py +0 -0
  59. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/parser.py +0 -0
  60. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/quality.py +0 -0
  61. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/utils/templates.py +0 -0
  62. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/validate.py +0 -0
  63. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/__init__.py +0 -0
  64. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/__init__.py +0 -0
  65. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/clean.py +0 -0
  66. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/generate.py +0 -0
  67. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/project_setup.py +0 -0
  68. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/actions/test_runner.py +0 -0
  69. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/menu.py +0 -0
  70. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator/wizard/prompts.py +0 -0
  71. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/SOURCES.txt +0 -0
  72. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
  73. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
  74. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/requires.txt +0 -0
  75. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/src/model_generator_kit.egg-info/top_level.txt +0 -0
  76. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_cleanup.py +0 -0
  77. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_cli.py +0 -0
  78. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_edge_cases.py +0 -0
  79. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_enum_examples.py +0 -0
  80. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_full_generation.py +0 -0
  81. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_integration.py +0 -0
  82. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_template_utils.py +0 -0
  83. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_utils.py +0 -0
  84. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_validate.py +0 -0
  85. {model_generator_kit-0.1.2 → model_generator_kit-0.1.4}/tests/test_validation.py +0 -0
  86. {model_generator_kit-0.1.2 → 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.2
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.2
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.2
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.2"
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.2"
6
+ __version__ = "0.1.4"
@@ -57,11 +57,13 @@ This module contains:
57
57
  {% endif %}
58
58
  {#- Check for financial/counter fields with constraints needing validators -#}
59
59
  {% if not field.api_exclude_create | default(false) or not field.api_exclude_update | default(false) %}
60
- {% if field.type == 'financial' and field.constraints is defined %}
61
- {% for c in field.constraints %}
62
- {% if c.type in ['non_negative', 'non_negative_or_null'] and 'validate_non_negative_decimal' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_non_negative_decimal') %}{% endif %}
63
- {% if c.type in ['positive', 'positive_or_null'] and 'validate_positive_decimal' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_positive_decimal') %}{% endif %}
64
- {% endfor %}
60
+ {% if field.type == 'financial' %}
61
+ {#- Choose validator by declared constraint: non-negative when constrained, signed otherwise -#}
62
+ {% set ctypes = (field.constraints | map(attribute='type') | list) if (field.constraints is defined and field.constraints) else [] %}
63
+ {% if 'non_negative' in ctypes or 'non_negative_or_null' in ctypes %}{% set fin_validator = 'validate_non_negative_decimal' %}
64
+ {% elif 'positive' in ctypes or 'positive_or_null' in ctypes %}{% set fin_validator = 'validate_positive_decimal' %}
65
+ {% else %}{% set fin_validator = 'validate_decimal' %}{% endif %}
66
+ {% if fin_validator not in ns.needs_validators %}{% set _ = ns.needs_validators.append(fin_validator) %}{% endif %}
65
67
  {% elif field.type == 'percentage' %}
66
68
  {% if 'validate_percentage' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_percentage') %}{% endif %}
67
69
  {% elif field.type == 'counter' and field.constraints is defined %}
@@ -208,7 +210,7 @@ class Create{{ entity_name }}Request(BaseModel):
208
210
  {% elif field.type == 'text' and db_name == 'username' %}
209
211
  "{{ field_name }}": "john_doe",
210
212
  {% elif field.type == 'text' and 'password' in db_name.lower() %}
211
- "{{ field_name }}": "SecureP@ssw0rd!",
213
+ "{{ field_name }}": "your-password-here",
212
214
  {% elif field.type == 'text' and 'name' in db_name.lower() %}
213
215
  "{{ field_name }}": "{{ db_name.replace('_', ' ').title() }}",
214
216
  {% elif field.type == 'text' %}
@@ -271,14 +273,13 @@ class Create{{ entity_name }}Request(BaseModel):
271
273
  {#- Collect constraint-driven validators (financial, percentage, counter) -#}
272
274
  {% for item in create_fields %}
273
275
  {% set field = item.field %}
274
- {% if field.type == 'financial' and field.constraints is defined %}
275
- {% for c in field.constraints %}
276
- {% if c.type in ['non_negative', 'non_negative_or_null'] %}
277
- {% set _ = field_validators.append({'name': item.name, 'validator': 'validate_non_negative_decimal'}) %}
278
- {% elif c.type in ['positive', 'positive_or_null'] %}
279
- {% set _ = field_validators.append({'name': item.name, 'validator': 'validate_positive_decimal'}) %}
280
- {% endif %}
281
- {% endfor %}
276
+ {% if field.type == 'financial' %}
277
+ {#- Choose validator by declared constraint: non-negative when constrained, signed otherwise -#}
278
+ {% set ctypes = (field.constraints | map(attribute='type') | list) if (field.constraints is defined and field.constraints) else [] %}
279
+ {% if 'non_negative' in ctypes or 'non_negative_or_null' in ctypes %}{% set fin_validator = 'validate_non_negative_decimal' %}
280
+ {% elif 'positive' in ctypes or 'positive_or_null' in ctypes %}{% set fin_validator = 'validate_positive_decimal' %}
281
+ {% else %}{% set fin_validator = 'validate_decimal' %}{% endif %}
282
+ {% set _ = field_validators.append({'name': item.name, 'validator': fin_validator}) %}
282
283
  {% elif field.type == 'percentage' %}
283
284
  {% set _ = field_validators.append({'name': item.name, 'validator': 'validate_percentage'}) %}
284
285
  {% elif field.type == 'counter' and field.constraints is defined %}
@@ -416,14 +417,13 @@ class Update{{ entity_name }}Request(BaseModel):
416
417
  {% set update_validators = [] %}
417
418
  {% for item in update_fields %}
418
419
  {% set field = item.field %}
419
- {% if field.type == 'financial' and field.constraints is defined %}
420
- {% for c in field.constraints %}
421
- {% if c.type in ['non_negative', 'non_negative_or_null'] %}
422
- {% set _ = update_validators.append({'name': item.name, 'validator': 'validate_non_negative_decimal'}) %}
423
- {% elif c.type in ['positive', 'positive_or_null'] %}
424
- {% set _ = update_validators.append({'name': item.name, 'validator': 'validate_positive_decimal'}) %}
425
- {% endif %}
426
- {% endfor %}
420
+ {% if field.type == 'financial' %}
421
+ {#- Choose validator by declared constraint: non-negative when constrained, signed otherwise -#}
422
+ {% set ctypes = (field.constraints | map(attribute='type') | list) if (field.constraints is defined and field.constraints) else [] %}
423
+ {% if 'non_negative' in ctypes or 'non_negative_or_null' in ctypes %}{% set fin_validator = 'validate_non_negative_decimal' %}
424
+ {% elif 'positive' in ctypes or 'positive_or_null' in ctypes %}{% set fin_validator = 'validate_positive_decimal' %}
425
+ {% else %}{% set fin_validator = 'validate_decimal' %}{% endif %}
426
+ {% set _ = update_validators.append({'name': item.name, 'validator': fin_validator}) %}
427
427
  {% elif field.type == 'percentage' %}
428
428
  {% set _ = update_validators.append({'name': item.name, 'validator': 'validate_percentage'}) %}
429
429
  {% elif field.type == 'counter' and field.constraints is defined %}
@@ -26,7 +26,14 @@ This module contains:
26
26
  {% if not field.api_exclude_response | default(false) %}
27
27
  {% if field.type in ['json_object', 'json_array'] %}{% set ns.has_any = true %}{% endif %}
28
28
  {% if field.type == 'enum' and field.enum_name not in ns.enum_imports %}{% set _ = ns.enum_imports.append(field.enum_name) %}{% endif %}
29
- {% if field.type == 'financial' and 'validate_non_negative_decimal' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_non_negative_decimal') %}{% endif %}
29
+ {% if field.type == 'financial' %}
30
+ {#- Choose validator by declared constraint: non-negative when constrained, signed otherwise -#}
31
+ {% set ctypes = (field.constraints | map(attribute='type') | list) if (field.constraints is defined and field.constraints) else [] %}
32
+ {% if 'non_negative' in ctypes or 'non_negative_or_null' in ctypes %}{% set fin_validator = 'validate_non_negative_decimal' %}
33
+ {% elif 'positive' in ctypes or 'positive_or_null' in ctypes %}{% set fin_validator = 'validate_positive_decimal' %}
34
+ {% else %}{% set fin_validator = 'validate_decimal' %}{% endif %}
35
+ {% if fin_validator not in ns.needs_validators %}{% set _ = ns.needs_validators.append(fin_validator) %}{% endif %}
36
+ {% endif %}
30
37
  {% if field.type == 'percentage' and 'validate_percentage' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_percentage') %}{% endif %}
31
38
  {% if field.type == 'counter' and 'validate_non_negative_int' not in ns.needs_validators %}{% set _ = ns.needs_validators.append('validate_non_negative_int') %}{% endif %}
32
39
  {% endif %}
@@ -173,7 +180,12 @@ class {{ entity_name }}Response(BaseModel):
173
180
  {% for field_name, field in entity.fields.items() %}
174
181
  {% if not field.api_exclude_response | default(false) %}
175
182
  {% if field.type == 'financial' %}
176
- {% set _ = validated_fields.append({'name': field_name, 'validator': 'validate_non_negative_decimal'}) %}
183
+ {#- Choose validator by declared constraint: non-negative when constrained, signed otherwise -#}
184
+ {% set ctypes = (field.constraints | map(attribute='type') | list) if (field.constraints is defined and field.constraints) else [] %}
185
+ {% if 'non_negative' in ctypes or 'non_negative_or_null' in ctypes %}{% set fin_validator = 'validate_non_negative_decimal' %}
186
+ {% elif 'positive' in ctypes or 'positive_or_null' in ctypes %}{% set fin_validator = 'validate_positive_decimal' %}
187
+ {% else %}{% set fin_validator = 'validate_decimal' %}{% endif %}
188
+ {% set _ = validated_fields.append({'name': field_name, 'validator': fin_validator}) %}
177
189
  {% elif field.type == 'percentage' %}
178
190
  {% set _ = validated_fields.append({'name': field_name, 'validator': 'validate_percentage'}) %}
179
191
  {% elif field.type == 'counter' %}
@@ -23,6 +23,23 @@ from typing import Any
23
23
  {{ section_divider('DECIMAL VALIDATORS') }}
24
24
 
25
25
 
26
+ def validate_decimal(value: Any) -> str | None:
27
+ """Validate that a value is a well-formed decimal (sign-agnostic).
28
+
29
+ Accepts negative, zero, and positive values. Use this for signed
30
+ financial fields (e.g. PnL, returns) that may legitimately be negative.
31
+ """
32
+ if value is None:
33
+ return value
34
+ try:
35
+ decimal_value = Decimal(str(value))
36
+ except InvalidOperation:
37
+ raise ValueError("Invalid decimal value") from None
38
+ if not decimal_value.is_finite():
39
+ raise ValueError("Value must be a finite decimal")
40
+ return str(value)
41
+
42
+
26
43
  def validate_non_negative_decimal(value: Any) -> str | None:
27
44
  """Validate that a decimal value is non-negative (>= 0)."""
28
45
  if value is None:
@@ -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.2
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.2
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.4
@@ -1105,6 +1105,223 @@ 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
+
1138
+
1139
+ @pytest.fixture
1140
+ def financial_model() -> dict[str, Any]:
1141
+ """Single entity exercising signed vs constrained financial fields.
1142
+
1143
+ - unrealized_pnl: no constraint -> signed -> validate_decimal
1144
+ - balance: non_negative -> validate_non_negative_decimal
1145
+ - deposit: positive -> validate_positive_decimal
1146
+ """
1147
+ return {
1148
+ "domain": "ledger",
1149
+ "description": "Ledger domain with signed and constrained money",
1150
+ "entities": {
1151
+ "Account": {
1152
+ "table": "accounts",
1153
+ "description": "Trading account",
1154
+ "fields": {
1155
+ "id": {
1156
+ "type": "uuid",
1157
+ "primary_key": True,
1158
+ "auto_generate": True,
1159
+ },
1160
+ "unrealized_pnl": {
1161
+ "type": "financial",
1162
+ "required": True,
1163
+ },
1164
+ "balance": {
1165
+ "type": "financial",
1166
+ "required": True,
1167
+ "constraints": [{"type": "non_negative"}],
1168
+ },
1169
+ "deposit": {
1170
+ "type": "financial",
1171
+ "required": True,
1172
+ "constraints": [{"type": "positive"}],
1173
+ },
1174
+ },
1175
+ "timestamps": {"created": True, "updated": True},
1176
+ },
1177
+ },
1178
+ }
1179
+
1180
+
1181
+ class TestFinancialValidatorSelection:
1182
+ """Signed financial fields must not be forced non-negative.
1183
+
1184
+ Regression: the response/request templates hardcoded
1185
+ validate_non_negative_decimal for every financial field, so any
1186
+ legitimately negative value (PnL, returns) failed validation.
1187
+ """
1188
+
1189
+ def _content(self, model: dict[str, Any], env_fixture: Any, suffix: str) -> str:
1190
+ project_root, config, env = env_fixture
1191
+ result = generate_api_models(model, config, env, project_root)
1192
+ assert isinstance(result, list)
1193
+ content = next(r["content"] for r in result if r["path"].name.endswith(suffix))
1194
+ assert isinstance(content, str)
1195
+ return content
1196
+
1197
+ def test_response_signed_financial_uses_validate_decimal(
1198
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1199
+ ) -> None:
1200
+ content = self._content(
1201
+ financial_model, project_env_per_entity, "account_response.py"
1202
+ )
1203
+ # Unconstrained financial -> signed validator
1204
+ assert (
1205
+ '_validate_unrealized_pnl = field_validator("unrealized_pnl")'
1206
+ "(validate_decimal)" in content
1207
+ )
1208
+ # Constrained financial -> stays non-negative / positive
1209
+ assert (
1210
+ '_validate_balance = field_validator("balance")'
1211
+ "(validate_non_negative_decimal)" in content
1212
+ )
1213
+ assert (
1214
+ '_validate_deposit = field_validator("deposit")'
1215
+ "(validate_positive_decimal)" in content
1216
+ )
1217
+
1218
+ def test_response_imports_match_validators_used(
1219
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1220
+ ) -> None:
1221
+ content = self._content(
1222
+ financial_model, project_env_per_entity, "account_response.py"
1223
+ )
1224
+ import_line = next(
1225
+ line
1226
+ for line in content.splitlines()
1227
+ if line.startswith("from") and "validators import" in line
1228
+ )
1229
+ assert "validate_decimal" in import_line
1230
+ assert "validate_non_negative_decimal" in import_line
1231
+ assert "validate_positive_decimal" in import_line
1232
+
1233
+ def test_request_signed_financial_uses_validate_decimal(
1234
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1235
+ ) -> None:
1236
+ content = self._content(
1237
+ financial_model, project_env_per_entity, "account_requests.py"
1238
+ )
1239
+ assert (
1240
+ '_validate_unrealized_pnl = field_validator("unrealized_pnl")'
1241
+ "(validate_decimal)" in content
1242
+ )
1243
+ assert (
1244
+ '_validate_balance = field_validator("balance")'
1245
+ "(validate_non_negative_decimal)" in content
1246
+ )
1247
+ assert (
1248
+ '_validate_deposit = field_validator("deposit")'
1249
+ "(validate_positive_decimal)" in content
1250
+ )
1251
+
1252
+ def test_request_imports_match_validators_used(
1253
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1254
+ ) -> None:
1255
+ content = self._content(
1256
+ financial_model, project_env_per_entity, "account_requests.py"
1257
+ )
1258
+ import_line = next(
1259
+ line
1260
+ for line in content.splitlines()
1261
+ if line.startswith("from") and "validators import" in line
1262
+ )
1263
+ assert "validate_decimal" in import_line
1264
+ assert "validate_non_negative_decimal" in import_line
1265
+ assert "validate_positive_decimal" in import_line
1266
+
1267
+ def test_validators_module_defines_validate_decimal(
1268
+ self, project_env_per_entity: Any
1269
+ ) -> None:
1270
+ project_root, config, env = project_env_per_entity
1271
+ result = generate_validators(config, env, project_root)
1272
+ assert result is not None
1273
+ assert "def validate_decimal(value: Any) -> str | None:" in result["content"]
1274
+
1275
+ def test_validate_decimal_rejects_non_finite(
1276
+ self, project_env_per_entity: Any
1277
+ ) -> None:
1278
+ """The emitted validate_decimal must reject NaN/Infinity, not just
1279
+ malformed strings (financial fields cannot store non-finite values)."""
1280
+ project_root, config, env = project_env_per_entity
1281
+ result = generate_validators(config, env, project_root)
1282
+ assert result is not None
1283
+ ns: dict[str, Any] = {}
1284
+ exec(result["content"], ns)
1285
+ validate_decimal = ns["validate_decimal"]
1286
+ # Signed finite values pass.
1287
+ assert validate_decimal("-12.5") == "-12.5"
1288
+ assert validate_decimal(None) is None
1289
+ # Non-finite values are rejected.
1290
+ for bad in ("NaN", "Infinity", "-Infinity", "sNaN"):
1291
+ with pytest.raises(ValueError):
1292
+ validate_decimal(bad)
1293
+ with pytest.raises(ValueError):
1294
+ validate_decimal("not-a-number")
1295
+
1296
+ def test_empty_constraints_fall_back_to_signed(
1297
+ self, project_env_per_entity: Any
1298
+ ) -> None:
1299
+ """A financial field with an empty (falsy) `constraints` list falls
1300
+ back to the signed default rather than crashing on the `map` filter."""
1301
+ model = {
1302
+ "domain": "ledger",
1303
+ "entities": {
1304
+ "Account": {
1305
+ "table": "accounts",
1306
+ "fields": {
1307
+ "id": {
1308
+ "type": "uuid",
1309
+ "primary_key": True,
1310
+ "auto_generate": True,
1311
+ },
1312
+ "pnl": {
1313
+ "type": "financial",
1314
+ "required": True,
1315
+ "constraints": [],
1316
+ },
1317
+ },
1318
+ }
1319
+ },
1320
+ }
1321
+ for suffix in ("account_response.py", "account_requests.py"):
1322
+ content = self._content(model, project_env_per_entity, suffix)
1323
+ assert '_validate_pnl = field_validator("pnl")(validate_decimal)' in content
1324
+
1108
1325
 
1109
1326
  class TestGenerateApiInitPerEntity:
1110
1327
  """Per-entity api __init__.py emission."""
@@ -2858,6 +3075,81 @@ class TestMigrationGenerator:
2858
3075
  # config.paths.database_models is used in the import path
2859
3076
  assert "src.database.models" in env_py["content"]
2860
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
+
2861
3153
  def test_custom_migrations_path(
2862
3154
  self, minimal_model: dict[str, Any], project_env: Any
2863
3155
  ) -> None: