databricks-tellr-app 0.4.2.dev9__tar.gz → 0.4.2.dev11__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 (167) hide show
  1. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/PKG-INFO +1 -1
  2. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app.egg-info/PKG-INFO +1 -1
  3. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/pyproject.toml +1 -1
  4. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/design_systems.py +37 -2
  5. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/design_system_service.py +255 -46
  6. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/design_system_templates.py +31 -3
  7. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/README.md +0 -0
  8. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app/__init__.py +0 -0
  9. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app/run.py +0 -0
  10. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app.egg-info/SOURCES.txt +0 -0
  11. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app.egg-info/dependency_links.txt +0 -0
  12. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app.egg-info/requires.txt +0 -0
  13. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/databricks_tellr_app.egg-info/top_level.txt +0 -0
  14. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/setup.cfg +0 -0
  15. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/setup.py +0 -0
  16. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/__init__.py +0 -0
  17. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/__init__.py +0 -0
  18. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/_otel_bootstrap.py +0 -0
  19. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/fixtures/tour_demo_deck.json +0 -0
  20. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/main.py +0 -0
  21. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/mcp_auth.py +0 -0
  22. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/mcp_server.py +0 -0
  23. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/middleware/__init__.py +0 -0
  24. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/middleware/csrf.py +0 -0
  25. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/middleware/request_logging.py +0 -0
  26. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/middleware/security_headers.py +0 -0
  27. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/__init__.py +0 -0
  28. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/_authz.py +0 -0
  29. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/admin.py +0 -0
  30. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/admin_usage.py +0 -0
  31. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/agent_config.py +0 -0
  32. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/chat.py +0 -0
  33. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/deck_contributors.py +0 -0
  34. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/export.py +0 -0
  35. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/feedback.py +0 -0
  36. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/google_slides.py +0 -0
  37. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/images.py +0 -0
  38. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/local_version.py +0 -0
  39. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/profiles.py +0 -0
  40. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/sessions.py +0 -0
  41. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/__init__.py +0 -0
  42. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/contributors.py +0 -0
  43. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/deck_prompts.py +0 -0
  44. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/identities.py +0 -0
  45. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/settings/slide_styles.py +0 -0
  46. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/setup.py +0 -0
  47. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/slides.py +0 -0
  48. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/tools.py +0 -0
  49. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/tour.py +0 -0
  50. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/verification.py +0 -0
  51. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/routes/version.py +0 -0
  52. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/__init__.py +0 -0
  53. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/agent_config.py +0 -0
  54. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/feedback.py +0 -0
  55. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/requests.py +0 -0
  56. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/responses.py +0 -0
  57. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/settings/__init__.py +0 -0
  58. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/settings/requests.py +0 -0
  59. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/settings/responses.py +0 -0
  60. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/schemas/streaming.py +0 -0
  61. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/__init__.py +0 -0
  62. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/chat_service.py +0 -0
  63. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/export_job_queue.py +0 -0
  64. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/feedback_service.py +0 -0
  65. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/job_queue.py +0 -0
  66. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/session_manager.py +0 -0
  67. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/session_naming.py +0 -0
  68. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/usage_events.py +0 -0
  69. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/services/usage_service.py +0 -0
  70. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/utils/__init__.py +0 -0
  71. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/api/utils/validation.py +0 -0
  72. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/__init__.py +0 -0
  73. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/config_loader.py +0 -0
  74. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/context_utils.py +0 -0
  75. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/database.py +0 -0
  76. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/databricks_client.py +0 -0
  77. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/defaults.py +0 -0
  78. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/encryption.py +0 -0
  79. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/init_default_profile.py +0 -0
  80. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/lakebase.py +0 -0
  81. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/migrate_profiles_to_agent_config.py +0 -0
  82. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/mlflow_agent_spans.py +0 -0
  83. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/mlflow_tracing.py +0 -0
  84. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/otel_logging.py +0 -0
  85. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/permission_context.py +0 -0
  86. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/prompt_modules.py +0 -0
  87. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/settings_db.py +0 -0
  88. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/core/user_context.py +0 -0
  89. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/__init__.py +0 -0
  90. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/__init__.py +0 -0
  91. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/deck_contributor.py +0 -0
  92. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/design_system.py +0 -0
  93. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/encryption_key.py +0 -0
  94. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/feedback.py +0 -0
  95. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/genie_space.py +0 -0
  96. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/google_global_credentials.py +0 -0
  97. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/google_oauth_token.py +0 -0
  98. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/identity.py +0 -0
  99. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/image.py +0 -0
  100. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/profile.py +0 -0
  101. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/profile_contributor.py +0 -0
  102. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/prompts.py +0 -0
  103. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/request_log.py +0 -0
  104. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/session.py +0 -0
  105. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/slide_deck_prompt.py +0 -0
  106. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/slide_style_library.py +0 -0
  107. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/usage_event.py +0 -0
  108. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/models/user_preference.py +0 -0
  109. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/database/types.py +0 -0
  110. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/domain/__init__.py +0 -0
  111. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/domain/slide.py +0 -0
  112. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/domain/slide_deck.py +0 -0
  113. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/__init__.py +0 -0
  114. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/agent.py +0 -0
  115. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/agent_factory.py +0 -0
  116. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/config_service.py +0 -0
  117. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/config_validator.py +0 -0
  118. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/__init__.py +0 -0
  119. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/ast_guard.py +0 -0
  120. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/codeprep.py +0 -0
  121. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/gslides_runner.py +0 -0
  122. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/jail.py +0 -0
  123. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/pptx_runner.py +0 -0
  124. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/converter_jail/protocol.py +0 -0
  125. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/design_system_compiler.py +0 -0
  126. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/drive_uploader.py +0 -0
  127. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/evaluation/__init__.py +0 -0
  128. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/evaluation/llm_judge.py +0 -0
  129. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/genie_service.py +0 -0
  130. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/google_slides_auth.py +0 -0
  131. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/google_slides_prompts_defaults.py +0 -0
  132. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/html_to_google_slides.py +0 -0
  133. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/html_to_pptx.py +0 -0
  134. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/identity_provider.py +0 -0
  135. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/identity_providers/__init__.py +0 -0
  136. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/identity_providers/account_provider.py +0 -0
  137. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/identity_providers/local_provider.py +0 -0
  138. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/identity_providers/workspace_provider.py +0 -0
  139. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/image_service.py +0 -0
  140. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/image_tools.py +0 -0
  141. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/permission_service.py +0 -0
  142. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/pptx_from_html_huashu.py +0 -0
  143. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/pptx_from_records.py +0 -0
  144. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/pptx_prompts_defaults.py +0 -0
  145. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/profile_service.py +0 -0
  146. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/streaming_callback.py +0 -0
  147. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/__init__.py +0 -0
  148. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/agent_bricks_tool.py +0 -0
  149. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/ds_asset_tool.py +0 -0
  150. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/genie_tool.py +0 -0
  151. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/mcp_tool.py +0 -0
  152. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/model_endpoint_tool.py +0 -0
  153. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/tools/vector_tool.py +0 -0
  154. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/services/validator.py +0 -0
  155. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/__init__.py +0 -0
  156. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/css_utils.py +0 -0
  157. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/ds_asset_utils.py +0 -0
  158. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/error_handling.py +0 -0
  159. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/html_safety.py +0 -0
  160. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/html_utils.py +0 -0
  161. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/image_utils.py +0 -0
  162. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/js_validator.py +0 -0
  163. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/logging_config.py +0 -0
  164. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/pi_filter.py +0 -0
  165. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/slide_hash.py +0 -0
  166. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/spotlight.py +0 -0
  167. {databricks_tellr_app-0.4.2.dev9 → databricks_tellr_app-0.4.2.dev11}/src/utils/text_caps.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr-app
