tina4-python 3.11.13__tar.gz → 3.11.14__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 (150) hide show
  1. {tina4_python-3.11.13 → tina4_python-3.11.14}/PKG-INFO +1 -1
  2. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/__init__.py +1 -1
  3. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/firebird.py +10 -2
  4. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/postgres.py +10 -2
  5. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/dev_admin/__init__.py +557 -0
  6. {tina4_python-3.11.13 → tina4_python-3.11.14}/.gitignore +0 -0
  7. {tina4_python-3.11.13 → tina4_python-3.11.14}/README.md +0 -0
  8. {tina4_python-3.11.13 → tina4_python-3.11.14}/pyproject.toml +0 -0
  9. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/CLAUDE.md +0 -0
  10. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/HtmlElement.py +0 -0
  11. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/Testing.py +0 -0
  12. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/ai/__init__.py +0 -0
  13. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/api/__init__.py +0 -0
  14. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/auth/__init__.py +0 -0
  15. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/cache/__init__.py +0 -0
  16. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/cli/__init__.py +0 -0
  17. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/container/__init__.py +0 -0
  18. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/__init__.py +0 -0
  19. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/cache.py +0 -0
  20. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/constants.py +0 -0
  21. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/events.py +0 -0
  22. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/middleware.py +0 -0
  23. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/rate_limiter.py +0 -0
  24. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/request.py +0 -0
  25. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/response.py +0 -0
  26. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/router.py +0 -0
  27. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/core/server.py +0 -0
  28. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/crud/__init__.py +0 -0
  29. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/__init__.py +0 -0
  30. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/adapter.py +0 -0
  31. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/connection.py +0 -0
  32. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/mongodb.py +0 -0
  33. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/mssql.py +0 -0
  34. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/mysql.py +0 -0
  35. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/odbc.py +0 -0
  36. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/database/sqlite.py +0 -0
  37. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/debug/__init__.py +0 -0
  38. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/debug/error_overlay.py +0 -0
  39. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/dev_admin/metrics.py +0 -0
  40. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/dotenv/__init__.py +0 -0
  41. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/frond/FROND.md +0 -0
  42. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/frond/__init__.py +0 -0
  43. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/frond/engine.py +0 -0
  44. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/auth/meta.json +0 -0
  45. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/auth/src/routes/api/gallery_auth.py +0 -0
  46. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/database/meta.json +0 -0
  47. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/database/src/routes/api/gallery_db.py +0 -0
  48. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/error-overlay/meta.json +0 -0
  49. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/error-overlay/src/routes/api/gallery_crash.py +0 -0
  50. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/orm/meta.json +0 -0
  51. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/orm/src/orm/Product.py +0 -0
  52. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/orm/src/routes/api/gallery_products.py +0 -0
  53. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/queue/meta.json +0 -0
  54. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/queue/src/routes/api/gallery_queue.py +0 -0
  55. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/rest-api/meta.json +0 -0
  56. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/rest-api/src/routes/api/gallery_hello.py +0 -0
  57. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/templates/meta.json +0 -0
  58. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/templates/src/routes/gallery_page.py +0 -0
  59. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/gallery/templates/src/templates/gallery_page.twig +0 -0
  60. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/graphql/__init__.py +0 -0
  61. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/i18n/__init__.py +0 -0
  62. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/mcp/__init__.py +0 -0
  63. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/mcp/protocol.py +0 -0
  64. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/mcp/tools.py +0 -0
  65. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/messenger/__init__.py +0 -0
  66. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/migration/__init__.py +0 -0
  67. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/migration/runner.py +0 -0
  68. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/orm/__init__.py +0 -0
  69. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/orm/fields.py +0 -0
  70. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/orm/model.py +0 -0
  71. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/css/tina4.css +0 -0
  72. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/css/tina4.min.css +0 -0
  73. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/favicon.ico +0 -0
  74. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/images/logo.svg +0 -0
  75. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/images/tina4-logo-icon.webp +0 -0
  76. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/js/frond.min.js +0 -0
  77. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/js/tina4-dev-admin.js +0 -0
  78. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/js/tina4-dev-admin.min.js +0 -0
  79. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/js/tina4.min.js +0 -0
  80. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/js/tina4js.min.js +0 -0
  81. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/swagger/index.html +0 -0
  82. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/public/swagger/oauth2-redirect.html +0 -0
  83. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/query_builder/__init__.py +0 -0
  84. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/__init__.py +0 -0
  85. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/job.py +0 -0
  86. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/kafka_backend.py +0 -0
  87. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/lite_backend.py +0 -0
  88. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/mongo_backend.py +0 -0
  89. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue/rabbitmq_backend.py +0 -0
  90. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue_backends/__init__.py +0 -0
  91. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue_backends/kafka_backend.py +0 -0
  92. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue_backends/mongo_backend.py +0 -0
  93. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/queue_backends/rabbitmq_backend.py +0 -0
  94. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/__init__.py +0 -0
  95. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_alerts.scss +0 -0
  96. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_badges.scss +0 -0
  97. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_buttons.scss +0 -0
  98. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_cards.scss +0 -0
  99. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_forms.scss +0 -0
  100. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_grid.scss +0 -0
  101. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_modals.scss +0 -0
  102. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_nav.scss +0 -0
  103. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_reset.scss +0 -0
  104. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_tables.scss +0 -0
  105. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_typography.scss +0 -0
  106. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_utilities.scss +0 -0
  107. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/_variables.scss +0 -0
  108. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/base.scss +0 -0
  109. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/colors.scss +0 -0
  110. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/scss/tina4css/tina4.scss +0 -0
  111. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/seeder/__init__.py +0 -0
  112. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/service/__init__.py +0 -0
  113. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/session/__init__.py +0 -0
  114. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/session_handlers/__init__.py +0 -0
  115. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/session_handlers/mongodb_handler.py +0 -0
  116. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/session_handlers/redis_handler.py +0 -0
  117. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/session_handlers/valkey_handler.py +0 -0
  118. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/swagger/__init__.py +0 -0
  119. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/components/crud.twig +0 -0
  120. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/docker/distroless/Dockerfile +0 -0
  121. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/docker/poetry/Dockerfile +0 -0
  122. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/docker/python/Dockerfile +0 -0
  123. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/docker/uv/Dockerfile +0 -0
  124. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/302.twig +0 -0
  125. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/401.twig +0 -0
  126. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/403.twig +0 -0
  127. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/404.twig +0 -0
  128. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/500.twig +0 -0
  129. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/502.twig +0 -0
  130. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/503.twig +0 -0
  131. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/errors/base.twig +0 -0
  132. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/frontend/README.md +0 -0
  133. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/templates/readme.md +0 -0
  134. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/test_client/__init__.py +0 -0
  135. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/af/LC_MESSAGES/messages.mo +0 -0
  136. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/af/LC_MESSAGES/messages.po +0 -0
  137. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/en/LC_MESSAGES/messages.mo +0 -0
  138. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/en/LC_MESSAGES/messages.po +0 -0
  139. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/es/LC_MESSAGES/messages.mo +0 -0
  140. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/es/LC_MESSAGES/messages.po +0 -0
  141. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/fr/LC_MESSAGES/messages.mo +0 -0
  142. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/fr/LC_MESSAGES/messages.po +0 -0
  143. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/ja/LC_MESSAGES/messages.mo +0 -0
  144. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/ja/LC_MESSAGES/messages.po +0 -0
  145. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/zh/LC_MESSAGES/messages.mo +0 -0
  146. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/translations/zh/LC_MESSAGES/messages.po +0 -0
  147. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/validator/__init__.py +0 -0
  148. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/websocket/__init__.py +0 -0
  149. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/websocket/backplane.py +0 -0
  150. {tina4_python-3.11.13 → tina4_python-3.11.14}/tina4_python/wsdl/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tina4-python
