model-generator-kit 0.1.1__tar.gz → 0.1.2__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.2}/PKG-INFO +2 -2
  2. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/README.md +1 -1
  3. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/pyproject.toml +1 -1
  4. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/__init__.py +1 -1
  5. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/route.py.j2 +7 -1
  6. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/request_limit.py.j2 +20 -3
  7. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/PKG-INFO +2 -2
  8. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_generators.py +166 -0
  9. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/LICENSE +0 -0
  10. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/setup.cfg +0 -0
  11. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generate.py +0 -0
  12. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/__init__.py +0 -0
  13. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/api.py +0 -0
  14. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/constraints.py +0 -0
  15. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/database.py +0 -0
  16. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/enums.py +0 -0
  17. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/infrastructure.py +0 -0
  18. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/generators/migrations.py +0 -0
  19. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/py.typed +0 -0
  20. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/schema/model.schema.json +0 -0
  21. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/config.yaml +0 -0
  22. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_base.j2 +0 -0
  23. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_entity.j2 +0 -0
  24. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_examples.j2 +0 -0
  25. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_fields.j2 +0 -0
  26. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/_shared/_tests.j2 +0 -0
  27. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/init.py.j2 +0 -0
  28. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/pagination.py.j2 +0 -0
  29. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/request.py.j2 +0 -0
  30. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/api/response.py.j2 +0 -0
  31. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/constraints.py.j2 +0 -0
  32. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/enums.py.j2 +0 -0
  33. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/factory.py.j2 +0 -0
  34. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/init.py.j2 +0 -0
  35. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/database/model.py.j2 +0 -0
  36. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/auth_router.py.j2 +0 -0
  37. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/base.py.j2 +0 -0
  38. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/csrf.py.j2 +0 -0
  39. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/database_init.py.j2 +0 -0
  40. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/encrypted_bytes.py.j2 +0 -0
  41. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/engine.py.j2 +0 -0
  42. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/errors.py.j2 +0 -0
  43. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/gitignore.j2 +0 -0
  44. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/main.py.j2 +0 -0
  45. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/pyproject.toml.j2 +0 -0
  46. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/rate_limit.py.j2 +0 -0
  47. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/types.py.j2 +0 -0
  48. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/utils.py.j2 +0 -0
  49. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/stacks/python-fastapi/templates/infrastructure/validators.py.j2 +0 -0
  50. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/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.2}/src/model_generator/stacks/python-fastapi/templates/migrations/ini.j2 +0 -0
  52. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/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.2}/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.2}/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.2}/src/model_generator/utils/__init__.py +0 -0
  56. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/conftest_generator.py +0 -0
  57. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/constants.py +0 -0
  58. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/loaders.py +0 -0
  59. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/parser.py +0 -0
  60. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/quality.py +0 -0
  61. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/utils/templates.py +0 -0
  62. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/validate.py +0 -0
  63. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/__init__.py +0 -0
  64. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/__init__.py +0 -0
  65. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/clean.py +0 -0
  66. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/generate.py +0 -0
  67. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/project_setup.py +0 -0
  68. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/actions/test_runner.py +0 -0
  69. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/menu.py +0 -0
  70. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator/wizard/prompts.py +0 -0
  71. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/SOURCES.txt +0 -0
  72. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/dependency_links.txt +0 -0
  73. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/entry_points.txt +0 -0
  74. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/requires.txt +0 -0
  75. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/src/model_generator_kit.egg-info/top_level.txt +0 -0
  76. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_cleanup.py +0 -0
  77. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_cli.py +0 -0
  78. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_edge_cases.py +0 -0
  79. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_enum_examples.py +0 -0
  80. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_full_generation.py +0 -0
  81. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_integration.py +0 -0
  82. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_template_utils.py +0 -0
  83. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_utils.py +0 -0
  84. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_validate.py +0 -0
  85. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/tests/test_validation.py +0 -0
  86. {model_generator_kit-0.1.1 → model_generator_kit-0.1.2}/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.2
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.2
@@ -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.2
@@ -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.2"
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.2"
@@ -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
@@ -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.2
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.2
@@ -1652,6 +1652,58 @@ class TestApiRoutesFilterCoercion:
1652
1652
  assert "from datetime import datetime" in content
1653
1653
 
1654
1654
 
