model-generator-kit 0.1.1__tar.gz → 0.1.3__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.1 → model_generator_kit-0.1.3}/PKG-INFO +2 -2
  2. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/README.md +1 -1
  3. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/pyproject.toml +1 -1
  4. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/__init__.py +1 -1
  5. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/api/request.py.j2 +21 -21
  6. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/api/response.py.j2 +14 -2
  7. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/api/route.py.j2 +7 -1
  8. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +20 -3
  9. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +17 -0
  10. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
  11. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_generators.py +353 -0
  12. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/LICENSE +0 -0
  13. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/setup.cfg +0 -0
  14. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generate.py +0 -0
  15. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/__init__.py +0 -0
  16. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/api.py +0 -0
  17. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/constraints.py +0 -0
  18. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/database.py +0 -0
  19. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/enums.py +0 -0
  20. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/infrastructure.py +0 -0
  21. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/generators/migrations.py +0 -0
  22. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/py.typed +0 -0
  23. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/schema/model.schema.json +0 -0
  24. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/config.yaml +0 -0
  25. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
  26. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
  27. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
  28. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
  29. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
  30. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/api/init.py.j2 +0 -0
  31. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +0 -0
  32. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +0 -0
  33. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +0 -0
  34. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +0 -0
  35. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/database/init.py.j2 +0 -0
  36. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/database/model.py.j2 +0 -0
  37. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +0 -0
  38. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +0 -0
  39. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +0 -0
  40. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +0 -0
  41. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +0 -0
  42. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +0 -0
  43. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +0 -0
  44. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
  45. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +0 -0
  46. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +0 -0
  47. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +0 -0
  48. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +0 -0
  49. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +0 -0
  50. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/migrations/env.py.j2 +0 -0
  51. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
  52. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/migrations/script.py.mako.j2 +0 -0
  53. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/tests/conftest_root.py.j2 +0 -0
  54. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/stacks/python-fastapi/templates/tests/contract.py.j2 +0 -0
  55. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/__init__.py +0 -0
  56. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/conftest_generator.py +0 -0
  57. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/constants.py +0 -0
  58. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/loaders.py +0 -0
  59. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/parser.py +0 -0
  60. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/quality.py +0 -0
  61. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/utils/templates.py +0 -0
  62. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/validate.py +0 -0
  63. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/__init__.py +0 -0
  64. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/actions/__init__.py +0 -0
  65. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/actions/clean.py +0 -0
  66. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/actions/generate.py +0 -0
  67. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/actions/project_setup.py +0 -0
  68. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/actions/test_runner.py +0 -0
  69. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/menu.py +0 -0
  70. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator/wizard/prompts.py +0 -0
  71. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/SOURCES.txt +0 -0
  72. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
  73. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
  74. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/requires.txt +0 -0
  75. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/src/model_generator_kit.egg-info/top_level.txt +0 -0
  76. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_cleanup.py +0 -0
  77. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_cli.py +0 -0
  78. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_edge_cases.py +0 -0
  79. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_enum_examples.py +0 -0
  80. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_full_generation.py +0 -0
  81. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_integration.py +0 -0
  82. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_template_utils.py +0 -0
  83. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_utils.py +0 -0
  84. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_validate.py +0 -0
  85. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/tests/test_validation.py +0 -0
  86. {model_generator_kit-0.1.1 → model_generator_kit-0.1.3}/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.1
3
+ Version: 0.1.3
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.1
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
@@ -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.1
107
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "model-generator-kit"
7
- version = "0.1.1"
7
+ version = "0.1.3"
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.1"
6
+ __version__ = "0.1.3"
@@ -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 %}
@@ -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' %}
@@ -75,7 +75,7 @@ All endpoints follow RESTful conventions and return standardized error responses
75
75
  import bcrypt
76
76
  {% endif %}
77
77
  {% if ns.has_datetime %}