3
- Version: 3.11.13
3
+ Version: 3.11.14
4
4
  Summary: Tina4 for Python — 54 built-in features, zero dependencies
5
5
  Author-email: Andre van Zuydam <andrevanzuydam@gmail.com>
6
6
  License: MIT
@@ -8,7 +8,7 @@ Tina4 Python v3.0 — Zero-dependency, lightweight web framework.
8
8
 
9
9
  One import, everything works.
10
10
  """
11
- __version__ = "3.11.13"
11
+ __version__ = "3.11.14"
12
12
 
13
13
  # ── Route decorators ──
14
14
  from tina4_python.core.router import ( # noqa: E402, F401
@@ -158,7 +158,7 @@ class FirebirdAdapter(DatabaseAdapter):
158
158
 
159
159
  desc = cursor.description
160
160
  col_names = [d[0].strip().lower() for d in desc] if desc else []
161
- rows = [dict(zip(col_names, row)) for row in cursor.fetchall()]
161
+ rows = [self._decode_blobs(dict(zip(col_names, row))) for row in cursor.fetchall()]
162
162
 
163
163
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
164
164
 
@@ -171,7 +171,15 @@ class FirebirdAdapter(DatabaseAdapter):
171
171
  if row is None:
172
172
  return None
173
173
  col_names = [d[0].strip().lower() for d in desc] if desc else []
174
- return dict(zip(col_names, row))
174
+ return self._decode_blobs(dict(zip(col_names, row)))
175
+
176
+ @staticmethod
177
+ def _decode_blobs(row: dict) -> dict:
178
+ """Ensure Firebird BLOB columns are proper bytes, not memoryview."""
179
+ for key, value in row.items():
180
+ if isinstance(value, memoryview):
181
+ row[key] = bytes(value)
182
+ return row
175
183
 
176
184
  def insert(self, table: str, data: dict) -> DatabaseResult:
177
185
  columns = ", ".join(data.keys())
@@ -119,7 +119,7 @@ class PostgreSQLAdapter(DatabaseAdapter):
119
119
  paginated_sql = f"{sql} LIMIT %s OFFSET %s"
120
120
  paginated_params = (params or []) + [limit, offset]
121
121
  cursor.execute(paginated_sql, paginated_params)
122
- rows = [dict(row) for row in cursor.fetchall()]
122
+ rows = [self._decode_blobs(dict(row)) for row in cursor.fetchall()]
123
123
 
124
124
  return DatabaseResult(records=rows, count=total, limit=limit, offset=offset, sql=sql, adapter=self)
125
125
 
@@ -130,7 +130,15 @@ class PostgreSQLAdapter(DatabaseAdapter):
130
130
  cursor = self._conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
131
131
  cursor.execute(sql, params or [])
132
132
  row = cursor.fetchone()
133
- return dict(row) if row else None
133
+ return self._decode_blobs(dict(row)) if row else None
134
+
135
+ @staticmethod
136
+ def _decode_blobs(row: dict) -> dict:
137
+ """Ensure binary columns (bytea) are proper bytes, not memoryview."""
138
+ for key, value in row.items():
139
+ if isinstance(value, memoryview):
140
+ row[key] = bytes(value)
141
+ return row
134
142
 
135
143
  def insert(self, table: str, data: dict) -> DatabaseResult:
136
144
  columns = ", ".join(data.keys())
@@ -338,6 +338,16 @@ def get_api_handlers() -> dict:
338
338
  "/__dev/api/metrics/full": ("GET", _api_metrics_full),
339
339
  "/__dev/api/metrics/file": ("GET", _api_metrics_file),
340
340
  "/__dev/api/graphql/schema": ("GET", _api_graphql_schema),
341
+ # ── Editor endpoints ──
342
+ "/__dev/api/files": ("GET", _api_files),
343
+ "/__dev/api/file": ("GET", _api_file_read),
344
+ "/__dev/api/file/save": ("POST", _api_file_save),
345
+ "/__dev/api/file/raw": ("GET", _api_file_raw),
346
+ "/__dev/api/file/rename": ("POST", _api_file_rename),
347
+ "/__dev/api/file/delete": ("POST", _api_file_delete),
348
+ "/__dev/api/deps/search": ("GET", _api_deps_search),
349
+ "/__dev/api/deps/install": ("POST", _api_deps_install),
350
+ "/__dev/api/git/status": ("GET", _api_git_status),
341
351
  }
342
352
 
343
353
 
@@ -1499,5 +1509,552 @@ function tina4VersionModal(){{
1499
1509
  </script>"""
