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.
- {tina4_python-3.13.103 → tina4_python-3.13.105}/PKG-INFO +1 -1
- {tina4_python-3.13.103 → tina4_python-3.13.105}/pyproject.toml +1 -1
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/__init__.py +8 -1
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/cli/__init__.py +2 -7
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/__init__.py +2 -2
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/router.py +28 -5
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/server.py +101 -1
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/__init__.py +6 -2
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/adapter.py +3 -2
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/sql_translator.py +122 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/__init__.py +6 -3
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/fields.py +125 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/orm/model.py +212 -7
- tina4_python-3.13.105/tina4_python/orm/point.py +421 -0
- tina4_python-3.13.105/tina4_python/query_builder/__init__.py +757 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/__init__.py +42 -7
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/lite_backend.py +16 -2
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/mongo_backend.py +67 -9
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session/__init__.py +4 -1
- tina4_python-3.13.105/tina4_python/sso.py +319 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/swagger/__init__.py +13 -1
- tina4_python-3.13.103/tina4_python/query_builder/__init__.py +0 -445
- {tina4_python-3.13.103 → tina4_python-3.13.105}/.gitignore +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/README.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/CLAUDE.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/HtmlElement.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/Testing.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/ai/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/ai/client.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/api/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/auth/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/cache/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/container/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/context/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/context/chunker.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/cache.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/constants.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/events.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/middleware.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/port_takeover.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/rate_limiter.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/request.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/core/response.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/crud/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/connection.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/database_url.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/firebird.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mongodb.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mssql.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/mysql.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/odbc.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/postgres.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/database/sqlite.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/debug/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/debug/error_overlay.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/metrics.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/plan.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dev_admin/project_index.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/docs.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/docstore/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/dotenv/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/env.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/FROND.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/compiler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/engine.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/frond/parser.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/auth/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/database/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/error-overlay/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/queue/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/rest-api/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/meta.json +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/graphql/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/i18n/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/protocol.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mcp/tools.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/messenger/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/migration/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/migration/runner.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mqtt/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/mqtt/message.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/__feedback/widget.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/css/tina4.css +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/css/tina4.min.css +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/favicon.ico +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/images/logo.svg +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/frond.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/frond.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/js/tina4js.min.js +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/swagger/index.html +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/amqp_url.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/job.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/kafka_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/kafka_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/mongo_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Attachment.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Channel.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/ChannelMember.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Message.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/Workspace.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/models/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/realtime/storage.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/seeder/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/service/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/memcached_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/mongodb_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/redis_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/session_handlers/valkey_handler.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/components/crud.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/python/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/docker/uv/Dockerfile +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/302.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/401.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/403.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/404.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/500.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/502.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/503.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/errors/base.twig +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/frontend/README.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/templates/readme.md +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/test/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/test_client/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/validator/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/websocket/__init__.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/websocket/backplane.py +0 -0
- {tina4_python-3.13.103 → tina4_python-3.13.105}/tina4_python/wsdl/__init__.py +0 -0
|
@@ -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.
|
|
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
|
-
|
|
829
|
-
|
|
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
|
|
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
|
-
#
|
|
832
|
-
#
|
|
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
|
|
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
|
|
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__ = [
|
|
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
|
|
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.
|
|
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
|
|