1655
+ class TestApiRoutesDatetimeFilterTzAware:
1656
+ """Naive datetime filter values are localized to UTC before comparison.
1657
+
1658
+ A ``datetime | None`` filter parses input without a tz offset (e.g.
1659
+ ``2026-06-11T12:00:00``) as a naive datetime; compared against a tz-aware
1660
+ ``DateTime(timezone=True)`` column it raises ``TypeError``/``DataError`` on
1661
+ strict drivers (asyncpg/psycopg2). The handler localizes a naive value to
1662
+ UTC first. Latent (SQLite suites don't enforce tz-awareness), not a
1663
+ regression — fixed uniformly for both ``_after`` and ``_before``.
1664
+ """
1665
+
1666
+ def _render(self, model: dict[str, Any], project_env: Any) -> str:
1667
+ project_root, config, env = project_env
1668
+ result = generate_api_routes(
1669
+ model, config, env, project_root, enums={}, constraints={}
1670
+ )
1671
+ assert isinstance(result, dict)
1672
+ return str(result["content"])
1673
+
1674
+ def test_imports_timezone(
1675
+ self, filter_model: dict[str, Any], project_env: Any
1676
+ ) -> None:
1677
+ content = self._render(filter_model, project_env)
1678
+ assert "from datetime import datetime, timezone" in content
1679
+
1680
+ def test_naive_after_localized_to_utc(
1681
+ self, filter_model: dict[str, Any], project_env: Any
1682
+ ) -> None:
1683
+ content = self._render(filter_model, project_env)
1684
+ assert "if observed_at_after.tzinfo is None:" in content
1685
+ assert (
1686
+ "observed_at_after = observed_at_after.replace(tzinfo=timezone.utc)"
1687
+ in content
1688
+ )
1689
+
1690
+ def test_naive_before_localized_to_utc(
1691
+ self, filter_model: dict[str, Any], project_env: Any
1692
+ ) -> None:
1693
+ content = self._render(filter_model, project_env)
1694
+ assert "if observed_at_before.tzinfo is None:" in content
1695
+ assert (
1696
+ "observed_at_before = observed_at_before.replace(tzinfo=timezone.utc)"
1697
+ in content
1698
+ )
1699
+
1700
+ def test_route_still_compiles(
1701
+ self, filter_model: dict[str, Any], project_env: Any
1702
+ ) -> None:
1703
+ content = self._render(filter_model, project_env)
1704
+ compile(content, "<metric_route>", "exec")
1705
+
1706
+
1655
1707
  class TestValidateAuthConfig:
1656
1708
  """Test the _validate_auth_config helper."""
1657
1709
 
@@ -3738,6 +3790,120 @@ class TestRequestLimitGenerator:
3738
3790
  # Default cap (>0) still applies -> middleware still emitted, no crash.
3739
3791
  assert isinstance(generate_request_limit(config, env, project_root), dict)
3740
3792
 