1500
1510
 
1501
1511
 
1512
+ # ── Editor API endpoints ──────────────────────────────────────
1513
+
1514
+ async def _api_files(request, response):
1515
+ """List files in a directory with git status.
1516
+
1517
+ Query params:
1518
+ path — relative directory path (default: project root)
1519
+ """
1520
+ import os, subprocess
1521
+ rel = (request.params.get("path") or "").strip("/")
1522
+ base = os.getcwd()
1523
+ target = os.path.normpath(os.path.join(base, rel))
1524
+
1525
+ # Security: must stay within project root
1526
+ if not target.startswith(base):
1527
+ return response({"error": "Path outside project"}, 403)
1528
+
1529
+ if not os.path.isdir(target):
1530
+ return response({"error": "Not a directory"}, 404)
1531
+
1532
+ # Find git root (may differ from cwd)
1533
+ git_root = base
1534
+ try:
1535
+ out = subprocess.run(
1536
+ ["git", "rev-parse", "--show-toplevel"],
1537
+ capture_output=True, text=True, cwd=base, timeout=5
1538
+ )
1539
+ if out.returncode == 0:
1540
+ git_root = out.stdout.strip().replace("\\", "/")
1541
+ except Exception:
1542
+ pass
1543
+
1544
+ # Prefix to prepend to entry_rel paths to match git's paths
1545
+ cwd_in_git = os.path.relpath(base, git_root).replace("\\", "/")
1546
+ if cwd_in_git == ".":
1547
+ cwd_in_git = ""
1548
+ else:
1549
+ cwd_in_git += "/"
1550
+
1551
+ # Get git status for the project
1552
+ git_status = {}
1553
+ try:
1554
+ out = subprocess.run(
1555
+ ["git", "status", "--porcelain", "-uall"],
1556
+ capture_output=True, text=True, cwd=base, timeout=5
1557
+ )
1558
+ for line in out.stdout.strip().split("\n"):
1559
+ if len(line) >= 4:
1560
+ status_code = line[:2].strip()
1561
+ file_path = line[3:].strip()
1562
+ git_status[file_path] = status_code
1563
+ except Exception:
1564
+ pass
1565
+
1566
+ # Get current branch
1567
+ branch = ""
1568
+ try:
1569
+ out = subprocess.run(
1570
+ ["git", "branch", "--show-current"],
1571
+ capture_output=True, text=True, cwd=base, timeout=5
1572
+ )
1573
+ branch = out.stdout.strip()
1574
+ except Exception:
1575
+ pass
1576
+
1577
+ entries = []
1578
+ try:
1579
+ for name in sorted(os.listdir(target)):
1580
+ full = os.path.join(target, name)
1581
+ entry_rel = os.path.relpath(full, base).replace("\\", "/")
1582
+
1583
+ # Skip hidden dirs and noise
1584
+ if name.startswith(".") and name not in (".env", ".env.example"):
1585
+ continue
1586
+ if name in ("__pycache__", "node_modules", "vendor", ".git",
1587
+ "venv", ".venv", "dist", "target", ".tina4"):
1588
+ continue
1589
+
1590
+ is_dir = os.path.isdir(full)
1591
+
1592
+ # Git path = cwd_in_git + entry_rel (what git reports)
1593
+ git_path = cwd_in_git + entry_rel
1594
+
1595
+ # Git status for this entry
1596
+ status = "clean"
1597
+ if git_path in git_status:
1598
+ code = git_status[git_path]
1599
+ if code == "??":
1600
+ status = "untracked"
1601
+ elif "M" in code:
1602
+ status = "modified"
1603
+ elif "A" in code:
1604
+ status = "added"
1605
+ elif "D" in code:
1606
+ status = "deleted"
1607
+ elif is_dir:
1608
+ # Check if any child is dirty
1609
+ dir_prefix = git_path + "/"
1610
+ for gf, gc in git_status.items():
1611
+ if gf.startswith(dir_prefix):
1612
+ if gc == "??":
1613
+ status = "untracked"
1614
+ else:
1615
+ status = "modified"
1616
+ break
1617
+
1618
+ # Check if directory has children (for arrow display)
1619
+ has_children = False
1620
+ if is_dir:
1621
+ try:
1622
+ contents = os.listdir(full)
1623
+ has_children = any(
1624
+ not n.startswith(".") and n not in (
1625
+ "__pycache__", "node_modules", "vendor",
1626
+ ".git", "venv", ".venv", "dist", "target", ".tina4"
1627
+ ) for n in contents
1628
+ )
1629
+ except PermissionError:
1630
+ pass
1631
+
1632
+ entries.append({
1633
+ "name": name,
1634
+ "path": entry_rel,
1635
+ "is_dir": is_dir,
1636
+ "has_children": has_children if is_dir else None,
1637
+ "git_status": status,
1638
+ "size": os.path.getsize(full) if not is_dir else None,
1639
+ })
1640
+ except PermissionError:
1641
+ return response({"error": "Permission denied"}, 403)
1642
+
1643
+ return response({
1644
+ "path": rel or ".",
1645
+ "branch": branch,
1646
+ "entries": entries,
1647
+ })
1648
+
1649
+
1650
+ async def _api_file_read(request, response):
1651
+ """Read a file's content.
1652
+
1653
+ Query params:
1654
+ path — relative file path
1655
+ """
1656
+ import os
1657
+ rel = (request.params.get("path") or "").strip("/")
1658
+ if not rel:
1659
+ return response({"error": "path required"}, 400)
1660
+
1661
+ base = os.getcwd()
1662
+ target = os.path.normpath(os.path.join(base, rel))
1663
+
1664
+ if not target.startswith(base):
1665
+ return response({"error": "Path outside project"}, 403)
1666
+
1667
+ if not os.path.isfile(target):
1668
+ return response({"error": "File not found"}, 404)
1669
+
1670
+ # Size guard: don't load huge files into JSON
1671
+ size = os.path.getsize(target)
1672
+ if size > 2 * 1024 * 1024: # 2MB
1673
+ return response({"error": "File too large", "size": size}, 413)
1674
+
1675
+ try:
1676
+ with open(target, "r", encoding="utf-8", errors="replace") as f:
1677
+ content = f.read()
1678
+ except Exception as e:
1679
+ return response({"error": str(e)}, 500)
1680
+
1681
+ # Detect language from extension
1682
+ ext = os.path.splitext(rel)[1].lower()
1683
+ # Also detect Dockerfile (no extension)
1684
+ basename = os.path.basename(rel)
1685
+ if basename.lower() in ("dockerfile", "dockerfile.dev", "dockerfile.prod"):
1686
+ return response({
1687
+ "path": rel, "content": content,
1688
+ "language": "dockerfile", "size": size,
1689
+ })
1690
+
1691
+ lang_map = {
1692
+ ".py": "python", ".php": "php", ".rb": "ruby",
1693
+ ".ts": "typescript", ".js": "javascript", ".jsx": "javascript",
1694
+ ".tsx": "typescript", ".json": "json", ".html": "html",
1695
+ ".twig": "html", ".css": "css", ".scss": "css",
1696
+ ".md": "markdown", ".sql": "sql", ".yaml": "yaml",
1697
+ ".yml": "yaml", ".toml": "toml", ".xml": "html",
1698
+ ".env": "env", ".env.example": "env",
1699
+ ".sh": "shell", ".bash": "shell",
1700
+ ".bat": "shell", ".cmd": "shell", ".ps1": "shell",
1701
+ ".rs": "rust", ".go": "go", ".java": "java",
1702
+ ".txt": "text", ".csv": "text", ".log": "text",
1703
+ ".gemspec": "ruby", ".rake": "ruby",
1704
+ ".svg": "svg",
1705
+ }
1706
+
1707
+ return response({
1708
+ "path": rel,
1709
+ "content": content,
1710
+ "language": lang_map.get(ext, "text"),
1711
+ "size": size,
1712
+ })
1713
+
1714
+
1715
+ async def _api_file_raw(request, response):
1716
+ """Serve a raw file with correct content-type (for image preview etc).
1717
+
1718
+ Query params:
1719
+ path — relative file path
1720
+ """
1721
+ import os, mimetypes
1722
+ rel = (request.params.get("path") or "").strip("/")
1723
+ if not rel:
1724
+ return response({"error": "path required"}, 400)
1725
+
1726
+ base = os.getcwd()
1727
+ target = os.path.normpath(os.path.join(base, rel))
1728
+
1729
+ if not target.startswith(base):
1730
+ return response({"error": "Path outside project"}, 403)
1731
+ if not os.path.isfile(target):
1732
+ return response({"error": "File not found"}, 404)
1733
+
1734
+ # Size guard
1735
+ size = os.path.getsize(target)
1736
+ if size > 10 * 1024 * 1024:
1737
+ return response({"error": "File too large"}, 413)
1738
+
1739
+ content_type = mimetypes.guess_type(target)[0] or "application/octet-stream"
1740
+
1741
+ try:
1742
+ with open(target, "rb") as f:
1743
+ data = f.read()
1744
+ except Exception as e:
1745
+ return response({"error": str(e)}, 500)
1746
+
1747
+ from tina4_python.core.response import Response
1748
+ Response.add_header("Content-Type", content_type)
1749
+ Response.add_header("Cache-Control", "no-cache")
1750
+ # Return raw bytes — the response handler will detect binary
1751
+ import base64
1752
+ return response({
1753
+ "_raw": True,
1754
+ "data": base64.b64encode(data).decode("ascii"),
1755
+ "content_type": content_type,
1756
+ "size": size,
1757
+ })
1758
+
1759
+
1760
+ async def _api_file_save(request, response):
1761
+ """Save content to a file.
1762
+
1763
+ Body: { "path": "...", "content": "..." }
1764
+ """
1765
+ import os
1766
+ body = request.body or {}
1767
+ rel = (body.get("path") or "").strip("/")
1768
+ content = body.get("content")
1769
+
1770
+ if not rel:
1771
+ return response({"error": "path required"}, 400)
1772
+ if content is None:
1773
+ return response({"error": "content required"}, 400)
1774
+
1775
+ base = os.getcwd()
1776
+ target = os.path.normpath(os.path.join(base, rel))
1777
+
1778
+ if not target.startswith(base):
1779
+ return response({"error": "Path outside project"}, 403)
1780
+
1781
+ # Don't allow overwriting framework internals
1782
+ if "tina4_python/" in rel or "vendor/" in rel or "node_modules/" in rel:
1783
+ return response({"error": "Cannot write to framework directories"}, 403)
1784
+
1785
+ try:
1786
+ os.makedirs(os.path.dirname(target), exist_ok=True)
1787
+ with open(target, "w", encoding="utf-8", newline="") as f:
1788
+ f.write(content)
1789
+ except Exception as e:
1790
+ return response({"error": str(e)}, 500)
1791
+
1792
+ return response({"saved": True, "path": rel, "size": len(content)})
1793
+
1794
+
1795
+ async def _api_file_rename(request, response):
1796
+ """Rename/move a file or directory.
1797
+
1798
+ Body: { "from": "old/path", "to": "new/path" }
1799
+ """
1800
+ import os, shutil
1801
+ body = request.body or {}
1802
+ from_rel = (body.get("from") or "").strip("/")
1803
+ to_rel = (body.get("to") or "").strip("/")
1804
+ if not from_rel or not to_rel:
1805
+ return response({"error": "from and to required"}, 400)
1806
+
1807
+ base = os.getcwd()
1808
+ from_abs = os.path.normpath(os.path.join(base, from_rel))
1809
+ to_abs = os.path.normpath(os.path.join(base, to_rel))
1810
+
1811
+ if not from_abs.startswith(base) or not to_abs.startswith(base):
1812
+ return response({"error": "Path outside project"}, 403)
1813
+ if not os.path.exists(from_abs):
1814
+ return response({"error": "Source not found"}, 404)
1815
+
1816
+ try:
1817
+ os.makedirs(os.path.dirname(to_abs), exist_ok=True)
1818
+ shutil.move(from_abs, to_abs)
1819
+ except Exception as e:
1820
+ return response({"error": str(e)}, 500)
1821
+
1822
+ return response({"renamed": True, "from": from_rel, "to": to_rel})
1823
+
1824
+
1825
+ async def _api_file_delete(request, response):
1826
+ """Delete a file or directory.
1827
+
1828
+ Body: { "path": "...", "is_dir": false }
1829
+ """
1830
+ import os, shutil
1831
+ body = request.body or {}
1832
+ rel = (body.get("path") or "").strip("/")
1833
+ is_dir = body.get("is_dir", False)
1834
+
1835
+ if not rel:
1836
+ return response({"error": "path required"}, 400)
1837
+
1838
+ base = os.getcwd()
1839
+ target = os.path.normpath(os.path.join(base, rel))
1840
+
1841
+ if not target.startswith(base):
1842
+ return response({"error": "Path outside project"}, 403)
1843
+ if not os.path.exists(target):
1844
+ return response({"error": "Not found"}, 404)
1845
+
1846
+ # Safety: don't delete project root or key config
1847
+ if rel in (".", "..", "app.py", "index.php", "app.rb", "app.ts",
1848
+ "composer.json", "Gemfile", "package.json", "pyproject.toml"):
1849
+ return response({"error": "Cannot delete project root files"}, 403)
1850
+
1851
+ try:
1852
+ if is_dir:
1853
+ shutil.rmtree(target)
1854
+ else:
1855
+ os.remove(target)
1856
+ except Exception as e:
1857
+ return response({"error": str(e)}, 500)
1858
+
1859
+ return response({"deleted": True, "path": rel})
1860
+
1861
+
1862
+ async def _api_deps_search(request, response):
1863
+ """Search package registries.
1864
+
1865
+ Query params: q (search term), registry (pypi|npm|packagist|rubygems|crates)
1866
+ """
1867
+ import urllib.request, json
1868
+ query = request.params.get("q", "").strip()
1869
+ registry = request.params.get("registry", "pypi")
1870
+ if not query:
1871
+ return response({"packages": []})
1872
+
1873
+ packages = []
1874
+ try:
1875
+ if registry == "pypi":
1876
+ url = f"https://pypi.org/pypi/{query}/json"
1877
+ try:
1878
+ req = urllib.request.Request(url, headers={"User-Agent": "Tina4-DevAdmin/1.0"})
1879
+ with urllib.request.urlopen(req, timeout=5) as r:
1880
+ data = json.loads(r.read())
1881
+ info = data.get("info", {})
1882
+ packages.append({
1883
+ "name": info.get("name", query),
1884
+ "description": (info.get("summary") or "")[:120],
1885
+ "version": info.get("version", ""),
1886
+ })
1887
+ except Exception:
1888
+ # Fallback: search API
1889
+ url = f"https://pypi.org/search/?q={urllib.parse.quote(query)}&o="
1890
+ # Simple search not available via JSON — just return empty
1891
+ pass
1892
+
1893
+ # Also try search via simple JSON API
1894
+ if not packages:
1895
+ search_url = f"https://pypi.org/simple/"
1896
+ # PyPI doesn't have a search JSON API, use the name directly
1897
+ pass
1898
+
1899
+ elif registry == "npm":
1900
+ url = f"https://registry.npmjs.org/-/v1/search?text={urllib.parse.quote(query)}&size=10"
1901
+ req = urllib.request.Request(url, headers={"User-Agent": "Tina4-DevAdmin/1.0"})
1902
+ with urllib.request.urlopen(req, timeout=5) as r:
1903
+ data = json.loads(r.read())
1904
+ for obj in data.get("objects", []):
1905
+ pkg = obj.get("package", {})
1906
+ packages.append({
1907
+ "name": pkg.get("name", ""),
1908
+ "description": (pkg.get("description") or "")[:120],
1909
+ "version": pkg.get("version", ""),
1910
+ })
1911
+
1912
+ elif registry == "packagist":
1913
+ url = f"https://packagist.org/search.json?q={urllib.parse.quote(query)}&per_page=10"
1914
+ req = urllib.request.Request(url, headers={"User-Agent": "Tina4-DevAdmin/1.0"})
1915
+ with urllib.request.urlopen(req, timeout=5) as r:
1916
+ data = json.loads(r.read())
1917
+ for pkg in data.get("results", []):
1918
+ packages.append({
1919
+ "name": pkg.get("name", ""),
1920
+ "description": (pkg.get("description") or "")[:120],
1921
+ "version": "",
1922
+ })
1923
+
1924
+ elif registry == "rubygems":
1925
+ url = f"https://rubygems.org/api/v1/search.json?query={urllib.parse.quote(query)}&page=1"
1926
+ req = urllib.request.Request(url, headers={"User-Agent": "Tina4-DevAdmin/1.0"})
1927
+ with urllib.request.urlopen(req, timeout=5) as r:
1928
+ data = json.loads(r.read())
1929
+ for pkg in data[:10]:
1930
+ packages.append({
1931
+ "name": pkg.get("name", ""),
1932
+ "description": (pkg.get("info") or "")[:120],
1933
+ "version": pkg.get("version", ""),
1934
+ })
1935
+
1936
+ elif registry == "crates":
1937
+ url = f"https://crates.io/api/v1/crates?q={urllib.parse.quote(query)}&per_page=10"
1938
+ req = urllib.request.Request(url, headers={"User-Agent": "Tina4-DevAdmin/1.0"})
1939
+ with urllib.request.urlopen(req, timeout=5) as r:
1940
+ data = json.loads(r.read())
1941
+ for pkg in data.get("crates", []):
1942
+ packages.append({
1943
+ "name": pkg.get("name", ""),
1944
+ "description": (pkg.get("description") or "")[:120],
1945
+ "version": pkg.get("max_version", ""),
1946
+ })
1947
+
1948
+ except Exception as e:
1949
+ return response({"packages": [], "error": str(e)})
1950
+
1951
+ return response({"packages": packages})
1952
+
1953
+
1954
+ async def _api_deps_install(request, response):
1955
+ """Install a package dependency.
1956
+
1957
+ Body: { "name": "...", "version": "...", "registry": "pypi|npm|...", "file": "..." }
1958
+ """
1959
+ import subprocess
1960
+ body = request.body or {}
1961
+ name = body.get("name", "").strip()
1962
+ version = body.get("version", "").strip()
1963
+ registry = body.get("registry", "pypi")
1964
+
1965
+ if not name:
1966
+ return response({"error": "name required"}, 400)
1967
+
1968
+ try:
1969
+ if registry == "pypi":
1970
+ pkg = f"{name}>={version}" if version else name
1971
+ result = subprocess.run(
1972
+ ["pip", "install", pkg],
1973
+ capture_output=True, text=True, timeout=60
1974
+ )
1975
+ if result.returncode != 0:
1976
+ return response({"error": result.stderr.strip()}, 500)
1977
+ return response({"message": f"Installed {name} {version}".strip(), "output": result.stdout})
1978
+
1979
+ elif registry == "npm":
1980
+ pkg = f"{name}@{version}" if version else name
1981
+ result = subprocess.run(
1982
+ ["npm", "install", pkg],
1983
+ capture_output=True, text=True, timeout=60
1984
+ )
1985
+ if result.returncode != 0:
1986
+ return response({"error": result.stderr.strip()}, 500)
1987
+ return response({"message": f"Installed {name}", "output": result.stdout})
1988
+
1989
+ elif registry == "packagist":
1990
+ pkg = f"{name}:{version}" if version else name
1991
+ result = subprocess.run(
1992
+ ["composer", "require", pkg],
1993
+ capture_output=True, text=True, timeout=60
1994
+ )
1995
+ if result.returncode != 0:
1996
+ return response({"error": result.stderr.strip()}, 500)
1997
+ return response({"message": f"Installed {name}", "output": result.stdout})
1998
+
1999
+ elif registry == "rubygems":
2000
+ result = subprocess.run(
2001
+ ["gem", "install", name, "-v", version] if version else ["gem", "install", name],
2002
+ capture_output=True, text=True, timeout=60
2003
+ )
2004
+ if result.returncode != 0:
2005
+ return response({"error": result.stderr.strip()}, 500)
2006
+ return response({"message": f"Installed {name}", "output": result.stdout})
2007
+
2008
+ else:
2009
+ return response({"error": f"Unsupported registry: {registry}"}, 400)
2010
+
2011
+ except subprocess.TimeoutExpired:
2012
+ return response({"error": "Install timed out (60s)"}, 500)
2013
+ except FileNotFoundError as e:
2014
+ return response({"error": f"Package manager not found: {e}"}, 500)
2015
+ except Exception as e:
2016
+ return response({"error": str(e)}, 500)
2017
+
2018
+
2019
+ async def _api_git_status(request, response):
2020
+ """Return git branch, changed files, and summary."""
2021
+ import os, subprocess
2022
+ base = os.getcwd()
2023
+
2024
+ result = {"branch": "", "changes": [], "clean": True}
2025
+
2026
+ try:
2027
+ out = subprocess.run(
2028
+ ["git", "branch", "--show-current"],
2029
+ capture_output=True, text=True, cwd=base, timeout=5
2030
+ )
2031
+ result["branch"] = out.stdout.strip()
2032
+ except Exception:
2033
+ return response(result)
2034
+
2035
+ try:
2036
+ out = subprocess.run(
2037
+ ["git", "status", "--porcelain", "-uall"],
2038
+ capture_output=True, text=True, cwd=base, timeout=5
2039
+ )
2040
+ for line in out.stdout.strip().split("\n"):
2041
+ if len(line) >= 4:
2042
+ code = line[:2].strip()
2043
+ path = line[3:].strip()
2044
+ status = "modified"
2045
+ if code == "??":
2046
+ status = "untracked"
2047
+ elif "A" in code:
2048
+ status = "added"
2049
+ elif "D" in code:
2050
+ status = "deleted"
2051
+ result["changes"].append({"path": path, "status": status})
2052
+ result["clean"] = len(result["changes"]) == 0
2053
+ except Exception:
2054
+ pass
2055
+
2056
+ return response(result)
2057
+
2058
+
1502
2059
  __all__ = ["MessageLog", "RequestInspector", "BrokenTracker",
1503
2060
  "get_api_handlers", "render_dev_toolbar"]
File without changes