tina4-python 3.13.103__tar.gz → 3.13.105__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 (163) hide show
  1. {tina4_python-3.13.103 → tina4_python-3.13.105}/PKG-INFO +1 -1
  2. {tina4_python-3.13.103 → tina4_python-3.13.105}/pyproject.toml +1 -1
  3. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/__init__.py +8 -1
  4. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/cli/__init__.py +2 -7
  5. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/__init__.py +2 -2
  6. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/router.py +28 -5
  7. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/server.py +101 -1
  8. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/__init__.py +6 -2
  9. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/adapter.py +3 -2
  10. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/sql_translator.py +122 -0
  11. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/__init__.py +6 -3
  12. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/fields.py +125 -0
  13. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/model.py +212 -7
  14. tina4_python-3.13.105/tina4_python/orm/point.py +421 -0
  15. tina4_python-3.13.105/tina4_python/query_builder/__init__.py +757 -0
  16. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/__init__.py +42 -7
  17. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/lite_backend.py +16 -2
  18. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/mongo_backend.py +67 -9
  19. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session/__init__.py +4 -1
  20. tina4_python-3.13.105/tina4_python/sso.py +319 -0
  21. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/swagger/__init__.py +13 -1
  22. tina4_python-3.13.103/tina4_python/query_builder/__init__.py +0 -445
  23. {tina4_python-3.13.103 → tina4_python-3.13.105}/.gitignore +0 -0
  24. {tina4_python-3.13.103 → tina4_python-3.13.105}/README.md +0 -0
  25. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/CLAUDE.md +0 -0
  26. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/HtmlElement.py +0 -0
  27. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/Testing.py +0 -0
  28. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/ai/__init__.py +0 -0
  29. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/ai/client.py +0 -0
  30. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/api/__init__.py +0 -0
  31. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/auth/__init__.py +0 -0
  32. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/cache/__init__.py +0 -0
  33. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/container/__init__.py +0 -0
  34. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/context/__init__.py +0 -0
  35. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/context/chunker.py +0 -0
  36. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/cache.py +0 -0
  37. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/constants.py +0 -0
  38. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/events.py +0 -0
  39. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/middleware.py +0 -0
  40. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/port_takeover.py +0 -0
  41. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/rate_limiter.py +0 -0
  42. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/request.py +0 -0
  43. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/response.py +0 -0
  44. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/crud/__init__.py +0 -0
  45. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/connection.py +0 -0
  46. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/database_url.py +0 -0
  47. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/firebird.py +0 -0
  48. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mongodb.py +0 -0
  49. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mssql.py +0 -0
  50. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mysql.py +0 -0
  51. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/odbc.py +0 -0
  52. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/postgres.py +0 -0
  53. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/sqlite.py +0 -0
  54. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/debug/__init__.py +0 -0
  55. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/debug/error_overlay.py +0 -0
  56. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/__init__.py +0 -0
  57. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/metrics.py +0 -0
  58. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/plan.py +0 -0
  59. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/project_index.py +0 -0
  60. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/docs.py +0 -0
  61. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/docstore/__init__.py +0 -0
  62. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dotenv/__init__.py +0 -0
  63. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/env.py +0 -0
  64. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/FROND.md +0 -0
  65. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/__init__.py +0 -0
  66. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/compiler.py +0 -0
  67. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/engine.py +0 -0
  68. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/parser.py +0 -0
  69. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/auth/meta.json +0 -0
  70. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  71. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/database/meta.json +0 -0
  72. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  73. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/error-overlay/meta.json +0 -0
  74. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  75. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/meta.json +0 -0
  76. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  77. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  78. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/queue/meta.json +0 -0
  79. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  80. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/rest-api/meta.json +0 -0
  81. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  82. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/meta.json +0 -0
  83. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  84. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  85. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/graphql/__init__.py +0 -0
  86. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/i18n/__init__.py +0 -0
  87. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/__init__.py +0 -0
  88. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/protocol.py +0 -0
  89. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/tools.py +0 -0
  90. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/messenger/__init__.py +0 -0
  91. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/migration/__init__.py +0 -0
  92. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/migration/runner.py +0 -0
  93. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mqtt/__init__.py +0 -0
  94. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mqtt/message.py +0 -0
  95. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/__feedback/widget.js +0 -0
  96. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/css/tina4.css +0 -0
  97. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/css/tina4.min.css +0 -0
  98. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/favicon.ico +0 -0
  99. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/images/logo.svg +0 -0
  100. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  101. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/frond.js +0 -0
  102. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/frond.min.js +0 -0
  103. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  104. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4.min.js +0 -0
  105. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4js.min.js +0 -0
  106. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/swagger/index.html +0 -0
  107. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  108. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/amqp_url.py +0 -0
  109. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/job.py +0 -0
  110. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/kafka_backend.py +0 -0
  111. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/rabbitmq_backend.py +0 -0
  112. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/__init__.py +0 -0
  113. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/kafka_backend.py +0 -0
  114. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/mongo_backend.py +0 -0
  115. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  116. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/__init__.py +0 -0
  117. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Attachment.py +0 -0
  118. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Channel.py +0 -0
  119. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/ChannelMember.py +0 -0
  120. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Message.py +0 -0
  121. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Workspace.py +0 -0
  122. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/__init__.py +0 -0
  123. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/storage.py +0 -0
  124. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/seeder/__init__.py +0 -0
  125. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/service/__init__.py +0 -0
  126. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/__init__.py +0 -0
  127. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/memcached_handler.py +0 -0
  128. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  129. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/redis_handler.py +0 -0
  130. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/valkey_handler.py +0 -0
  131. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/components/crud.twig +0 -0
  132. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  133. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  134. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/python/Dockerfile +0 -0
  135. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  136. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/302.twig +0 -0
  137. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/401.twig +0 -0
  138. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/403.twig +0 -0
  139. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/404.twig +0 -0
  140. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/500.twig +0 -0
  141. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/502.twig +0 -0
  142. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/503.twig +0 -0
  143. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/base.twig +0 -0
  144. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/frontend/README.md +0 -0
  145. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/readme.md +0 -0
  146. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/test/__init__.py +0 -0
  147. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/test_client/__init__.py +0 -0
  148. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  149. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  150. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  151. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  152. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  153. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  154. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  155. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  156. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  157. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  158. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  159. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  160. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/validator/__init__.py +0 -0
  161. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/websocket/__init__.py +0 -0
  162. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/websocket/backplane.py +0 -0
  163. {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: tina4-python
3
- Version: 3.13.103
3
+ Version: 3.13.105
4
4
  Summary: Tina4 Python v3 — Zero-dependency, lightweight web framework
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "tina4-python"
3
- version = "3.13.103"
3
+ version = "3.13.105"
4
4
  description = "Tina4 Python v3 — Zero-dependency, lightweight web framework"
5
5
  authors = [
6
6
  {name = "Andre van Zuydam", email = "andrevanzuydam@gmail.com"}
@@ -51,7 +51,7 @@ def _resolve_version() -> str:
51
51
  #
52
52
  # test_version_constant.py now asserts this literal equals the pyproject
53
53
  # version, so the release bump cannot leave it behind again.
54
- return "3.13.103"
54
+ return "3.13.105"
55
55
 
56
56
 
57
57
  __version__ = _resolve_version()
@@ -108,6 +108,9 @@ _LAZY: dict[str, tuple[str, str]] = {
108
108
  "NumericField": ("tina4_python.orm", "NumericField"),
109
109
  "DecimalField": ("tina4_python.orm", "DecimalField"),
110
110
  "JSONField": ("tina4_python.orm", "JSONField"),
111
+ "PointField": ("tina4_python.orm", "PointField"),
112
+ "Point": ("tina4_python.orm", "Point"),
113
+ "feature_collection": ("tina4_python.orm", "feature_collection"),
111
114
  "ForeignKeyField": ("tina4_python.orm", "ForeignKeyField"),
112
115
  "IntField": ("tina4_python.orm", "IntField"),
113
116
  "StrField": ("tina4_python.orm", "StrField"),
@@ -141,6 +144,10 @@ _LAZY: dict[str, tuple[str, str]] = {
141
144
  "AiHTTPError": ("tina4_python.ai", "AiHTTPError"),
142
145
  "AiTimeoutError": ("tina4_python.ai", "AiTimeoutError"),
143
146
  "AiParseError": ("tina4_python.ai", "AiParseError"),
147
+ # Provider-neutral OpenID Connect SSO (ADR-0056)
148
+ "Sso": ("tina4_python.sso", "Sso"),
149
+ "SSO": ("tina4_python.sso", "SSO"),
150
+ "SsoError": ("tina4_python.sso", "SsoError"),
144
151
  # SOAP / WSDL
145
152
  "WSDL": ("tina4_python.wsdl", "WSDL"),
146
153
  "wsdl_operation": ("tina4_python.wsdl", "wsdl_operation"),
@@ -825,13 +825,8 @@ def _seed(args):
825
825
 
826
826
  def _routes(args):
827
827
  """List all registered routes."""
828
- if Path("app.py").exists():
829
- sys.path.insert(0, str(Path.cwd()))
830
- import importlib
831
- importlib.import_module("app")
832
-
833
- from tina4_python.core.router import Router
834
- routes = Router.get_routes()
828
+ from tina4_python.core import discover_routes
829
+ routes = discover_routes("src/routes")
835
830
  if not routes:
836
831
  print("No routes registered.")
837
832
  return
@@ -19,7 +19,7 @@ from tina4_python.core.router import (
19
19
  from tina4_python.core.middleware import CorsMiddleware, RateLimiter
20
20
  from tina4_python.core.cache import Cache
21
21
  from tina4_python.core.events import on, off, emit, emit_async, once, listeners, events, clear as clear_events
22
- from tina4_python.core.server import run, resolve_config, handle, start, stop
22
+ from tina4_python.core.server import discover_routes, run, resolve_config, handle, start, stop
23
23
 
24
24
  __all__ = [
25
25
  "Request", "Response", "Router",
@@ -28,5 +28,5 @@ __all__ = [
28
28
  "CorsMiddleware", "RateLimiter",
29
29
  "Cache",
30
30
  "on", "off", "emit", "emit_async", "once", "listeners", "events", "clear_events",
31
- "run", "resolve_config", "handle", "start", "stop",
31
+ "discover_routes", "run", "resolve_config", "handle", "start", "stop",
32
32
  ]
@@ -817,9 +817,23 @@ def noauth():
817
817
  def decorator(fn):
818
818
  fn._noauth = True
819
819
  # If route was already registered (decorator applied after @get/@post),
820
- # update the route dict directly.
820
+ # update the route dict directly AND log the corrected state. The
821
+ # documented decorator order puts @noauth() ABOVE @get/@post, which
822
+ # means Python's bottom-up application runs @post first — so
823
+ # `_register_route` fires its "auth=required" line for a route that
824
+ # will be public a microsecond later. Without the corrective line
825
+ # below, the earlier log reads as definitive and misleads anyone
826
+ # reading startup output (issue #103: an AI reviewer drafted a false
827
+ # critical-security finding after reading the misleading line).
821
828
  if hasattr(fn, "_route_ref"):
822
- fn._route_ref._route["auth_required"] = False
829
+ route = fn._route_ref._route
830
+ was_required = route.get("auth_required", False)
831
+ route["auth_required"] = False
832
+ if was_required:
833
+ Log.debug(
834
+ f"Route auth updated: {route['method']} {route['path']} "
835
+ f"(auth=public via @noauth)"
836
+ )
823
837
  return fn
824
838
  return decorator
825
839
 
@@ -828,10 +842,19 @@ def secured():
828
842
  """Require auth on a GET route (which is public by default)."""
829
843
  def decorator(fn):
830
844
  fn._secured = True
831
- # If route was already registered (decorator applied after @get/@post),
832
- # update the route dict directly.
845
+ # See @noauth() above: emit a corrective log line if we flip the flag
846
+ # on an already-registered route so a reader of the startup log sees
847
+ # the true final auth state after every decorator has applied
848
+ # (issue #103).
833
849
  if hasattr(fn, "_route_ref"):
834
- fn._route_ref._route["auth_required"] = True
850
+ route = fn._route_ref._route
851
+ was_public = not route.get("auth_required", True)
852
+ route["auth_required"] = True
853
+ if was_public:
854
+ Log.debug(
855
+ f"Route auth updated: {route['method']} {route['path']} "
856
+ f"(auth=required via @secured)"
857
+ )
835
858
  # Same for a WebSocket route registered by @websocket() below this one.
836
859
  if hasattr(fn, "_ws_route_ref"):
837
860
  fn._ws_route_ref["auth_required"] = True
@@ -316,6 +316,79 @@ async def background_tick_loop(callback, interval: float, executor, shutdown):
316
316
  _discovered_mtimes: dict[str, float] = {}
317
317
 
318
318
 
319
+ def _cascade_reload_dependents(reloaded: str, root_pkg: str, visited: set[str]) -> None:
320
+ """After re-importing ``reloaded``, re-import any other in-scope module that
321
+ still holds a stale ``from <reloaded> import Foo`` reference.
322
+
323
+ Fixes https://github.com/tina4stack/tina4-python/issues/102 (hot reload
324
+ does not re-register ORM field metadata). Root cause: ``_auto_discover``
325
+ reloads the CHANGED module (say ``src.orm.Todo``) by ``del sys.modules[.]``
326
+ + ``importlib.import_module(.)``, which builds a fresh class object. But a
327
+ route module that did ``from src.orm.Todo import Todo`` at ITS first import
328
+ captured a reference to the OLD class, and unless that module ALSO
329
+ re-executes, its ``Todo(...)`` call still uses the old class -- with the
330
+ old ``_fields`` -- so a newly added column is silently absent from
331
+ ``to_dict()`` output. The DATA is written to the DB correctly (the ORM
332
+ knows the field via the new class), and the API just lies about it.
333
+
334
+ Detection: for every in-scope module already in ``sys.modules``, walk its
335
+ namespace and look for any attribute whose ``__module__`` matches the
336
+ reloaded module's name. That's the fingerprint of a ``from X import Y``
337
+ binding into an unchanged file. Re-import that file so its ``from`` binds
338
+ to the fresh class. Recurse (a dependent may itself be depended on).
339
+
340
+ Bounded to the discovery scope (``root_pkg`` and its subpackages) so we
341
+ never delete a framework or third-party module. ``visited`` breaks cycles
342
+ and prevents re-doing a module we already refreshed in this cascade.
343
+ """
344
+ # Snapshot: mutating sys.modules during the walk would break iteration.
345
+ for other_name in list(sys.modules):
346
+ if other_name in visited:
347
+ continue
348
+ if other_name == reloaded:
349
+ continue
350
+ if other_name != root_pkg and not other_name.startswith(root_pkg + "."):
351
+ continue
352
+ other = sys.modules.get(other_name)
353
+ if other is None or not hasattr(other, "__dict__"):
354
+ continue
355
+ # Any attribute whose __module__ names the reloaded module is a
356
+ # ``from X import Y`` binding into this file -- it is stale.
357
+ try:
358
+ attrs = vars(other)
359
+ except Exception:
360
+ continue
361
+ stale = False
362
+ for attr_name, attr in attrs.items():
363
+ if attr_name.startswith("__"):
364
+ continue
365
+ if getattr(attr, "__module__", None) == reloaded:
366
+ stale = True
367
+ break
368
+ if not stale:
369
+ continue
370
+ try:
371
+ from tina4_python.core.router import Router
372
+ Router.remove_routes_for_module(other_name)
373
+ del sys.modules[other_name]
374
+ importlib.import_module(other_name)
375
+ # Record so the next mtime tick sees this reload as authoritative.
376
+ try:
377
+ other_path = getattr(sys.modules[other_name], "__file__", None)
378
+ if other_path:
379
+ _discovered_mtimes[other_name] = Path(other_path).stat().st_mtime
380
+ except OSError:
381
+ pass
382
+ visited.add(other_name)
383
+ Log.info(f"Cascaded reload of dependent module: {other_name}")
384
+ # A module that imported from `reloaded` may itself be imported
385
+ # from by yet another module. Cascade the same detection on THIS
386
+ # module now that its class objects are fresh.
387
+ _cascade_reload_dependents(other_name, root_pkg, visited)
388
+ except Exception as exc:
389
+ Log.error(f"Cascade reload failed for {other_name}: {exc}")
390
+
391
+
319
392
  def _auto_discover(root_dir: str = "src"):
320
393
  """Auto-import all .py files in ``root_dir`` to trigger route decorators.
321
394
 
@@ -444,6 +517,14 @@ def _auto_discover(root_dir: str = "src"):
444
517
  f"Reloaded changed module: {module_name}"
445
518
  + (f" (dropped {dropped} stale route(s))" if dropped else "")
446
519
  )
520
+ # Cascade to any in-scope module that captured a
521
+ # ``from <module_name> import Foo`` binding at ITS first
522
+ # import -- that binding still points at the OLD class
523
+ # object we just replaced, so the dependent silently uses
524
+ # stale field metadata / stale globals until it too
525
+ # re-executes. See _cascade_reload_dependents above and
526
+ # issue #102.
527
+ _cascade_reload_dependents(module_name, root_pkg, {module_name})
447
528
  else:
448
529
  # Out-of-scope module changed — record mtime so we don't
449
530
  # keep re-evaluating it, but do not re-import it.
@@ -468,6 +549,13 @@ def _auto_discover(root_dir: str = "src"):
468
549
  pass
469
550
 
470
551
 
552
+ def discover_routes(root_dir: str = "src/routes"):
553
+ """Discover application routes without booting the application server."""
554
+ _auto_discover(root_dir)
555
+ from tina4_python.core.router import Router
556
+ return Router.get_routes()
557
+
558
+
471
559
  def _record_broken_import(py_file: Path, error: Exception) -> None:
472
560
  """Write a .broken sentinel so /health and the dev dashboard surface
473
561
  auto-discover failures instead of burying them in the console."""
@@ -1691,8 +1779,16 @@ def _check_auth(request: Request, response: Response, route: dict) -> bool:
1691
1779
  if not _auth_ok:
1692
1780
  _session = getattr(request, "session", None)
1693
1781
  if _session:
1782
+ # A provider-verified OIDC identity is handed into the SAME auth
1783
+ # gate as JWT. Provider credentials stay in the reserved Session
1784
+ # value and never enter request.user.
1785
+ _sso = _session.get("_tina4_sso")
1786
+ _sso_identity = _sso.get("identity") if isinstance(_sso, dict) else None
1787
+ if isinstance(_sso_identity, dict) and _sso_identity.get("issuer") and _sso_identity.get("subject"):
1788
+ _auth_ok = True
1789
+ request.user = _sso_identity
1694
1790
  _session_token = _session.get("token") if _session else ""
1695
- if _session_token:
1791
+ if not _auth_ok and _session_token:
1696
1792
  try:
1697
1793
  from tina4_python.auth import Auth
1698
1794
  _payload = Auth.valid_token_static(_session_token)
@@ -3669,6 +3765,10 @@ def run(host: str | None = None, port: int | None = None, no_browser: bool = Fal
3669
3765
 
3670
3766
  # Auto-discover routes
3671
3767
  _auto_discover("src")
3768
+ # Configuration-first OIDC: canonical routes appear only when configured,
3769
+ # after app discovery so collisions fail loudly rather than overwrite.
3770
+ from tina4_python.sso import Sso as _Sso
3771
+ _Sso.mount_configured()
3672
3772
  route_count = len(Router.get_routes())
3673
3773
  Log.info(f"Discovered {route_count} routes")
3674
3774
 
@@ -11,7 +11,11 @@ SQL-first database layer. One interface, many drivers.
11
11
  row = db.fetch_one("SELECT * FROM users WHERE id = ?", [42])
12
12
  db.execute("INSERT INTO users (name) VALUES (?)", ["Alice"])
13
13
  """
14
- from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult, SQLTranslator
14
+ from tina4_python.database.adapter import DatabaseAdapter, DatabaseResult
15
+ from tina4_python.database.sql_translator import SQLTranslator, SpatialNotSupportedError
15
16
  from tina4_python.database.connection import Database
16
17
 
17
- __all__ = ["Database", "DatabaseAdapter", "DatabaseResult", "SQLTranslator"]
18
+ __all__ = [
19
+ "Database", "DatabaseAdapter", "DatabaseResult", "SQLTranslator",
20
+ "SpatialNotSupportedError",
21
+ ]
@@ -1131,9 +1131,10 @@ class SqlCrudMixin:
1131
1131
  # ── SQL Translation Rules ──────────────────────────────────────
1132
1132
  # Reusable translation functions for common cross-engine quirks.
1133
1133
 
1134
-
1135
1134
  # SQLTranslator moved to sql_translator.py (feature 3: the adapter module is the
1136
1135
  # adapter contract and nothing else). Re-exported here because the sqlite,
1137
1136
  # firebird, mssql and odbc adapters import it from this module, and a file move
1138
1137
  # is not the place to churn their imports.
1139
- from tina4_python.database.sql_translator import SQLTranslator # noqa: E402,F401
1138
+ from tina4_python.database.sql_translator import ( # noqa: E402,F401
1139
+ SQLTranslator, SpatialNotSupportedError,
1140
+ )
@@ -16,6 +16,10 @@ by engine; moving it here would flatten that override.
16
16
  import re
17
17
 
18
18
 
19
+ class SpatialNotSupportedError(NotImplementedError):
20
+ """The selected database engine cannot honor the GIS contract."""
21
+
22
+
19
23
  class SQLTranslator:
20
24
  """Cross-engine SQL translator.
21
25
 
@@ -23,6 +27,124 @@ class SQLTranslator:
23
27
  and stateless — just string transforms.
24
28
  """
25
29
 
30
+ SPATIAL_ENGINES = ("postgres", "postgresql")
31
+ _IDENTIFIER = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*$")
32
+
33
+ @classmethod
34
+ def supports_spatial(cls, engine: str) -> bool:
35
+ """Return whether Tina4 has a spatial provider for ``engine``."""
36
+ name = (engine or "").lower()
37
+ name = cls.ENGINE_ALIASES.get(name, name)
38
+ return name in cls.SPATIAL_ENGINES
39
+
40
+ @classmethod
41
+ def require_spatial(cls, engine: str, feature: str) -> str:
42
+ """Return the normalized spatial engine or fail with an actionable error."""
43
+ name = (engine or "unknown").lower()
44
+ name = cls.ENGINE_ALIASES.get(name, name)
45
+ if cls.supports_spatial(name):
46
+ return name
47
+ raise SpatialNotSupportedError(
48
+ f"{feature} is not supported on the '{name}' database engine. "
49
+ "Tina4 GIS support is PostGIS-first: use PostgreSQL with the "
50
+ "PostGIS extension (CREATE EXTENSION postgis). Tina4 will not "
51
+ "replace a spatial query with an approximate latitude/longitude query. "
52
+ "If the application needs storage only and no GIS behavior, declare "
53
+ "separate longitude and latitude FloatField columns instead."
54
+ )
55
+
56
+ @classmethod
57
+ def identifier(cls, name: str, what: str = "column") -> str:
58
+ """Validate an identifier before placing it in spatial SQL."""
59
+ if not isinstance(name, str) or not cls._IDENTIFIER.fullmatch(name):
60
+ raise ValueError(
61
+ f"Spatial {what} name {name!r} is not a valid SQL identifier"
62
+ )
63
+ return name
64
+
65
+ @staticmethod
66
+ def _srid(srid) -> int:
67
+ """Coerce the interpolated SRID to an integer."""
68
+ if isinstance(srid, bool):
69
+ raise ValueError(f"Spatial SRID must be an integer, got {srid!r}")
70
+ try:
71
+ value = int(srid)
72
+ except (TypeError, ValueError) as error:
73
+ raise ValueError(f"Spatial SRID must be an integer, got {srid!r}") from error
74
+ if value <= 0:
75
+ raise ValueError(f"Spatial SRID must be positive, got {value}")
76
+ return value
77
+
78
+ @classmethod
79
+ def point_column_type(cls, engine: str, srid: int = 4326) -> str:
80
+ """Return PostGIS geography Point DDL."""
81
+ cls.require_spatial(engine, "PointField")
82
+ return f"geography(Point,{cls._srid(srid)})"
83
+
84
+ @classmethod
85
+ def spatial_index(cls, engine: str, table: str, column: str) -> str:
86
+ """Return idempotent PostGIS GiST-index DDL."""
87
+ cls.require_spatial(engine, "spatial index creation")
88
+ table = cls.identifier(table, "table")
89
+ column = cls.identifier(column)
90
+ index = f"{table.replace('.', '_')}_{column}_gist"
91
+ return f"CREATE INDEX IF NOT EXISTS {index} ON {table} USING GIST ({column})"
92
+
93
+ @classmethod
94
+ def point_literal(cls, engine: str, srid: int = 4326) -> str:
95
+ """Return a bound PostGIS point expression in longitude/latitude order."""
96
+ cls.require_spatial(engine, "spatial predicates")
97
+ return f"ST_SetSRID(ST_MakePoint(?, ?), {cls._srid(srid)})::geography"
98
+
99
+ @classmethod
100
+ def within_distance(cls, engine: str, column: str, srid: int = 4326) -> str:
101
+ """Return a bound radius predicate whose distance uses metres."""
102
+ cls.require_spatial(engine, "within_distance()")
103
+ column = cls.identifier(column)
104
+ return f"ST_DWithin({column}, {cls.point_literal(engine, srid)}, ?)"
105
+
106
+ @classmethod
107
+ def distance(cls, engine: str, column: str, srid: int = 4326) -> str:
108
+ """Return a bound spheroid-distance expression in metres."""
109
+ cls.require_spatial(engine, "order_by_distance()")
110
+ column = cls.identifier(column)
111
+ return f"ST_Distance({column}, {cls.point_literal(engine, srid)})"
112
+
113
+ @classmethod
114
+ def distance_as(cls, engine: str, column: str, alias: str, srid: int = 4326) -> str:
115
+ """Return an aliased bound distance expression for a SELECT list."""
116
+ alias = cls.identifier(alias, "result alias")
117
+ return f"{cls.distance(engine, column, srid)} AS {alias}"
118
+
119
+ @classmethod
120
+ def geometry_literal(cls, engine: str, form: str = "ewkt", srid: int = 4326) -> str:
121
+ """Return a one-parameter PostGIS geometry expression."""
122
+ cls.require_spatial(engine, "spatial predicates")
123
+ if form == "ewkt":
124
+ return "ST_GeogFromText(?)"
125
+ if form == "geojson":
126
+ return f"ST_SetSRID(ST_GeomFromGeoJSON(?), {cls._srid(srid)})::geography"
127
+ raise ValueError(
128
+ f"Spatial geometry form {form!r} is not supported; use 'ewkt' or 'geojson'"
129
+ )
130
+
131
+ @classmethod
132
+ def intersects(cls, engine: str, column: str, form: str = "ewkt",
133
+ srid: int = 4326) -> str:
134
+ """Return a bound PostGIS intersection predicate."""
135
+ column = cls.identifier(column)
136
+ return f"ST_Intersects({column}, {cls.geometry_literal(engine, form, srid)})"
137
+
138
+ @classmethod
139
+ def bbox(cls, engine: str, column: str, srid: int = 4326) -> str:
140
+ """Return a bound PostGIS bounding-box predicate."""
141
+ cls.require_spatial(engine, "bbox()")
142
+ column = cls.identifier(column)
143
+ return (
144
+ f"ST_Intersects({column}, "
145
+ f"ST_MakeEnvelope(?, ?, ?, ?, {cls._srid(srid)})::geography)"
146
+ )
147
+
26
148
  @staticmethod
27
149
  def limit_to_rows(sql: str) -> str:
28
150
  """Convert LIMIT/OFFSET to Firebird ROWS...TO syntax.
@@ -20,11 +20,12 @@ from tina4_python.orm.fields import (
20
20
  Field,
21
21
  IntegerField, StringField, BooleanField, FloatField,
22
22
  DateTimeField, TextField, BlobField, NumericField, DecimalField, JSONField,
23
- ForeignKeyField,
23
+ PointField, ForeignKeyField,
24
24
  IntField, StrField, BoolField, # short aliases
25
25
  has_many, has_one, belongs_to, # relationship descriptors
26
26
  )
27
- from tina4_python.orm.model import ORM, bind_database
27
+ from tina4_python.orm.point import Point
28
+ from tina4_python.orm.model import ORM, bind_database, feature_collection
28
29
 
29
30
  __all__ = [
30
31
  "ORM", "bind_database",
@@ -32,9 +33,11 @@ __all__ = [
32
33
  # Verbose (preferred)
33
34
  "IntegerField", "StringField", "BooleanField", "FloatField",
34
35
  "DateTimeField", "TextField", "BlobField", "NumericField", "DecimalField", "JSONField",
35
- "ForeignKeyField",
36
+ "PointField", "ForeignKeyField",
36
37
  # Short aliases (backwards compat)
37
38
  "IntField", "StrField", "BoolField",
38
39
  # Relationships
39
40
  "has_many", "has_one", "belongs_to",
41
+ # Spatial
42
+ "Point", "feature_collection",
40
43
  ]
@@ -422,6 +422,131 @@ class JSONField(Field):
422
422
  raise ValueError(f"Field '{self.name}': value is not JSON-serializable") from e
423
423
 
424
424
 
425
+ class PointField(Field):
426
+ """A column that stores a single geographic point, SRID-aware.
427
+
428
+ You work with a :class:`~tina4_python.orm.point.Point` value object; the ORM
429
+ writes EWKT on save and parses the engine's (HEX)EWKB back into a ``Point``
430
+ on load, so ``model.location.lat`` always works regardless of engine wire
431
+ format.
432
+
433
+ Assignment accepts any of these and normalises to ``Point``::
434
+
435
+ site.location = (18.4241, -33.9249) # (lon, lat) tuple
436
+ site.location = "POINT(18.4241 -33.9249)" # WKT
437
+ site.location = "SRID=4326;POINT(18.4241 -33.9249)" # EWKT
438
+ site.location = {"type": "Point", "coordinates": [18.42, -33.9]} # GeoJSON
439
+
440
+ Reading it back gives the value object::
441
+
442
+ site.location.lon # 18.4241
443
+ site.location.lat # -33.9249
444
+ site.location.wkt # 'POINT(18.4241 -33.9249)'
445
+ site.location.geojson # {'type': 'Point', 'coordinates': [18.4241, -33.9249]}
446
+
447
+ ``to_dict()`` / ``to_json()`` / ``response()`` emit the GeoJSON form, so a
448
+ map front end consumes a Tina4 route with no translation layer.
449
+
450
+ **Engine support is PostGIS-first and never silently wrong.** The column DDL
451
+ is ``geography(Point,<srid>)`` on PostgreSQL, and
452
+ :meth:`~tina4_python.orm.model.ORM.create_table` also creates a GiST index
453
+ for it. On any engine without spatial support, ``create_table()`` raises
454
+ :class:`~tina4_python.database.adapter.SpatialNotSupportedError` naming that
455
+ engine rather than creating a wrong column type.
456
+
457
+ Usage::
458
+
459
+ class ChargePoint(ORM):
460
+ id = IntegerField(primary_key=True, auto_increment=True)
461
+ name = StringField()
462
+ location = PointField() # geography(Point,4326) + GiST index
463
+
464
+ ChargePoint({"name": "V&A", "location": (18.4241, -33.9249)}).save()
465
+
466
+ near = ChargePoint.query() \\
467
+ .within_distance("location", (18.42, -33.92), 5000) \\
468
+ .order_by_distance("location", (18.42, -33.92)) \\
469
+ .get()
470
+
471
+ Args:
472
+ srid: Spatial reference id for the column. Defaults to 4326 (WGS 84).
473
+ spatial_index: Create the engine's spatial index for this column in
474
+ ``create_table()``. Defaults to True — a radius query without one
475
+ is a full table scan.
476
+ """
477
+
478
+ def __init__(self, srid: int = None, spatial_index: bool = True, **kwargs):
479
+ from tina4_python.orm.point import DEFAULT_SRID, Point
480
+ super().__init__(Point, **kwargs)
481
+ self.kind = "PointField"
482
+ self.srid = DEFAULT_SRID if srid is None else int(srid)
483
+ self.spatial_index = spatial_index
484
+
485
+ def _resolve_default(self):
486
+ # ORM.__init__ seeds attributes straight from _resolve_default (it does
487
+ # not route through validate), so coerce here too — otherwise a
488
+ # ``default=(lon, lat)`` would leave ``model.location`` as a raw tuple on
489
+ # an unsaved instance and ``.lat`` would fail.
490
+ from tina4_python.orm.point import Point
491
+
492
+ default = super()._resolve_default()
493
+ if default is None:
494
+ return default
495
+ return self._parse_point(default)
496
+
497
+ def _parse_point(self, value):
498
+ from tina4_python.orm.point import Point
499
+
500
+ try:
501
+ point = Point.parse(value, self.srid)
502
+ except ValueError as e:
503
+ raise ValueError(f"Field '{self.name}': {e}") from e
504
+ if point.srid != self.srid:
505
+ raise ValueError(
506
+ f"Field '{self.name}' expects SRID {self.srid}; received {point.srid}. "
507
+ "Tina4 never silently reprojects or restamps spatial data."
508
+ )
509
+ return point
510
+
511
+ def coerce(self, value):
512
+ """Hydrate/assign through the Point parser, not ``Point(value)``."""
513
+ return None if value is None else self._parse_point(value)
514
+
515
+ def validate(self, value):
516
+ """Coerce any supported point representation into a ``Point``.
517
+
518
+ Runs on BOTH the write path (tuple / WKT / GeoJSON from a route) and the
519
+ read path (the engine's HEXEWKB), which is what makes the column
520
+ round-trip identically no matter how it was assigned.
521
+ """
522
+ from tina4_python.orm.point import Point
523
+
524
+ if value is None:
525
+ if self.required and self.default is None:
526
+ raise ValueError(f"Field '{self.name}' is required")
527
+ default = self._resolve_default()
528
+ return None if default is None else Point.parse(default, self.srid)
529
+
530
+ point = self._parse_point(value)
531
+
532
+ if self.validator is not None:
533
+ self.validator(point)
534
+ return point
535
+
536
+ def to_db(self, value):
537
+ """Serialise to EWKT — ``SRID=4326;POINT(lon lat)``.
538
+
539
+ EWKT carries the SRID with the geometry, so the engine never has to
540
+ assume one, and it is accepted verbatim by PostGIS geography input. The
541
+ value is bound as a normal parameter; it is never formatted into SQL.
542
+ """
543
+ from tina4_python.orm.point import Point
544
+
545
+ if value is None:
546
+ return None
547
+ return self._parse_point(value).ewkt
548
+
549
+
425
550
  class ForeignKeyField(Field):
426
551
  """Integer field that references another model's primary key.
427
552