3793
+ def test_negative_content_length_treated_as_invalid(
3794
+ self, project_env: Any, monkeypatch: pytest.MonkeyPatch
3795
+ ) -> None:
3796
+ """Defense-in-depth: a negative Content-Length must not bypass the cap.
3797
+
3798
+ ``int(b"-100")`` used to be returned verbatim, so ``-100 > max`` was
3799
+ False and the request streamed through uncounted. Now a negative length
3800
+ is invalid (``None``), so the request falls through to the chunked
3801
+ byte-counting path and is still rejected on overflow.
3802
+ """
3803
+ import asyncio
3804
+ import sys
3805
+ import types as types_module
3806
+
3807
+ project_root, config, env = project_env
3808
+ result = generate_request_limit(config, env, project_root)
3809
+ assert isinstance(result, dict)
3810
+ content = result["content"]
3811
+ # Template-level guard: a negative declared length is invalid.
3812
+ assert "if n < 0:" in content
3813
+
3814
+ # Runtime probe: drive the middleware with a lying Content-Length: -100
3815
+ # and an oversized body; the cap must still produce a 413. starlette is
3816
+ # not a generator dependency, so stub its type-only import (auto-undone
3817
+ # by the monkeypatch fixture).
3818
+ starlette = types_module.ModuleType("starlette")
3819
+ starlette_types = types_module.ModuleType("starlette.types")
3820
+ for name in ("ASGIApp", "Message", "Receive", "Scope", "Send"):
3821
+ setattr(starlette_types, name, Any)
3822
+ starlette.types = starlette_types # type: ignore[attr-defined]
3823
+ monkeypatch.setitem(sys.modules, "starlette", starlette)
3824
+ monkeypatch.setitem(sys.modules, "starlette.types", starlette_types)
3825
+
3826
+ ns: dict[str, Any] = {}
3827
+ exec(content, ns)
3828
+ middleware_cls = ns["RequestBodySizeLimitMiddleware"]
3829
+
3830
+ async def app(scope: Any, receive: Any, send: Any) -> None:
3831
+ raise AssertionError("oversized body reached the app")
3832
+
3833
+ mw = middleware_cls(app, max_body_bytes=10)
3834
+ scope = {"type": "http", "headers": [(b"content-length", b"-100")]}
3835
+
3836
+ async def receive() -> dict[str, Any]:
3837
+ return {"type": "http.request", "body": b"x" * 100, "more_body": False}
3838
+
3839
+ sent: list[dict[str, Any]] = []
3840
+
3841
+ async def send(message: dict[str, Any]) -> None:
3842
+ sent.append(message)
3843
+
3844
+ asyncio.run(mw(scope, receive, send))
3845
+
3846
+ start = next(m for m in sent if m["type"] == "http.response.start")
3847
+ assert start["status"] == 413
3848
+
3849
+ def test_duplicate_content_length_treated_as_invalid(
3850
+ self, project_env: Any, monkeypatch: pytest.MonkeyPatch
3851
+ ) -> None:
3852
+ """Defense-in-depth: duplicate Content-Length headers must not bypass the cap.
3853
+
3854
+ Returning the first header's value lets a smuggling pair (small + large)
3855
+ slip an oversized body past a guard keyed on the small one if a
3856
+ downstream server honors the other. Two Content-Length headers are now
3857
+ treated as invalid, forcing the chunked byte-counting path → 413.
3858
+ """
3859
+ import asyncio
3860
+ import sys
3861
+ import types as types_module
3862
+
3863
+ project_root, config, env = project_env
3864
+ result = generate_request_limit(config, env, project_root)
3865
+ assert isinstance(result, dict)
3866
+ content = result["content"]
3867
+
3868
+ starlette = types_module.ModuleType("starlette")
3869
+ starlette_types = types_module.ModuleType("starlette.types")
3870
+ for name in ("ASGIApp", "Message", "Receive", "Scope", "Send"):
3871
+ setattr(starlette_types, name, Any)
3872
+ starlette.types = starlette_types # type: ignore[attr-defined]
3873
+ monkeypatch.setitem(sys.modules, "starlette", starlette)
3874
+ monkeypatch.setitem(sys.modules, "starlette.types", starlette_types)
3875
+
3876
+ ns: dict[str, Any] = {}
3877
+ exec(content, ns)
3878
+ middleware_cls = ns["RequestBodySizeLimitMiddleware"]
3879
+
3880
+ async def app(scope: Any, receive: Any, send: Any) -> None:
3881
+ raise AssertionError("oversized body reached the app")
3882
+
3883
+ mw = middleware_cls(app, max_body_bytes=10)
3884
+ # Smuggling pair: a small declared length the guard would accept, plus a
3885
+ # second header. The middleware must distrust both and count bytes.
3886
+ scope = {
3887
+ "type": "http",
3888
+ "headers": [
3889
+ (b"content-length", b"5"),
3890
+ (b"content-length", b"100"),
3891
+ ],
3892
+ }
3893
+
3894
+ async def receive() -> dict[str, Any]:
3895
+ return {"type": "http.request", "body": b"x" * 100, "more_body": False}
3896
+
3897
+ sent: list[dict[str, Any]] = []
3898
+
3899
+ async def send(message: dict[str, Any]) -> None:
3900
+ sent.append(message)
3901
+
3902
+ asyncio.run(mw(scope, receive, send))
3903
+
3904
+ start = next(m for m in sent if m["type"] == "http.response.start")
3905
+ assert start["status"] == 413
3906
+
3741
3907
 
3742
3908
  class TestImmutableEntityGeneration:
3743
3909
  """Test generation for immutable entities (no update endpoint)."""