3
- Version: 0.4.2.dev9
3
+ Version: 0.4.2.dev11
4
4
  Summary: Tellr application package for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: databricks-tellr-app
3
- Version: 0.4.2.dev9
3
+ Version: 0.4.2.dev11
4
4
  Summary: Tellr application package for Databricks Apps
5
5
  Requires-Python: >=3.10
6
6
  Description-Content-Type: text/markdown
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "databricks-tellr-app"
7
- version = "0.4.2.dev9"
7
+ version = "0.4.2.dev11"
8
8
  description = "Tellr application package for Databricks Apps"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -172,6 +172,28 @@ class DesignSystemDetail(DesignSystemSummary):
172
172
  assets: List[AssetOut]
173
173
 
174
174
 
175
+ class ImportWarningOut(BaseModel):
176
+ """One bundle entry the import ignored, and why."""
177
+ path: str
178
+ reason: str
179
+
180
+
181
+ class DesignSystemImportResult(DesignSystemDetail):
182
+ """The /import response: the imported detail PLUS anything that was ignored.
183
+
184
+ Additive by design. Some entries are individually unusable without making the
185
+ bundle unusable (an unsniffable template screenshot, a non-UTF-8 token source);
186
+ those are dropped so one junk file cannot cost the whole upload. Reporting them
187
+ only to the server log told the user the import had succeeded and left them to
188
+ discover a missing thumbnail themselves.
189
+
190
+ A NON-FATAL list on a 201, and its own model rather than a field on
191
+ :class:`DesignSystemDetail`, so the GET/create/update responses are byte-for-byte
192
+ unchanged and an existing /import client simply ignores a field it does not read.
193
+ """
194
+ warnings: List[ImportWarningOut] = []
195
+
196
+
175
197
  class DesignSystemCreate(BaseModel):
176
198
  """Structured (in-app) create — thin. Assets arrive via /import."""