78
- from datetime import datetime
78
+ from datetime import datetime, timezone
79
79
  {% endif %}
80
80
  {% if ns.has_financial or ns.has_percentage %}
81
81
  from decimal import Decimal
@@ -225,9 +225,15 @@ async def list_{{ entity_plural }}(
225
225
  count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} == {{ field_name }})
226
226
  {% elif field.type == 'datetime' %}
227
227
  if {{ field_name }}_after is not None:
228
+ # Localize a naive value to UTC: the column is tz-aware, and comparing
229
+ # it against a naive datetime raises on strict drivers (asyncpg/psycopg2).
230
+ if {{ field_name }}_after.tzinfo is None:
231
+ {{ field_name }}_after = {{ field_name }}_after.replace(tzinfo=timezone.utc)
228
232
  stmt = stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_after)
229
233
  count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} >= {{ field_name }}_after)
230
234
  if {{ field_name }}_before is not None:
235
+ if {{ field_name }}_before.tzinfo is None:
236
+ {{ field_name }}_before = {{ field_name }}_before.replace(tzinfo=timezone.utc)
231
237
  stmt = stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_before)
232
238
  count_stmt = count_stmt.where({{ entity_name }}.{{ field_name }} <= {{ field_name }}_before)
233
239
  {% elif field.type in ['financial', 'percentage'] %}
@@ -97,11 +97,28 @@ class RequestBodySizeLimitMiddleware:
97
97
 
98
98
 
99
99
  def _content_length(scope: Scope) -> int | None:
100
- """Return the request's Content-Length as an int, or None if absent/invalid."""
100
+ """Return the request's Content-Length as an int, or None if absent/invalid.
101
+
102
+ A declared length is treated as invalid (returns None) — forcing the caller
103
+ onto the chunked byte-counting path, which is safe regardless of any header
104
+ — when it is negative or when *more than one* Content-Length header is
105
+ present. Multiple Content-Length headers are a request-smuggling signal: a
106
+ downstream server or proxy might honor a different one than this middleware,
107
+ so an oversized request could slip past a guard keyed on the first value.
108
+ Compliant servers reject both cases at the protocol layer, but the
109
+ middleware must not rely on that pre-filtering — defense-in-depth.
110
+ """
111
+ found: int | None = None
101
112
  for name, value in scope.get("headers", []):
102
113
  if name == b"content-length":
114
+ if found is not None:
115
+ # Duplicate Content-Length headers: force the chunked path.
116
+ return None
103
117
  try:
104
- return int(value)
118
+ n = int(value)
105
119
  except ValueError:
106
120
  return None
107
- return None
121
+ if n < 0:
122
+ return None
123
+ found = n
124
+ return found
@@ -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:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: model-generator-kit
3
- Version: 0.1.1
3
+ Version: 0.1.3
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.1
143
+ **Model Generator** | Bootstrap Tool for API Backends | v0.1.3
@@ -1106,6 +1106,193 @@ class TestApiModelsGeneratorPerEntity:
1106
1106
  assert "CreatePostRequest" not in author_req
1107
1107
 
1108
1108
 