177
199
  # UNCAPPED, matching the unbounded ``design_system.name`` column; ``min_length``
@@ -496,7 +518,11 @@ def list_design_systems(
496
518
  )
497
519
 
498
520
 
499
- @router.post("/import", response_model=DesignSystemDetail, status_code=status.HTTP_201_CREATED)
521
+ @router.post(
522
+ "/import",
523
+ response_model=DesignSystemImportResult,
524
+ status_code=status.HTTP_201_CREATED,
525
+ )
500
526
  async def import_design_system(
501
527
  file: UploadFile = File(...),
502
528
  name: Optional[str] = Form(None),
@@ -517,14 +543,23 @@ async def import_design_system(
517
543
  detail=f"Upload exceeds the maximum bundle size of {MAX_BUNDLE_SIZE_BYTES} bytes",
518
544
  )
519
545
  try:
546
+ # Filled with the entries the import dropped without failing, so the caller
547
+ # learns about them instead of only the server log doing so.
548
+ dropped: list[design_system_service.BundleImportWarning] = []
520
549
  ds = design_system_service.import_bundle(
521
550
  db,
522
551
  zip_bytes=content,
523
552
  user=_current_user(),
524
553
  name_override=name,
525
554
  source_filename=file.filename,
555
+ warnings=dropped,
556
+ )
557
+ return DesignSystemImportResult(
558
+ **_detail(ds).model_dump(),
559
+ warnings=[
560
+ ImportWarningOut(path=w.path, reason=w.reason) for w in dropped
561
+ ],
526
562
  )
527
- return _detail(ds)
528
563
  except DesignSystemNameConflictError as e:
529
564
  db.rollback()
530
565
  raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e))
@@ -11,9 +11,13 @@ Lakebase rows:
11
11
  custom properties are parsed as an ADDITIONAL token source.