1109
+ @pytest.fixture
1110
+ def financial_model() -> dict[str, Any]:
1111
+ """Single entity exercising signed vs constrained financial fields.
1112
+
1113
+ - unrealized_pnl: no constraint -> signed -> validate_decimal
1114
+ - balance: non_negative -> validate_non_negative_decimal
1115
+ - deposit: positive -> validate_positive_decimal
1116
+ """
1117
+ return {
1118
+ "domain": "ledger",
1119
+ "description": "Ledger domain with signed and constrained money",
1120
+ "entities": {
1121
+ "Account": {
1122
+ "table": "accounts",
1123
+ "description": "Trading account",
1124
+ "fields": {
1125
+ "id": {
1126
+ "type": "uuid",
1127
+ "primary_key": True,
1128
+ "auto_generate": True,
1129
+ },
1130
+ "unrealized_pnl": {
1131
+ "type": "financial",
1132
+ "required": True,
1133
+ },
1134
+ "balance": {
1135
+ "type": "financial",
1136
+ "required": True,
1137
+ "constraints": [{"type": "non_negative"}],
1138
+ },
1139
+ "deposit": {
1140
+ "type": "financial",
1141
+ "required": True,
1142
+ "constraints": [{"type": "positive"}],
1143
+ },
1144
+ },
1145
+ "timestamps": {"created": True, "updated": True},
1146
+ },
1147
+ },
1148
+ }
1149
+
1150
+
1151
+ class TestFinancialValidatorSelection:
1152
+ """Signed financial fields must not be forced non-negative.
1153
+
1154
+ Regression: the response/request templates hardcoded
1155
+ validate_non_negative_decimal for every financial field, so any
1156
+ legitimately negative value (PnL, returns) failed validation.
1157
+ """
1158
+
1159
+ def _content(self, model: dict[str, Any], env_fixture: Any, suffix: str) -> str:
1160
+ project_root, config, env = env_fixture
1161
+ result = generate_api_models(model, config, env, project_root)
1162
+ assert isinstance(result, list)
1163
+ content = next(r["content"] for r in result if r["path"].name.endswith(suffix))
1164
+ assert isinstance(content, str)
1165
+ return content
1166
+
1167
+ def test_response_signed_financial_uses_validate_decimal(
1168
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1169
+ ) -> None:
1170
+ content = self._content(
1171
+ financial_model, project_env_per_entity, "account_response.py"
1172
+ )
1173
+ # Unconstrained financial -> signed validator
1174
+ assert (
1175
+ '_validate_unrealized_pnl = field_validator("unrealized_pnl")'
1176
+ "(validate_decimal)" in content
1177
+ )
1178
+ # Constrained financial -> stays non-negative / positive
1179
+ assert (
1180
+ '_validate_balance = field_validator("balance")'
1181
+ "(validate_non_negative_decimal)" in content
1182
+ )
1183
+ assert (
1184
+ '_validate_deposit = field_validator("deposit")'
1185
+ "(validate_positive_decimal)" in content
1186
+ )
1187
+
1188
+ def test_response_imports_match_validators_used(
1189
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1190
+ ) -> None:
1191
+ content = self._content(
1192
+ financial_model, project_env_per_entity, "account_response.py"
1193
+ )
1194
+ import_line = next(
1195
+ line
1196
+ for line in content.splitlines()
1197
+ if line.startswith("from") and "validators import" in line
1198
+ )
1199
+ assert "validate_decimal" in import_line
1200
+ assert "validate_non_negative_decimal" in import_line
1201
+ assert "validate_positive_decimal" in import_line
1202
+
1203
+ def test_request_signed_financial_uses_validate_decimal(
1204
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1205
+ ) -> None:
1206
+ content = self._content(
1207
+ financial_model, project_env_per_entity, "account_requests.py"
1208
+ )
1209
+ assert (
1210
+ '_validate_unrealized_pnl = field_validator("unrealized_pnl")'
1211
+ "(validate_decimal)" in content
1212
+ )
1213
+ assert (
1214
+ '_validate_balance = field_validator("balance")'
1215
+ "(validate_non_negative_decimal)" in content
1216
+ )
1217
+ assert (
1218
+ '_validate_deposit = field_validator("deposit")'
1219
+ "(validate_positive_decimal)" in content
1220
+ )
1221
+
1222
+ def test_request_imports_match_validators_used(
1223
+ self, financial_model: dict[str, Any], project_env_per_entity: Any
1224
+ ) -> None:
1225
+ content = self._content(
1226
+ financial_model, project_env_per_entity, "account_requests.py"
1227
+ )
1228
+ import_line = next(
1229
+ line
1230
+ for line in content.splitlines()
1231
+ if line.startswith("from") and "validators import" in line
1232
+ )
1233
+ assert "validate_decimal" in import_line
1234
+ assert "validate_non_negative_decimal" in import_line
1235
+ assert "validate_positive_decimal" in import_line
1236
+
1237
+ def test_validators_module_defines_validate_decimal(
1238
+ self, project_env_per_entity: Any
1239
+ ) -> None:
1240
+ project_root, config, env = project_env_per_entity
1241
+ result = generate_validators(config, env, project_root)
1242
+ assert result is not None
1243
+ assert "def validate_decimal(value: Any) -> str | None:" in result["content"]
1244
+
1245
+ def test_validate_decimal_rejects_non_finite(
1246
+ self, project_env_per_entity: Any
1247
+ ) -> None:
1248
+ """The emitted validate_decimal must reject NaN/Infinity, not just
1249
+ malformed strings (financial fields cannot store non-finite values)."""
1250
+ project_root, config, env = project_env_per_entity
1251
+ result = generate_validators(config, env, project_root)
1252
+ assert result is not None
1253
+ ns: dict[str, Any] = {}
1254
+ exec(result["content"], ns)
1255
+ validate_decimal = ns["validate_decimal"]
1256
+ # Signed finite values pass.
1257
+ assert validate_decimal("-12.5") == "-12.5"
1258
+ assert validate_decimal(None) is None
1259
+ # Non-finite values are rejected.
1260
+ for bad in ("NaN", "Infinity", "-Infinity", "sNaN"):
1261
+ with pytest.raises(ValueError):
1262
+ validate_decimal(bad)
1263
+ with pytest.raises(ValueError):
1264
+ validate_decimal("not-a-number")
1265
+
1266
+ def test_empty_constraints_fall_back_to_signed(
1267
+ self, project_env_per_entity: Any
1268
+ ) -> None:
1269
+ """A financial field with an empty (falsy) `constraints` list falls
1270
+ back to the signed default rather than crashing on the `map` filter."""
1271
+ model = {
1272
+ "domain": "ledger",
1273
+ "entities": {
1274
+ "Account": {
1275
+ "table": "accounts",
1276
+ "fields": {
1277
+ "id": {
1278
+ "type": "uuid",
1279
+ "primary_key": True,
1280
+ "auto_generate": True,
1281
+ },
1282
+ "pnl": {
1283
+ "type": "financial",
1284
+ "required": True,
1285
+ "constraints": [],
1286
+ },
1287
+ },
1288
+ }
1289
+ },
1290
+ }
1291
+ for suffix in ("account_response.py", "account_requests.py"):
1292
+ content = self._content(model, project_env_per_entity, suffix)
1293
+ assert '_validate_pnl = field_validator("pnl")(validate_decimal)' in content
1294
+
1295
+
1109
1296
  class TestGenerateApiInitPerEntity:
1110
1297
  """Per-entity api __init__.py emission."""
1111
1298
 
@@ -1652,6 +1839,58 @@ class TestApiRoutesFilterCoercion:
1652
1839
  assert "from datetime import datetime" in content
1653
1840
 
1654
1841
 
1842
+ class TestApiRoutesDatetimeFilterTzAware:
1843
+ """Naive datetime filter values are localized to UTC before comparison.
1844
+
1845
+ A ``datetime | None`` filter parses input without a tz offset (e.g.
1846
+ ``2026-06-11T12:00:00``) as a naive datetime; compared against a tz-aware
1847
+ ``DateTime(timezone=True)`` column it raises ``TypeError``/``DataError`` on
1848
+ strict drivers (asyncpg/psycopg2). The handler localizes a naive value to
1849
+ UTC first. Latent (SQLite suites don't enforce tz-awareness), not a
1850
+ regression — fixed uniformly for both ``_after`` and ``_before``.
1851
+ """
1852
+
1853
+ def _render(self, model: dict[str, Any], project_env: Any) -> str:
1854
+ project_root, config, env = project_env
1855
+ result = generate_api_routes(
1856
+ model, config, env, project_root, enums={}, constraints={}
1857
+ )
1858
+ assert isinstance(result, dict)
1859
+ return str(result["content"])
1860
+
1861
+ def test_imports_timezone(
1862
+ self, filter_model: dict[str, Any], project_env: Any
1863
+ ) -> None:
1864
+ content = self._render(filter_model, project_env)
1865
+ assert "from datetime import datetime, timezone" in content
1866
+
1867
+ def test_naive_after_localized_to_utc(
1868
+ self, filter_model: dict[str, Any], project_env: Any
1869
+ ) -> None:
1870
+ content = self._render(filter_model, project_env)
1871
+ assert "if observed_at_after.tzinfo is None:" in content
1872
+ assert (
1873
+ "observed_at_after = observed_at_after.replace(tzinfo=timezone.utc)"
1874
+ in content
1875
+ )
1876
+
1877
+ def test_naive_before_localized_to_utc(
1878
+ self, filter_model: dict[str, Any], project_env: Any
1879
+ ) -> None:
1880
+ content = self._render(filter_model, project_env)
1881
+ assert "if observed_at_before.tzinfo is None:" in content
1882
+ assert (
1883
+ "observed_at_before = observed_at_before.replace(tzinfo=timezone.utc)"
1884
+ in content
1885
+ )
1886
+
1887
+ def test_route_still_compiles(
1888
+ self, filter_model: dict[str, Any], project_env: Any
1889
+ ) -> None:
1890
+ content = self._render(filter_model, project_env)
1891
+ compile(content, "<metric_route>", "exec")
1892
+
1893
+
1655
1894
  class TestValidateAuthConfig:
1656
1895
  """Test the _validate_auth_config helper."""
1657
1896
 
@@ -3738,6 +3977,120 @@ class TestRequestLimitGenerator:
3738
3977
  # Default cap (>0) still applies -> middleware still emitted, no crash.
3739
3978
  assert isinstance(generate_request_limit(config, env, project_root), dict)
3740
3979
 
3980
+ def test_negative_content_length_treated_as_invalid(
3981
+ self, project_env: Any, monkeypatch: pytest.MonkeyPatch
3982
+ ) -> None:
3983
+ """Defense-in-depth: a negative Content-Length must not bypass the cap.
3984
+
3985
+ ``int(b"-100")`` used to be returned verbatim, so ``-100 > max`` was
3986
+ False and the request streamed through uncounted. Now a negative length
3987
+ is invalid (``None``), so the request falls through to the chunked
3988
+ byte-counting path and is still rejected on overflow.
3989
+ """
3990
+ import asyncio
3991
+ import sys
3992
+ import types as types_module
3993
+
3994
+ project_root, config, env = project_env
3995
+ result = generate_request_limit(config, env, project_root)
3996
+ assert isinstance(result, dict)
3997
+ content = result["content"]
3998
+ # Template-level guard: a negative declared length is invalid.
3999
+ assert "if n < 0:" in content
4000
+
4001
+ # Runtime probe: drive the middleware with a lying Content-Length: -100
4002
+ # and an oversized body; the cap must still produce a 413. starlette is
4003
+ # not a generator dependency, so stub its type-only import (auto-undone
4004
+ # by the monkeypatch fixture).
4005
+ starlette = types_module.ModuleType("starlette")
4006
+ starlette_types = types_module.ModuleType("starlette.types")
4007
+ for name in ("ASGIApp", "Message", "Receive", "Scope", "Send"):
4008
+ setattr(starlette_types, name, Any)
4009
+ starlette.types = starlette_types # type: ignore[attr-defined]
4010
+ monkeypatch.setitem(sys.modules, "starlette", starlette)
4011
+ monkeypatch.setitem(sys.modules, "starlette.types", starlette_types)
4012
+
4013
+ ns: dict[str, Any] = {}
4014
+ exec(content, ns)
4015
+ middleware_cls = ns["RequestBodySizeLimitMiddleware"]
4016
+
4017
+ async def app(scope: Any, receive: Any, send: Any) -> None:
4018
+ raise AssertionError("oversized body reached the app")
4019
+
4020
+ mw = middleware_cls(app, max_body_bytes=10)
4021
+ scope = {"type": "http", "headers": [(b"content-length", b"-100")]}
4022
+
4023
+ async def receive() -> dict[str, Any]:
4024
+ return {"type": "http.request", "body": b"x" * 100, "more_body": False}
4025
+
4026
+ sent: list[dict[str, Any]] = []
4027
+
4028
+ async def send(message: dict[str, Any]) -> None:
4029
+ sent.append(message)
4030
+
4031
+ asyncio.run(mw(scope, receive, send))
4032
+
4033
+ start = next(m for m in sent if m["type"] == "http.response.start")
4034
+ assert start["status"] == 413
4035
+
4036
+ def test_duplicate_content_length_treated_as_invalid(
4037
+ self, project_env: Any, monkeypatch: pytest.MonkeyPatch
4038
+ ) -> None:
4039
+ """Defense-in-depth: duplicate Content-Length headers must not bypass the cap.
4040
+
4041
+ Returning the first header's value lets a smuggling pair (small + large)
4042
+ slip an oversized body past a guard keyed on the small one if a
4043
+ downstream server honors the other. Two Content-Length headers are now
4044
+ treated as invalid, forcing the chunked byte-counting path → 413.
4045
+ """
4046
+ import asyncio
4047
+ import sys
4048
+ import types as types_module
4049
+
4050
+ project_root, config, env = project_env
4051
+ result = generate_request_limit(config, env, project_root)
4052
+ assert isinstance(result, dict)
4053
+ content = result["content"]
4054
+
4055
+ starlette = types_module.ModuleType("starlette")
4056
+ starlette_types = types_module.ModuleType("starlette.types")
4057
+ for name in ("ASGIApp", "Message", "Receive", "Scope", "Send"):
4058
+ setattr(starlette_types, name, Any)
4059
+ starlette.types = starlette_types # type: ignore[attr-defined]
4060
+ monkeypatch.setitem(sys.modules, "starlette", starlette)
4061
+ monkeypatch.setitem(sys.modules, "starlette.types", starlette_types)
4062
+
4063
+ ns: dict[str, Any] = {}
4064
+ exec(content, ns)
4065
+ middleware_cls = ns["RequestBodySizeLimitMiddleware"]
4066
+
4067
+ async def app(scope: Any, receive: Any, send: Any) -> None:
4068
+ raise AssertionError("oversized body reached the app")
4069
+
4070
+ mw = middleware_cls(app, max_body_bytes=10)
4071
+ # Smuggling pair: a small declared length the guard would accept, plus a
4072
+ # second header. The middleware must distrust both and count bytes.
4073
+ scope = {
4074
+ "type": "http",
4075
+ "headers": [
4076
+ (b"content-length", b"5"),
4077
+ (b"content-length", b"100"),
4078
+ ],
4079
+ }
4080
+
4081
+ async def receive() -> dict[str, Any]:
4082
+ return {"type": "http.request", "body": b"x" * 100, "more_body": False}
4083
+
4084
+ sent: list[dict[str, Any]] = []
4085
+
4086
+ async def send(message: dict[str, Any]) -> None:
4087
+ sent.append(message)
4088
+
4089
+ asyncio.run(mw(scope, receive, send))
4090
+
4091
+ start = next(m for m in sent if m["type"] == "http.response.start")
4092
+ assert start["status"] == 413
4093
+
3741
4094
 
3742
4095
  class TestImmutableEntityGeneration:
3743
4096
  """Test generation for immutable entities (no update endpoint)."""