12
12
  - ``fonts/**`` and ``assets/**`` — binary files stored as ``design_system_asset``
13
13
  rows (bytes in-DB, following the ``image_assets`` pattern). ``preview*`` files
14
- under ``assets/`` are reference-only and skipped; a template folder's
15
- ``templates/<folder>/preview.*`` screenshot IS stored (kind ``template_shot``,
16
- v1 Phase 4) so the template picker can serve thumbnails.
14
+ under ``assets/`` are reference-only and skipped; a template folder's screenshot
15
+ IS stored (kind ``template_shot``, v1 Phase 4) so the template picker can serve
16
+ thumbnails. Both shipped shapes count: ``templates/<folder>/preview.<ext>`` and
17
+ the DOT-PREFIXED, EXTENSION-LESS ``templates/<folder>/.thumbnail`` a real export
18
+ writes — the latter is allowlisted past the general dotfile skip and has its
19
+ media type SNIFFED from its magic bytes (unknown content is refused, never
20
+ stored under a guessed type).
17
21
 
18
22
  v1 Phase 1 ("import foundation") extends the importer WITHOUT changing the
19
23
  generation seam:
@@ -55,7 +59,7 @@ import logging
55
59
  import mimetypes
56
60
  import re
57
61
  import zipfile
58
- from typing import Any, Optional
62
+ from typing import Any, NamedTuple, Optional
59
63
 
60
64
  from sqlalchemy.orm import Session, defer
61
65
 
@@ -74,6 +78,22 @@ logger = logging.getLogger(__name__)
74
78
  MANIFEST_FILENAME = "_ds_manifest.json"
75
79
  DEFAULT_CSS_TOKEN_SOURCE = "colors_and_type.css"
76
80
 
81
+
82
+ class BundleImportWarning(NamedTuple):
83
+ """A bundle entry the import DROPPED without failing the upload.
84
+
85
+ Some entries are individually unusable without making the bundle unusable — an
86
+ unsniffable template screenshot, a CSS token source that is not UTF-8. Dropping
87
+ just that entry is right (one junk file must not cost the whole upload), but the
88
+ caller was then told the import succeeded with nothing to say a file had been
89
+ ignored, so the only record was a server-side log the user cannot see. These ride
90
+ back on the import response instead.
91
+ """
92
+
93
+ path: str
94
+ reason: str
95
+
96
+
77
97
  # Color sub-groups the compiler renders as :root vars. A token name whose first
78
98
  # segment is one of these carries its group in the name (e.g. --brand-accents-lava).
79
99
  _COLOR_SUBGROUPS = frozenset(("core", "accents", "ink", "tints"))
@@ -276,21 +296,89 @@ def _infer_asset_kind(rel_path: str) -> str:
276
296
  # raster-image extension) — stored as a ``template_shot`` asset so the Phase 4
277
297
  # template picker can serve thumbnails. Checked BEFORE ``_should_skip`` (which
278
298
  # excludes ``templates/**`` from generic asset storage).
299
+ #
300
+ # Anchored with ``\Z``, NOT ``$``: in Python ``$`` matches at the end of the string
301
+ # OR immediately before a single trailing newline, so a ``$``-anchored allowlist on
302
+ # a PATH accepts ``<allowed shape>\n`` — ``templates/x/preview.png\n`` was matched,
303
+ # stored, and served. ``\Z`` is the true end of string. Applies to every allowlist
304
+ # regex in this module that judges a path or a filename.
279
305
  _TEMPLATE_PREVIEW_RE = re.compile(
280
- r"^templates/[^/]+/preview[^/]*\.(png|jpe?g|gif|webp)$", re.IGNORECASE
306
+ r"^templates/[^/]+/preview[^/]*\.(png|jpe?g|gif|webp)\Z", re.IGNORECASE
281
307
  )
282
308
 
309
+ # The SAME screenshot as it actually ships in a real export: DOT-PREFIXED, named
310
+ # ``thumbnail`` rather than ``preview``, and carrying NO file extension —
311
+ # ``templates/<folder>/.thumbnail``. Every one of those was discarded, so a real
312
+ # bundle imported with no thumbnail at all and a NULL ``thumbnail_url``.
313
+ #
314
+ # Deliberately the NARROWEST path shape that describes them: exactly one folder
315
+ # segment under ``templates/``, an optional single leading dot, one of the two
316
+ # known basenames, and NOTHING else — no extension, no suffix. It is this
317
+ # tightness that makes it safe for :func:`_iter_safe_entries` to exempt a match
318
+ # from the general dotfile skip; a dotfile anywhere else stays skipped. Because
319
+ # a match carries no extension, its media type is NOT guessable from the name and
320
+ # is sniffed from the content instead (:func:`_sniff_raster_mime`).
321
+ # ``\Z`` for the same reason as _TEMPLATE_PREVIEW_RE: with ``$`` this accepted
322
+ # ``templates/x/.thumbnail\n``, which was then stored AND linked as a template's
323
+ # thumbnail_url.
324
+ _TEMPLATE_THUMBNAIL_RE = re.compile(
325
+ r"^templates/[^/]+/\.?(thumbnail|preview)\Z", re.IGNORECASE
326
+ )
327
+
328
+ # Magic-byte signatures for the raster formats a template thumbnail may be. An
329
+ # extension-less file is stored ONLY if its bytes match one of these; unknown
330
+ # content is refused rather than persisted under a guessed content type. SVG is
331
+ # absent on purpose (it can carry inline script and has no magic number).
332
+ _PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
333
+ _JPEG_MAGIC = b"\xff\xd8\xff"
334
+ _GIF_MAGICS = (b"GIF87a", b"GIF89a")
335
+
336
+
337
+ def _sniff_raster_mime(data: bytes) -> Optional[str]:
338
+ """MIME type from an image's MAGIC BYTES, or ``None`` if not a known raster.
339
+
340
+ Content-based detection for files whose name carries no extension. WebP is a
341
+ RIFF container, so ``RIFF`` alone is not enough — the form type at bytes 8:12
342
+ must spell ``WEBP``, otherwise a RIFF/WAVE file would be stored as an image.
343
+ """
344
+ if data.startswith(_PNG_MAGIC):
345
+ return "image/png"
346
+ if data.startswith(_JPEG_MAGIC):
347
+ return "image/jpeg"
348
+ if data.startswith(_GIF_MAGICS):
349
+ return "image/gif"
350
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
351
+ return "image/webp"
352
+ return None
353
+
354
+
355
+ def _is_template_thumbnail(rel_path: str) -> bool:
356
+ """True for an EXTENSION-LESS template screenshot (``templates/x/.thumbnail``).
357
+
358
+ Distinguished from :func:`_is_template_preview` because the absence of an
359
+ extension is what forces content sniffing.
360
+ """
361
+ return bool(_TEMPLATE_THUMBNAIL_RE.match(rel_path.lower()))
362
+
283
363
 
284
364
  def _is_template_preview(rel_path: str) -> bool:
285
- return bool(_TEMPLATE_PREVIEW_RE.match(rel_path.lower()))
365
+ """True for a template folder's screenshot in EITHER shipped shape."""
366
+ low = rel_path.lower()
367
+ return bool(_TEMPLATE_PREVIEW_RE.match(low)) or bool(
368
+ _TEMPLATE_THUMBNAIL_RE.match(low)
369
+ )
286
370
 
287
371
 
288
372
  def _should_skip(rel_path: str) -> bool:
289
373
  """Only ``assets/**`` and ``fonts/**`` files are stored; skip everything else.
290
374
 
291
375
  Directories, OS junk, dotfiles, template screenshots and ``preview*`` files
292
- (reference-only material) are excluded. Template-folder preview thumbnails
293
- are the exception — the caller checks :func:`_is_template_preview` first.
376
+ (reference-only material) are excluded. Template-folder screenshots are the
377
+ exception — the caller checks :func:`_is_template_preview` first.
378
+
379
+ NOTE: this is not the FIRST gate an entry passes. ``_iter_safe_entries`` runs
380
+ upstream and applies its own dotfile skip, so a dot-prefixed screenshot has to
381
+ be allowlisted THERE as well or it never reaches this function at all.
294
382
  """
295
383
  low = rel_path.lower()
296
384
  base = _basename(low)
@@ -500,12 +588,20 @@ def import_bundle(
500
588
  user: Optional[str],
501
589
  name_override: Optional[str] = None,
502
590
  source_filename: Optional[str] = None,
591
+ warnings: Optional[list[BundleImportWarning]] = None,
503
592
  ) -> DesignSystem:
504
593
  """Import a ``.zip`` design-system bundle into Lakebase and compile it.
505
594
 
506
595
  Returns the persisted :class:`DesignSystem` (committed, with tokens + assets +
507
596
  ``compiled_style_content``).
508
597
 
598
+ Args:
599
+ warnings: optional collector. Pass a list to receive an
600
+ :class:`BundleImportWarning` for every entry the import DROPPED without
601
+ failing — the caller can then tell the user which files were ignored and
602
+ why. Omitting it keeps the previous signature working for the many
603
+ callers that only want the design system.
604
+
509
605
  Raises:
510
606
  DesignSystemImportError: bundle is not a zip, missing/invalid manifest, or
511
607
  violates a size limit (HTTP 400).
@@ -563,8 +659,10 @@ def import_bundle(
563
659
  # Read the DECLARED CSS token sources ONCE (budgeted); the same bytes are
564
660
  # reused for both token parsing and source-file retention (no double-charge).
565
661
  css_sources = _read_css_sources(zf, root_prefix, manifest, budget)
566
- tokens = _collect_tokens(manifest, _decode_css_texts(css_sources))
567
- assets, files = _collect_assets_and_files(zf, root_prefix, budget, css_sources)
662
+ tokens = _collect_tokens(manifest, _decode_css_texts(css_sources, warnings))
663
+ assets, files = _collect_assets_and_files(
664
+ zf, root_prefix, budget, css_sources, warnings
665
+ )
568
666
 
569
667
  # Stored VERBATIM; the strip decides only whether the brand wrote a description
570
668
  # at all (a whitespace-only one describes nothing, so it stays NULL). Same split
@@ -913,7 +1011,10 @@ def _read_css_sources(
913
1011
  return result
914
1012
 
915
1013
 
916
- def _decode_css_texts(css_sources: "dict[str, bytes]") -> list[str]:
1014
+ def _decode_css_texts(
1015
+ css_sources: "dict[str, bytes]",
1016
+ warnings: "Optional[list[BundleImportWarning]]" = None,
1017
+ ) -> list[str]:
917
1018
  """Decode the pre-read CSS source bytes to UTF-8 text (skip undecodable)."""
918
1019
  texts: list[str] = []
919
1020
  for path, raw in css_sources.items():
@@ -921,31 +1022,98 @@ def _decode_css_texts(css_sources: "dict[str, bytes]") -> list[str]:
921
1022
  texts.append(raw.decode("utf-8"))
922
1023
  except UnicodeDecodeError:
923
1024
  logger.warning("Skipping non-UTF-8 CSS token source: %s", path)
1025
+ if warnings is not None:
1026
+ warnings.append(
1027
+ BundleImportWarning(
1028
+ path,
1029
+ "not valid UTF-8; its design tokens were not read.",
1030
+ )
1031
+ )
924
1032
  return texts
925
1033
 
926
1034
 
927
1035
  def _safe_relpath(rel_path: str) -> Optional[str]:
928
- """Normalize a bundle-relative path; return ``None`` if it is unsafe (zip-slip).
929
-
930
- Rejects absolute paths (POSIX ``/`` or a Windows drive) and any path that
931
- escapes the bundle root via a ``..`` segment. Backslashes are normalized to
932
- ``/`` and ``.``/empty segments are collapsed. Assets are stored as DB bytes
933
- (never written to disk), so this is defence-in-depth — and it keeps the
934
- persisted ``design_system_file.path`` values safe for the later file browser.
1036
+ """The CANONICAL form of a bundle-relative FILE path, or ``None`` to REFUSE it.
1037
+
1038
+ VALIDATION, not laundering: a path is already canonical or it is refused. It is
1039
+ never rewritten into an acceptable form, because rewriting changes the very
1040
+ thing later rules are about to judge. ``assets/innocent/.`` used to normalize to
1041
+ ``assets/innocent`` — losing the ``.`` basename that made it junk — and the
1042
+ entry was stored under a path that was not its own name. Two entries could also
1043
+ collapse onto ONE identity (``assets/./x``, ``assets//x`` and ``assets/x`` all
1044
+ became ``assets/x``), leaving which bytes win to zip ordering.
1045
+
1046
+ Refused: an empty name, a C0/DEL control character (``\\n`` and NUL among them,
1047
+ so no rule downstream has to cope with one), an absolute path (POSIX ``/`` or a
1048
+ Windows drive), and any ``.``, ``..`` or EMPTY segment. Backslashes are still
1049
+ folded to ``/`` first, which is the one tolerance kept deliberately: zips written
1050
+ on Windows use them as separators, and folding cannot erase a segment or change a
1051
+ basename's junk-ness the way the collapses above did.
1052
+
1053
+ A trailing-slash DIRECTORY marker is not a file path and is refused here; callers
1054
+ reach the directory case through :func:`_classify_bundle_entry`, which strips the
1055
+ marker and validates the path it names.
1056
+
1057
+ This is the contract the file browser already relies on: stored
1058
+ ``design_system_file.path`` values are canonical by construction, so
1059
+ ``_validated_file_path`` can reject a non-canonical REQUEST instead of
1060
+ normalizing it into an accepted one.
935
1061
  """
936
1062
  if not rel_path:
937
1063
  return None
938
1064
  normalized = rel_path.replace("\\", "/")
1065
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in normalized):
1066
+ return None
939
1067
  if normalized.startswith("/") or re.match(r"^[A-Za-z]:", normalized):
940
1068
  return None
941
- parts: list[str] = []
942
- for segment in normalized.split("/"):
943
- if segment in ("", "."):
944
- continue
945
- if segment == "..":
946
- return None
947
- parts.append(segment)
948
- return "/".join(parts) if parts else None
1069
+ if any(segment in ("", ".", "..") for segment in normalized.split("/")):
1070
+ return None
1071
+ return normalized
1072
+
1073
+
1074
+ # The verdict :func:`_classify_bundle_entry` reaches for one zip entry.
1075
+ _ENTRY_REFUSE = "refuse" # unsafe — reject the whole bundle
1076
+ _ENTRY_SKIP = "skip" # legitimate but never stored (directory, OS junk, dotfile)
1077
+ _ENTRY_FILE = "file" # store it, under the canonical path returned alongside
1078
+
1079
+
1080
+ def _classify_bundle_entry(rel_path: str) -> tuple[str, Optional[str]]:
1081
+ """THE decision for one bundle entry: refuse it, skip it, or store it.
1082
+
1083
+ ONE place that canonicalizes and then applies EVERY rule. Both gates that judge
1084
+ bundle paths call this — the up-front whole-bundle scan
1085
+ (:func:`_assert_bundle_paths_safe`) and the per-entry iterator
1086
+ (:func:`_iter_safe_entries`) — so they cannot disagree about a path shape, and a
1087
+ new rule cannot be added to one and forgotten in the other. They used to decide
1088
+ separately and DID disagree: the scan refused ``assets/innocent/.`` on its raw
1089
+ basename while the iterator accepted the same entry on its normalized one, and
1090
+ the permissive gate was the one that reached storage.
1091
+
1092
+ Returns ``(verdict, canonical_path)``; the path is set only for ``_ENTRY_FILE``.
1093
+ """
1094
+ if rel_path.endswith("/"):
1095
+ # A directory marker. It is never stored, but the path it NAMES still has to
1096
+ # be canonical, so '../evil/' is refused rather than skipped.
1097
+ return (
1098
+ (_ENTRY_SKIP, None)
1099
+ if _safe_relpath(rel_path[:-1]) is not None
1100
+ else (_ENTRY_REFUSE, None)
1101
+ )
1102
+ canonical = _safe_relpath(rel_path)
1103
+ if canonical is None:
1104
+ return _ENTRY_REFUSE, None
1105
+ low = canonical.lower()
1106
+ if low.startswith("__macosx/") or "/__macosx/" in low:
1107
+ return _ENTRY_SKIP, None
1108
+ base = _basename(low)
1109
+ # The ONE dot-prefixed shape the importer stores is a template folder's
1110
+ # screenshot; every other dotfile stays skipped. Tested against the CANONICAL
1111
+ # path — the same string the recognizer sees downstream.
1112
+ if (base == ".ds_store" or base.startswith(".")) and not _is_template_preview(
1113
+ canonical
1114
+ ):
1115
+ return _ENTRY_SKIP, None
1116
+ return _ENTRY_FILE, canonical
949
1117
 
950
1118
 
951
1119
  def _is_symlink(info: zipfile.ZipInfo) -> bool:
@@ -966,8 +1134,11 @@ def _assert_bundle_paths_safe(zf: zipfile.ZipFile) -> None:
966
1134
  root-prefix scoping — so a malicious entry that falls OUTSIDE the bundle root
967
1135
  (e.g. ``../evil.png`` when the manifest sits under ``safe/``) is REJECTED, not
968
1136
  silently skipped. EVERY entry is checked (no exemption for empty or ``.``-only
969
- names): absolute paths, ``..`` traversal, and empty/invalid names are refused,
970
- and symlink entries are refused before any bytes are read.
1137
+ names): absolute paths, ``..`` traversal, control characters, and empty/invalid
1138
+ names are refused, and symlink entries are refused before any bytes are read.
1139
+
1140
+ The path verdict comes from :func:`_classify_bundle_entry`, the SAME classifier
1141
+ :func:`_iter_safe_entries` uses, so the two gates cannot disagree about a shape.
971
1142
  """
972
1143
  for info in zf.infolist():
973
1144
  name = info.filename
@@ -975,12 +1146,12 @@ def _assert_bundle_paths_safe(zf: zipfile.ZipFile) -> None:
975
1146
  raise DesignSystemImportError(
976
1147
  f"Bundle entry '{name}' is a symlink; refusing to import."
977
1148
  )
978
- # No ``if name`` guard: an empty (or ``.``-only) name also fails
979
- # ``_safe_relpath`` and must be rejected, not skipped.
980
- if _safe_relpath(name) is None:
1149
+ # No ``if name`` guard: an empty (or ``.``-only) name is also refused, not
1150
+ # skipped.
1151
+ if _classify_bundle_entry(name)[0] == _ENTRY_REFUSE:
981
1152
  raise DesignSystemImportError(
982
- f"Bundle contains an unsafe path '{name}' (empty, absolute, or "
983
- "parent-directory traversal); refusing to import."
1153
+ f"Bundle contains an unsafe path '{name}' (empty, absolute, "
1154
+ "non-canonical, or parent-directory traversal); refusing to import."
984
1155
  )
985
1156
 
986
1157
 
@@ -990,26 +1161,34 @@ def _iter_safe_entries(zf: zipfile.ZipFile, root_prefix: str):
990
1161
  Skips directories, OS junk (``__MACOSX``, ``.DS_Store``) and dotfiles. Raises
991
1162
  :class:`DesignSystemImportError` on a zip-slip path (absolute or ``..``) so a
992
1163
  malicious bundle is rejected rather than silently stored.
1164
+
1165
+ This is the ACTUAL first gate every entry passes, so the ONE dot-prefixed shape
1166
+ the importer stores — a template folder's ``.thumbnail`` screenshot
1167
+ (:func:`_is_template_preview`) — is allowlisted HERE too. Without that a fix to
1168
+ the recognizer alone reads as working in a unit test and still drops every real
1169
+ thumbnail, because the entry never reaches the recognizer.
1170
+
1171
+ Every path rule lives in :func:`_classify_bundle_entry` — the same classifier the
1172
+ up-front scan uses — so an entry is judged on its CANONICAL path and neither gate
1173
+ can be patched into disagreeing with the other. Stripping ``root_prefix`` is the
1174
+ only work done here, and it is bookkeeping rather than a path rule.
993
1175
  """
994
1176
  for info in zf.infolist():
995
1177
  name = info.filename
996
1178
  if not name.startswith(root_prefix):
997
1179
  continue
998
1180
  rel_raw = name[len(root_prefix):]
999
- if not rel_raw or rel_raw.endswith("/"):
1000
- continue # directory entry
1001
- low = rel_raw.lower()
1002
- if low.startswith("__macosx/") or "/__macosx/" in low:
1003
- continue
1004
- base = _basename(low)
1005
- if base == ".ds_store" or base.startswith("."):
1006
- continue
1007
- safe = _safe_relpath(rel_raw)
1008
- if safe is None:
1181
+ if not rel_raw:
1182
+ continue # the root-prefix directory entry itself
1183
+ verdict, safe = _classify_bundle_entry(rel_raw)
1184
+ if verdict == _ENTRY_REFUSE:
1009
1185
  raise DesignSystemImportError(
1010
- f"Bundle contains an unsafe path '{rel_raw}' (absolute path or "
1011
- "parent-directory traversal); refusing to import."
1186
+ f"Bundle contains an unsafe path '{rel_raw}' (absolute path, "
1187
+ "non-canonical path, or parent-directory traversal); refusing to "
1188
+ "import."
1012
1189
  )
1190
+ if safe is None: # _ENTRY_SKIP
1191
+ continue
1013
1192
  yield info, safe
1014
1193
 
1015
1194
 
@@ -1128,6 +1307,7 @@ def _collect_assets_and_files(
1128
1307
  root_prefix: str,
1129
1308
  budget: "_SizeBudget",
1130
1309
  css_sources: "dict[str, bytes]",
1310
+ warnings: "Optional[list[BundleImportWarning]]" = None,
1131
1311
  ) -> tuple[list[DesignSystemAsset], list[DesignSystemFile]]:
1132
1312
  """Read a bundle into asset rows + file rows in one safety-checked pass.
1133
1313
 
@@ -1176,8 +1356,37 @@ def _collect_assets_and_files(
1176
1356
  # Size-checked read: the declared size is validated BEFORE
1177
1357
  # materialisation (bomb guard).
1178
1358
  data = budget.read_info(zf, info)
1179
- mime = _guess_mime(rel)
1180
1359
  kind = "template_shot" if _is_template_preview(rel) else _infer_asset_kind(rel)
1360
+ if _is_template_thumbnail(rel):
1361
+ # No extension to guess from, so the type comes from the CONTENT.
1362
+ # Unrecognized bytes are REFUSED rather than stored as
1363
+ # application/octet-stream: a thumbnail is served back to the
1364
+ # browser, so its declared type has to be one we actually verified.
1365
+ # Only THIS entry is dropped — a bundle import is one request, and
1366
+ # one junk screenshot must not cost the whole upload.
1367
+ sniffed = _sniff_raster_mime(data)
1368
+ if sniffed is None:
1369
+ logger.warning(
1370
+ "Bundle entry '%s' is not a PNG/JPEG/GIF/WebP image "
1371
+ "(%d bytes); not stored as a template thumbnail",
1372
+ rel,
1373
+ len(data),
1374
+ )
1375
+ # A server-side log alone left the user believing the import was
1376
+ # complete; the same fact rides back on the response.
1377
+ if warnings is not None:
1378
+ warnings.append(
1379
+ BundleImportWarning(
1380
+ rel,
1381
+ "not a PNG, JPEG, GIF or WebP image "
1382
+ f"({len(data)} bytes); this template's thumbnail "
1383
+ "was not stored.",
1384
+ )
1385
+ )
1386
+ continue
1387
+ mime = sniffed
1388
+ else:
1389
+ mime = _guess_mime(rel)
1181
1390
  width, height = _image_dimensions(data, mime)
1182
1391
  asset = DesignSystemAsset(
1183
1392
  kind=kind,
@@ -606,9 +606,37 @@ def _entry_path_candidates(entry: dict) -> list[str]:
606
606
  return out
607
607
 
608
608
 
609
+ #: Basenames a template folder's screenshot ships under, with or without a leading
610
+ #: dot and with or without an extension: the documented ``preview.png`` and the
611
+ #: ``.thumbnail`` a real export actually writes. Matching only ``preview*`` left
612
+ #: every real bundle's ``thumbnail_url`` NULL even once the importer stored the
613
+ #: bytes — the importer and this lookup are two separate gates on the same file.
614
+ _THUMBNAIL_BASENAME_PREFIXES = ("preview", "thumbnail")
615
+
616
+
617
+ def _is_thumbnail_basename(basename: str) -> bool:
618
+ """True for a screenshot basename, with or without a leading dot.
619
+
620
+ A PREFIX match on purpose, matching the breadth of the importer's
621
+ ``preview[^/]*\\.<ext>`` shape. A C0/DEL control character is refused outright:
622
+ the prefix test alone says nothing about what TRAILS the basename, and this is
623
+ the third gate on the same file — the importer now refuses such a path, so this
624
+ is defence in depth that no longer depends on the importer for the property.
625
+ """
626
+ if any(ord(ch) < 0x20 or ord(ch) == 0x7F for ch in basename):
627
+ return False
628
+ return basename.lstrip(".").startswith(_THUMBNAIL_BASENAME_PREFIXES)
629
+
630
+
609
631
  def _find_thumbnail_asset_id(files: list[Any], template_dir: str) -> Optional[int]:
610
- """The template folder's ``preview*`` screenshot: the smallest-path image
611
- reference row under ``template_dir`` whose basename starts with ``preview``."""
632
+ """The template folder's screenshot: the smallest-path image reference row
633
+ under ``template_dir`` whose basename names a preview or a thumbnail.
634
+
635
+ Smallest path is the tie-break when a folder ships more than one, so the choice
636
+ is deterministic. The ``image/*`` requirement is what keeps a non-image file
637
+ named ``thumbnail`` out: the importer only records a verified, sniffed media
638
+ type on these rows.
639
+ """
612
640
  previews: list[tuple[str, int]] = []
613
641
  for ds_file in files:
614
642
  asset_id = getattr(ds_file, "asset_id", None)
@@ -619,7 +647,7 @@ def _find_thumbnail_asset_id(files: list[Any], template_dir: str) -> Optional[in
619
647
  continue
620
648
  basename = path.rsplit("/", 1)[-1].lower()
621
649
  mime = (getattr(ds_file, "mime", "") or "").lower()
622
- if basename.startswith("preview") and mime.startswith("image/"):
650
+ if _is_thumbnail_basename(basename) and mime.startswith("image/"):
623
651
  previews.append((path, asset_id))
624
652
  return min(previews)[1] if previews else None
625
653