streamtex 0.7.76__tar.gz → 0.7.78__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 (194) hide show
  1. {streamtex-0.7.76/streamtex.egg-info → streamtex-0.7.78}/PKG-INFO +1 -1
  2. {streamtex-0.7.76 → streamtex-0.7.78}/pyproject.toml +1 -1
  3. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/release_cmd.py +43 -16
  4. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deploy/stx_auth.py +1 -1
  5. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/build.py +14 -3
  6. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/coolify.py +118 -7
  7. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/media.py +45 -4
  8. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/ops.py +68 -8
  9. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/state.py +4 -2
  10. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/target.py +2 -2
  11. {streamtex-0.7.76 → streamtex-0.7.78/streamtex.egg-info}/PKG-INFO +1 -1
  12. {streamtex-0.7.76 → streamtex-0.7.78}/LICENSE +0 -0
  13. {streamtex-0.7.76 → streamtex-0.7.78}/MANIFEST.in +0 -0
  14. {streamtex-0.7.76 → streamtex-0.7.78}/README.md +0 -0
  15. {streamtex-0.7.76 → streamtex-0.7.78}/setup.cfg +0 -0
  16. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/__init__.py +0 -0
  17. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/__main__.py +0 -0
  18. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/__init__.py +0 -0
  19. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/config.py +0 -0
  20. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/generate.py +0 -0
  21. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/history.py +0 -0
  22. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/metadata.py +0 -0
  23. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/__init__.py +0 -0
  24. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/base.py +0 -0
  25. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/fal.py +0 -0
  26. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/google.py +0 -0
  27. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/openai.py +0 -0
  28. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/registry.py +0 -0
  29. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/auth.py +0 -0
  30. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/banner.py +0 -0
  31. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/bib.py +0 -0
  32. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/bib_preview.py +0 -0
  33. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/block_helpers.py +0 -0
  34. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/blocks.py +0 -0
  35. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/book.py +0 -0
  36. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/browser.py +0 -0
  37. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/__init__.py +0 -0
  38. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_claude_files.py +0 -0
  39. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_divergence.py +0 -0
  40. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_install_local_packs.py +0 -0
  41. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_shared.py +0 -0
  42. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_stx_toml.py +0 -0
  43. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_toml_helpers.py +0 -0
  44. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/artifact_cmd.py +0 -0
  45. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/bib_cmd.py +0 -0
  46. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/build_check.py +0 -0
  47. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/cache_cmd.py +0 -0
  48. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_cmd.py +0 -0
  49. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_global.py +0 -0
  50. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_project.py +0 -0
  51. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/commands.py +0 -0
  52. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/component_cmd.py +0 -0
  53. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/console.py +0 -0
  54. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/coolify.py +0 -0
  55. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/deploy_auth_cmd.py +0 -0
  56. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/deploy_cmd.py +0 -0
  57. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/dev_cmd.py +0 -0
  58. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/dev_config.py +0 -0
  59. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/ds_cmd.py +0 -0
  60. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/export_cmd.py +0 -0
  61. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/install_cmd.py +0 -0
  62. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/kit_cmd.py +0 -0
  63. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/main.py +0 -0
  64. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/__init__.py +0 -0
  65. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/base.py +0 -0
  66. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/compat.py +0 -0
  67. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/registry.py +0 -0
  68. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/v0_4_0.py +0 -0
  69. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/v0_4_6.py +0 -0
  70. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/pack_cmd.py +0 -0
  71. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/project_cmd.py +0 -0
  72. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/project_rules.py +0 -0
  73. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/publish_cmd.py +0 -0
  74. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/published_check.py +0 -0
  75. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/run_cmd.py +0 -0
  76. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/run_set.py +0 -0
  77. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/screenshot_cmd.py +0 -0
  78. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/shortcuts.py +0 -0
  79. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/status_cmd.py +0 -0
  80. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/sync_cmd.py +0 -0
  81. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/upgrade_cmd.py +0 -0
  82. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/validate_cmd.py +0 -0
  83. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/workspace_cmd.py +0 -0
  84. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/code.py +0 -0
  85. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/collection.py +0 -0
  86. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/constants.py +0 -0
  87. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/container.py +0 -0
  88. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/__init__.py +0 -0
  89. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/__init__.py +0 -0
  90. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/_frontmatter.py +0 -0
  91. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/agent.py +0 -0
  92. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/ai_prompt.py +0 -0
  93. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/archetype.py +0 -0
  94. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/asset.py +0 -0
  95. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/discovery.py +0 -0
  96. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/guideline.py +0 -0
  97. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/install.py +0 -0
  98. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/integration.py +0 -0
  99. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/loaders.py +0 -0
  100. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/palette.py +0 -0
  101. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/skill.py +0 -0
  102. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/spec.py +0 -0
  103. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/validation.py +0 -0
  104. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/contracts.py +0 -0
  105. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/discovery.py +0 -0
  106. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/validation.py +0 -0
  107. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/countdown.py +0 -0
  108. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deeplink.py +0 -0
  109. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deploy/__init__.py +0 -0
  110. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/enums.py +0 -0
  111. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export.py +0 -0
  112. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_enrich.py +0 -0
  113. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_paginate.py +0 -0
  114. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_widgets.py +0 -0
  115. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/facts.py +0 -0
  116. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/grid.py +0 -0
  117. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/gsheet.py +0 -0
  118. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/hover_tooltip.py +0 -0
  119. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/i18n.py +0 -0
  120. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image.py +0 -0
  121. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_crop.py +0 -0
  122. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_editor.py +0 -0
  123. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_utils.py +0 -0
  124. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/inspector.py +0 -0
  125. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/kept.py +0 -0
  126. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/latex.py +0 -0
  127. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/latex_utils.py +0 -0
  128. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/link_config.py +0 -0
  129. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/link_preview.py +0 -0
  130. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/list.py +0 -0
  131. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/loading.py +0 -0
  132. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/markdown.py +0 -0
  133. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/marker.py +0 -0
  134. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/marker_runtime.py +0 -0
  135. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/media_overlay.py +0 -0
  136. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/mermaid.py +0 -0
  137. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/overlay.py +0 -0
  138. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/pagewalk.py +0 -0
  139. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/pdf_export.py +0 -0
  140. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/plantuml.py +0 -0
  141. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/presentation.py +0 -0
  142. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/presentation_profile.py +0 -0
  143. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/printing.py +0 -0
  144. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/__init__.py +0 -0
  145. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/config.py +0 -0
  146. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/levels.py +0 -0
  147. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/passwords.py +0 -0
  148. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/Dockerfile +0 -0
  149. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/entrypoint.sh +0 -0
  150. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/nginx.conf +0 -0
  151. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/stx-http.conf +0 -0
  152. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/supervisord.conf +0 -0
  153. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/10-stx-init.sh +0 -0
  154. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/20-stx-auth.sh +0 -0
  155. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/Dockerfile +0 -0
  156. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/closed.html +0 -0
  157. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/nginx.conf +0 -0
  158. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/stx-http.conf +0 -0
  159. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/stx_auth.py +0 -0
  160. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/runtime_flags.py +0 -0
  161. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/scripts/__init__.py +0 -0
  162. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/scripts/migrate_curves_to_relative.py +0 -0
  163. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/search.py +0 -0
  164. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/slide.py +0 -0
  165. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/slide_note.py +0 -0
  166. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/space.py +0 -0
  167. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/spacing.py +0 -0
  168. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/css/stx_global.css +0 -0
  169. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/default.css +0 -0
  170. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_countdown.js +0 -0
  171. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_marker_observer.js +0 -0
  172. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_scroll_spy.js +0 -0
  173. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_video.js +0 -0
  174. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/logo-stx-tiny.png +0 -0
  175. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/logo-stx.png +0 -0
  176. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/__init__.py +0 -0
  177. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/base.py +0 -0
  178. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/container.py +0 -0
  179. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/core.py +0 -0
  180. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/scale.py +0 -0
  181. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/scale_curves.toml +0 -0
  182. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/text.py +0 -0
  183. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/visibility.py +0 -0
  184. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/tikz.py +0 -0
  185. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/toc.py +0 -0
  186. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/utils.py +0 -0
  187. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/watch.py +0 -0
  188. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/write.py +0 -0
  189. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/zoom.py +0 -0
  190. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/SOURCES.txt +0 -0
  191. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/dependency_links.txt +0 -0
  192. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/entry_points.txt +0 -0
  193. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/requires.txt +0 -0
  194. {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamtex
3
- Version: 0.7.76
3
+ Version: 0.7.78
4
4
  Summary: AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required.
5
5
  Author-email: Nicolas Guelfi <nicolas.guelfi@laposte.net>
6
6
  License-Expression: BUSL-1.1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "streamtex"
7
- version = "0.7.76"
7
+ version = "0.7.78"
8
8
  description = "AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -46,21 +46,22 @@ def _html_env(items) -> dict[str, str]:
46
46
  return env
47
47
 
48
48
 
49
- def _skew(cfg, t, allow_skew: bool) -> str:
50
- """Why the exporting streamtex is not the live image's, refused unless --allow-skew (rel version=a)."""
51
- skew = build.library_skew(cfg)
49
+ def _skew(cfg, t, allow_skew: bool) -> tuple[str, str]:
50
+ """Why the exporting streamtex is not the live image's, refused unless --allow-skew (rel version=a);
51
+ with the exporting version when the gap is one of version only — a rebuild of the image at that
52
+ version ends it (#150) — else ``""``."""
53
+ info = build.library_info(cfg)
54
+ skew = build.library_skew(cfg, info)
52
55
  running = ops.image_version(t)
53
56
  wanted = (cfg.lock_dir / "stx-version").read_text(encoding="utf-8").strip()
54
57
  if not skew and running and running != wanted:
55
- how = ("stx release rebuild" if "live" in coolify.repo_faces(t.cfg)
56
- else "stx release setup --show live, paste, redeploy")
57
58
  skew = (f"the live application runs streamtex {running}, {cfg.dir}/stx-version says {wanted} — "
58
- f"rebuild it ({how})")
59
+ "rebuild it (stx release rebuild)")
59
60
  if skew and not allow_skew:
60
61
  raise ReleaseError(f"{skew} — publish with the image's streamtex, or pass --allow-skew for a trial")
61
62
  if skew:
62
63
  click.echo(f" ⚠ {skew} (--allow-skew: the sites are marked in status)")
63
- return skew
64
+ return skew, ("" if not skew or build.copy_skew(info) else info["version"])
64
65
 
65
66
 
66
67
  @release.command("publish")
@@ -85,7 +86,7 @@ def publish_cmd(sites, groups, all_, target, allow_skew, html_env):
85
86
  env = _html_env(html_env)
86
87
  release_name = build.require_committed(cfg.root)
87
88
  build.require_lock(cfg)
88
- skew = _skew(cfg, t, allow_skew)
89
+ skew, skew_version = _skew(cfg, t, allow_skew)
89
90
  run_checks(cfg, chosen)
90
91
  data = _passwords(cfg)
91
92
  faces = {s.site: pw.for_site(data, s) for s in chosen} # every password checked before any copy
@@ -93,7 +94,8 @@ def publish_cmd(sites, groups, all_, target, allow_skew, html_env):
93
94
  pool = pw.RecordPool(ops.server_records(t)) # one record per password, on every site
94
95
  for s in chosen:
95
96
  ops.publish_site(t, cfg, state, s, release_name, faces[s.site], click.echo, env, skew,
96
- levels[s.site], pool)
97
+ levels[s.site], pool, skew_version=skew_version)
98
+ ops.check_foreign_links(t, cfg, state, click.echo) # after all: a site published may stop one (#155)
97
99
 
98
100
 
99
101
  @release.command("status")
@@ -111,7 +113,7 @@ def status_cmd(target):
111
113
  wanted = wanted_path.read_text(encoding="utf-8").strip() if wanted_path.exists() else "?"
112
114
  gap = "" if running == wanted else " ← GAP"
113
115
  click.echo(f"target {t.name} · HEAD {head} · live image streamtex {running} (stx-version {wanted}){gap}\n")
114
- rows = ops.status_rows(t, cfg, state, data)
116
+ rows = ops.status_rows(t, cfg, state, data, running)
115
117
  w = max([28] + [len(r[0]) + 2 for r in rows])
116
118
  click.echo(f"{'site':<{w}}{'published':<14}{'served (html)':<19}{'live':<8}{'html':<11}date")
117
119
  for name, rel, html, live, access, date, flag in rows:
@@ -296,7 +298,8 @@ def media_report_cmd(site, show, html_env):
296
298
  """What SITE's live face would serve with `[release] media_per_site = true` (#142), on this machine.
297
299
 
298
300
  Exports SITE's HTML from the commit, as publish does, then sorts its static folder: the files the
299
- export shows and those media_keep names are kept, the others would no longer be served. Nothing is
301
+ export shows or links to under app/static/ and those media_keep names are kept, the others would no
302
+ longer be served. Nothing is
300
303
  sent: the server is not read nor changed."""
301
304
  import tempfile
302
305
  from pathlib import Path
@@ -316,9 +319,10 @@ def media_report_cmd(site, show, html_env):
316
319
  raise ReleaseError(f"{site}: {s.doc_dir}/static is not a folder — the live face serves no media")
317
320
  kept, dropped = sorted(mp.kept), mp.dropped
318
321
  by_export = sum(1 for why in mp.kept.values() if why == "export")
322
+ by_link = sum(1 for why in mp.kept.values() if why == "export link")
319
323
  click.echo(f"{site} — static folder {mp.static} ({len(mp.files)} files, {md.human(mp.size(mp.files))})")
320
324
  click.echo(f" kept: {len(kept)} file(s), {md.human(mp.size(kept))} — {by_export} shown by the HTML "
321
- f"export, {len(kept) - by_export} named by media_keep")
325
+ f"export, {by_link} linked by it, {len(kept) - by_export - by_link} named by media_keep")
322
326
  click.echo(f" no longer served: {len(dropped)} file(s), {md.human(mp.size(dropped))}")
323
327
  if mp.unmatched:
324
328
  click.echo(f" {mp.unmatched} export file(s) match no static file (inlined or generated: nothing to keep)")
@@ -327,6 +331,13 @@ def media_report_cmd(site, show, html_env):
327
331
  f"needs them ({len(mp.named)}):")
328
332
  for p in mp.named:
329
333
  click.echo(f" {p}")
334
+ if mp.foreign:
335
+ n = sum(len(v) for v in mp.foreign.values())
336
+ click.echo(f" links to other sites' files: {n} — served by those sites, not this one; publish "
337
+ "checks that each still serves its file:")
338
+ for other, rels in mp.foreign.items():
339
+ for p in rels:
340
+ click.echo(f" {other}: /app/static/{p}")
330
341
  if show:
331
342
  click.echo(f"{'Kept' if show == 'kept' else 'No longer served'}:")
332
343
  for p in (kept if show == "kept" else dropped):
@@ -352,11 +363,27 @@ def lock_cmd():
352
363
  @release.command("rebuild")
353
364
  @_target
354
365
  @click.option("--face", type=click.Choice(["static", "live"]), default="live", show_default=True)
355
- def rebuild_cmd(target, face):
356
- """Rebuild an application built from the repository (after lock, commit, push) and wait for it."""
366
+ @click.option("--dry-run", "dry_run", is_flag=True,
367
+ help="Dockerfile kept in Coolify: show the difference, write nothing, rebuild nothing.")
368
+ @click.option("--restore", is_flag=True,
369
+ help="Dockerfile kept in Coolify: put back the last one kept on the server, then rebuild.")
370
+ def rebuild_cmd(target, face, dry_run, restore):
371
+ """Rebuild an application after `stx release lock`, and wait for it.
372
+
373
+ Built from the repository: from the Dockerfile committed and pushed. Built from a Dockerfile kept in
374
+ Coolify: today's is written in Coolify's database over ssh (the API cannot), the one in place kept
375
+ on the server first (#149)."""
357
376
  cfg, _state, t = _ctx(target)
358
- coolify.rebuild(cfg, t.cfg, face, click.echo)
359
- if face == "live":
377
+ if face in coolify.repo_faces(t.cfg):
378
+ if dry_run or restore:
379
+ raise ReleaseError("--dry-run and --restore are for an application built from a Dockerfile kept "
380
+ "in Coolify; this one is built from the repository")
381
+ coolify.rebuild(cfg, t.cfg, face, click.echo)
382
+ elif not coolify.rebuild_pasted(cfg, t, face, click.echo, dry_run, restore):
383
+ return
384
+ if face == "live" and restore:
385
+ click.echo(f" live application on streamtex {ops.image_version(t) or '?'}")
386
+ elif face == "live":
360
387
  running = ops.image_version(t)
361
388
  wanted = (cfg.lock_dir / "stx-version").read_text(encoding="utf-8").strip()
362
389
  if running != wanted:
@@ -107,7 +107,7 @@ from pathlib import Path
107
107
  from urllib.parse import parse_qs, urlsplit
108
108
 
109
109
  #: The library release this file ships with, and its interface number (#135).
110
- STX_VERSION = "0.7.76"
110
+ STX_VERSION = "0.7.78"
111
111
  AUTH_INTERFACE = 3
112
112
 
113
113
  DEFAULT_ROOT = "/var/lib/stx-auth"
@@ -119,17 +119,28 @@ def require_lock(cfg: ReleaseConfig) -> None:
119
119
  f"{have_ver.strip() or '?'} in the image): `stx release lock`, commit, then rebuild the live application")
120
120
 
121
121
 
122
- def library_skew(cfg: ReleaseConfig) -> str:
123
- """Why the exporting streamtex is not the image's, or ``""`` (rel version=a)."""
124
- info = json.loads(_project_python(cfg, (
122
+ def library_info(cfg: ReleaseConfig) -> dict:
123
+ """The streamtex that exports here: its ``version``, and its ``direct_url`` when installed from a folder."""
124
+ return json.loads(_project_python(cfg, (
125
125
  "import json, importlib.metadata as m, streamtex\n"
126
126
  "d = m.distribution('streamtex')\n"
127
127
  "u = d.read_text('direct_url.json')\n"
128
128
  "print(json.dumps({'version': streamtex.__version__, 'direct_url': json.loads(u) if u else None}))")))
129
+
130
+
131
+ def library_skew(cfg: ReleaseConfig, info: dict | None = None) -> str:
132
+ """Why the exporting streamtex is not the image's, or ``""`` (rel version=a)."""
133
+ info = info if info is not None else library_info(cfg)
129
134
  want = (cfg.lock_dir / "stx-version").read_text(encoding="utf-8").strip() \
130
135
  if (cfg.lock_dir / "stx-version").exists() else ""
131
136
  if want and info["version"] != want:
132
137
  return f"streamtex {info['version']} exports here, the live image runs {want}"
138
+ return copy_skew(info)
139
+
140
+
141
+ def copy_skew(info: dict) -> str:
142
+ """Why an editable streamtex holds code that its version's release has not, or ``""``: such a gap
143
+ never ends with a rebuild of the image, unlike a gap of version only (#150)."""
133
144
  url = info.get("direct_url") or {}
134
145
  if url.get("dir_info", {}).get("editable"):
135
146
  path = url.get("url", "").removeprefix("file://")
@@ -5,11 +5,16 @@
5
5
  (no repository). ``stx-version`` and ``requirements.txt`` come from the project's lock folder.
6
6
 
7
7
  What the API cannot do (4.0.0-beta.468) is printed as manual steps, then checked where possible:
8
- mounting the host folder (Persistent Storage → Directory Mount ``<root>/<face>`` → ``/srv/<face>``), and
9
- replacing the Dockerfile of an existing application (Configuration → Dockerfile, Save, Redeploy).
8
+ mounting the host folder (Persistent Storage → Directory Mount ``<root>/<face>`` → ``/srv/<face>``).
10
9
  The Coolify token is read from the target's env file (``coolify_env``), never printed.
11
10
 
12
- A public repository removes the paste: with ``"repo": "owner/name"`` in the target, the faces of
11
+ The API also refuses to replace the Dockerfile of an existing application. ``stx release rebuild``
12
+ writes it where Coolify's interface does, the column ``applications.dockerfile`` of Coolify's database,
13
+ over ssh (#149): the difference shown first, the previous Dockerfile kept on the server
14
+ (``<root>/dockerfiles/``, put back by ``rebuild --restore``), the written one read back, then the
15
+ application rebuilt. A Coolify whose database changed stops it with a clear message.
16
+
17
+ A public repository needs no database: with ``"repo": "owner/name"`` in the target, the faces of
13
18
  ``repo_faces`` (default ``["live"]``) are built by Coolify from ``<dir>/Dockerfile.<face>``, written by
14
19
  ``stx release lock`` and committed. A new streamtex is then ``lock``, commit, push, ``stx release
15
20
  rebuild``. An application created from a pasted Dockerfile cannot be switched (the API cannot clear it):
@@ -19,6 +24,8 @@ rebuild``. An application created from a pasted Dockerfile cannot be switched (t
19
24
  from __future__ import annotations
20
25
 
21
26
  import base64
27
+ import datetime as dt
28
+ import difflib
22
29
  import json
23
30
  import re
24
31
  import shlex
@@ -204,8 +211,8 @@ def setup(cfg: ReleaseConfig, tcfg: dict, echo) -> dict:
204
211
  elif not _inline(app):
205
212
  echo(f" {name}: built from {app.get('git_repository')} ({app.get('dockerfile_location')})")
206
213
  else:
207
- echo(f" TO DO in Coolify ({name}): Configuration → Dockerfile, paste the output of "
208
- f"`stx release setup --show {face}`, Save, Redeploy (the API cannot change it)")
214
+ echo(f" {name}: built from a Dockerfile kept in Coolify — `stx release rebuild --face {face}` "
215
+ "writes today's and rebuilds")
209
216
  _health(co, uuid)
210
217
  echo(f" TO DO in Coolify ({name}): Persistent Storage → Directory Mount "
211
218
  f"{tcfg['root']}/{face} → /srv/{face}, then Redeploy")
@@ -317,8 +324,8 @@ def deploy(co: Coolify, uuid: str, echo, restart: bool = False) -> None:
317
324
 
318
325
  def rebuild(cfg: ReleaseConfig, tcfg: dict, face: str, echo) -> None:
319
326
  if face not in repo_faces(tcfg):
320
- raise ReleaseError(f"the {face} application is built from a pasted Dockerfile — paste "
321
- f"`stx release setup --show {face}` in Coolify (Configuration → Dockerfile), Save, Redeploy")
327
+ raise ReleaseError(f"the {face} application is built from a Dockerfile pasted in Coolify — "
328
+ "rebuild_pasted writes it there")
322
329
  require_pushed_dockerfile(cfg, tcfg, face)
323
330
  co = Coolify(cfg.root, tcfg["coolify_env"])
324
331
  uuid = tcfg[f"{face}_uuid"]
@@ -370,3 +377,107 @@ def migrate(cfg: ReleaseConfig, tcfg: dict, face: str, mounted, echo) -> bool:
370
377
  echo(f" {name + INLINE_SUFFIX}: stopped (kept for a rollback; delete it in Coolify when sure)")
371
378
  del tcfg[old_key]
372
379
  return True
380
+
381
+
382
+ # -- built from a pasted Dockerfile: written in Coolify's database (#149) -----------------------------
383
+
384
+ _PSQL = "docker exec -i {db} psql -U coolify -d coolify -v ON_ERROR_STOP=1 -qtA"
385
+ _TAG = "$stx_dockerfile$" # dollar quoting: the Dockerfile goes in unchanged
386
+ BACKUPS = "dockerfiles" # under the target's root, beside static/ and live/: never served
387
+
388
+
389
+ def _psql(t, sql: str) -> str:
390
+ db = t.cfg.get("coolify_db", "coolify-db")
391
+ r = t.sh(_PSQL.format(db=shlex.quote(db)), check=False, input=sql)
392
+ if r.returncode:
393
+ raise ReleaseError(f"Coolify's database ({db}) refused the request — if Coolify changed, paste "
394
+ f"`stx release setup --show <face>` in Coolify instead: {(r.stderr or r.stdout)[-400:]}")
395
+ return r.stdout
396
+
397
+
398
+ def _uuid(tcfg: dict, face: str) -> str:
399
+ uuid = tcfg.get(f"{face}_uuid", "")
400
+ if not re.fullmatch(r"[A-Za-z0-9-]+", uuid):
401
+ raise ReleaseError(f"no valid {face}_uuid in the target — run `stx release setup --apply` first")
402
+ return uuid
403
+
404
+
405
+ def read_pasted(t, uuid: str) -> str:
406
+ """The Dockerfile Coolify keeps for the application (base64: the text comes back unchanged)."""
407
+ out = _psql(t, "SELECT 'stx:' || coalesce(encode(convert_to(dockerfile, 'UTF8'), 'base64'), '') "
408
+ f"FROM applications WHERE uuid = '{uuid}';\n")
409
+ if not out.lstrip().startswith("stx:"):
410
+ raise ReleaseError(f"no application {uuid} in Coolify's database")
411
+ return base64.b64decode("".join(out.strip().removeprefix("stx:").split())).decode("utf-8")
412
+
413
+
414
+ def write_pasted(t, uuid: str, text: str) -> None:
415
+ """Write *text* as the application's Dockerfile, then read it back: it must be *text* exactly."""
416
+ if _TAG in text:
417
+ raise ReleaseError(f"the Dockerfile holds {_TAG}")
418
+ out = _psql(t, f"UPDATE applications SET dockerfile = {_TAG}{text}{_TAG}, updated_at = now() "
419
+ f"WHERE uuid = '{uuid}' RETURNING length(dockerfile);\n")
420
+ if not out.strip():
421
+ raise ReleaseError(f"no application {uuid} in Coolify's database: nothing written")
422
+ if read_pasted(t, uuid) != text:
423
+ raise ReleaseError(f"the Dockerfile read back from Coolify ({out.strip()} characters) is not the one "
424
+ f"written ({len(text)}) — check it in Coolify (Configuration → Dockerfile)")
425
+
426
+
427
+ def _diff(old: str, new: str, echo, limit: int = 60) -> None:
428
+ lines = list(difflib.unified_diff(old.splitlines(), new.splitlines(), "in Coolify", "to write", n=0,
429
+ lineterm=""))
430
+ plus = sum(1 for x in lines if x.startswith("+") and not x.startswith("+++"))
431
+ minus = sum(1 for x in lines if x.startswith("-") and not x.startswith("---"))
432
+ echo(f" difference: {plus} line(s) added, {minus} removed")
433
+ for x in lines[2:limit + 2]:
434
+ echo(f" {x}")
435
+ if len(lines) > limit + 2:
436
+ echo(f" … ({len(lines) - limit - 2} more line(s))")
437
+
438
+
439
+ def _backups(t, face: str) -> list[str]:
440
+ folder = t.path(BACKUPS)
441
+ r = t.sh(f"ls -1 {shlex.quote(folder)} 2>/dev/null || true", check=False)
442
+ return sorted(x for x in r.stdout.split() if x.startswith(f"Dockerfile.{face}."))
443
+
444
+
445
+ def rebuild_pasted(cfg: ReleaseConfig, t, face: str, echo, dry_run: bool = False, restore: bool = False) -> bool:
446
+ """Rebuild a face built from a Dockerfile pasted in Coolify (#149): write today's Dockerfile — or,
447
+ with *restore*, the last one kept — in Coolify's database, the one in place kept first under
448
+ ``<root>/dockerfiles/``, then rebuild and wait. *dry_run* shows the difference and writes nothing.
449
+ Returns True when the application was rebuilt."""
450
+ if t.kind != "ssh":
451
+ raise ReleaseError(f"target {t.name} is local: its containers are built by `stx release local up`")
452
+ uuid, name = _uuid(t.cfg, face), t.cfg[f"{face}_app"]
453
+ co = Coolify(cfg.root, t.cfg["coolify_env"])
454
+ if not _inline(co.call("GET", f"/applications/{uuid}")):
455
+ raise ReleaseError(f"{name} is built from a repository — `stx release rebuild` builds it from there")
456
+ current = read_pasted(t, uuid)
457
+ if restore:
458
+ kept = _backups(t, face)
459
+ if not kept:
460
+ raise ReleaseError(f"no Dockerfile kept for {face} under {t.path(BACKUPS)}")
461
+ source = t.path(BACKUPS, kept[-1])
462
+ wanted = t.sh(f"cat {shlex.quote(source)}").stdout
463
+ echo(f" {name}: back to {source}")
464
+ else:
465
+ wanted = compose_dockerfile(cfg, face)
466
+ echo(f" {name}: the Dockerfile of today's lock ({len(wanted)} characters)")
467
+ if current == wanted:
468
+ echo(" difference: none — Coolify already has it")
469
+ else:
470
+ _diff(current, wanted, echo)
471
+ if dry_run:
472
+ echo(" dry run: nothing written, nothing rebuilt")
473
+ return False
474
+ if current != wanted:
475
+ stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S-%f") # never the name of another one
476
+ backup = t.path(BACKUPS, f"Dockerfile.{face}.{stamp}")
477
+ t.write(backup, current)
478
+ echo(f" the Dockerfile in place kept: {backup} (`stx release rebuild --face {face} --restore` puts it back)")
479
+ write_pasted(t, uuid, wanted)
480
+ echo(f" written in Coolify and read back ({len(wanted)} characters)")
481
+ echo(f" {name}: rebuilding")
482
+ deploy(co, uuid, echo)
483
+ return True
@@ -7,9 +7,16 @@ document. With ``[release] media_per_site = true``, each release of the code hol
7
7
 
8
8
  * the files its HTML export shows: each file of the export's ``data/`` folders, found again in the
9
9
  static folder by its content (size, then SHA-256) — the export copies a served file unchanged;
10
+ * the files its HTML export links to under ``app/static/`` — a download offered by a link, at the
11
+ site's address or another's (``href='https://<site>…/app/static/downloads/x.zip'``): the export
12
+ leaves such a link as it is, and the live face must serve its file;
10
13
  * the files ``media_keep`` names (``[release]`` and the site's table): glob patterns relative to the
11
14
  static folder, as in ``/app/static/<path>``; ``*`` also crosses ``/``.
12
15
 
16
+ A link to another site's file (``https://<other site>…/app/static/x.zip``) is served by that site, and
17
+ only while that site keeps the file: ``foreign`` lists such links, publish records them and checks,
18
+ after each publication, that the other site still serves each file (#155).
19
+
13
20
  The code itself is unchanged: a block that reads a file of the static folder still finds it. A site
14
21
  without an HTML face keeps serving its whole static folder. ``stx release media-report SITE`` shows
15
22
  the plan without changing anything.
@@ -24,6 +31,7 @@ from collections import defaultdict
24
31
  from dataclasses import dataclass, field
25
32
  from fnmatch import fnmatchcase
26
33
  from pathlib import Path
34
+ from urllib.parse import unquote
27
35
 
28
36
  from .config import ReleaseConfig, Site
29
37
 
@@ -31,6 +39,10 @@ SERVED = ".stx-served"
31
39
 
32
40
  #: A path under the static folder named in a site's own files: ``static/…`` or ``app/static/…``.
33
41
  _NAMED_RE = re.compile(r"""(?:app/)?static/([\w.~/-]+)""")
42
+ #: A link of the export to a served file, absolute or not: the path after ``app/static/``.
43
+ _LINKED_RE = re.compile(r"""app/static/([^"'\s()<>?#\\]+)""")
44
+ #: The same link with its address: the host, whose first label names the site that serves it.
45
+ _HOSTED_RE = re.compile(r"""https?://([A-Za-z0-9.-]+)(?::\d+)?/app/static/([^"'\s()<>?#\\]+)""")
34
46
  _TEXT = {".py", ".md", ".html", ".css", ".js", ".toml", ".json", ".txt", ".yaml", ".yml"}
35
47
 
36
48
 
@@ -40,9 +52,12 @@ class MediaPlan:
40
52
 
41
53
  static: Path | None # the site's static folder, resolved (None: it has none)
42
54
  files: dict[str, int] = field(default_factory=dict) # every file of it → its size
43
- kept: dict[str, str] = field(default_factory=dict) # served → why: "export" or the pattern
55
+ kept: dict[str, str] = field(default_factory=dict) # served → why: "export", "export link",
56
+ # or the media_keep pattern
44
57
  unmatched: int = 0 # export files found in no static file (inlined, generated)
45
58
  named: list[str] = field(default_factory=list) # named in the site's files, not kept
59
+ foreign: dict[str, list[str]] = field(default_factory=dict) # another site of the project → the
60
+ # files the export links to on it (#155)
46
61
 
47
62
  @property
48
63
  def dropped(self) -> list[str]:
@@ -77,7 +92,20 @@ def keep_patterns(cfg: ReleaseConfig, site: Site) -> tuple[str, ...]:
77
92
  return (*cfg.media_keep, *site.media_keep)
78
93
 
79
94
 
80
- def plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
95
+ def _rel(match: str) -> str:
96
+ return unquote(match.replace("&amp;", "&")).rstrip("/")
97
+
98
+
99
+ def site_of_host(cfg: ReleaseConfig, host: str, suffix: str = "") -> str:
100
+ """The project's site an address names (its first label, without a provisional *suffix*), or ``""``."""
101
+ label = host.split(".", 1)[0].lower()
102
+ for name in (label, label.removesuffix(suffix) if suffix else label):
103
+ if name in cfg.by_name:
104
+ return name
105
+ return ""
106
+
107
+
108
+ def plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path, suffix: str = "") -> MediaPlan:
81
109
  """The files the live face of *site* serves, from its snapshot *code* and its HTML export *html*,
82
110
  both on this machine; the snapshot's media folder is linked to the project's while it is read."""
83
111
  linked = None
@@ -88,13 +116,13 @@ def plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
88
116
  linked.parent.mkdir(parents=True, exist_ok=True)
89
117
  linked.symlink_to(cfg.root / cfg.media)
90
118
  try:
91
- return _plan(cfg, site, code, html)
119
+ return _plan(cfg, site, code, html, suffix)
92
120
  finally:
93
121
  if linked is not None:
94
122
  linked.unlink()
95
123
 
96
124
 
97
- def _plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
125
+ def _plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path, suffix: str) -> MediaPlan:
98
126
  folder = code / site.doc_dir / "static"
99
127
  if not folder.is_dir():
100
128
  return MediaPlan(static=None)
@@ -117,6 +145,19 @@ def _plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
117
145
  mp.kept.setdefault(rel, "export")
118
146
  found = True
119
147
  mp.unmatched += not found
148
+ foreign: dict[str, set[str]] = defaultdict(set)
149
+ for f in sorted(html.rglob("*")):
150
+ if f.is_file() and f.suffix in (".html", ".css", ".js"):
151
+ text = f.read_text(encoding="utf-8", errors="replace")
152
+ for m in _LINKED_RE.findall(text):
153
+ rel = _rel(m)
154
+ if rel in mp.files:
155
+ mp.kept.setdefault(rel, "export link")
156
+ for host, m in _HOSTED_RE.findall(text):
157
+ other = site_of_host(cfg, host, suffix)
158
+ if other and other != site.site:
159
+ foreign[other].add(_rel(m))
160
+ mp.foreign = {k: sorted(v) for k, v in sorted(foreign.items())}
120
161
  for pat in keep_patterns(cfg, site):
121
162
  for rel in mp.files:
122
163
  if fnmatchcase(rel, pat):
@@ -280,7 +280,7 @@ def _levels_line(levels: dict, default: int | None) -> str:
280
280
 
281
281
  def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release: str, faces: dict,
282
282
  echo: Echo, html_env: dict[str, str] | None = None, skew: str = "",
283
- levels: dict | None = None, pool: pw.RecordPool | None = None) -> None:
283
+ levels: dict | None = None, pool: pw.RecordPool | None = None, skew_version: str = "") -> None:
284
284
  echo(f"\n▶ {site.site} @ {release} ({t.name})")
285
285
  entries = sites_of(state, t.name)
286
286
  v = entries.setdefault(site.site, {})
@@ -293,6 +293,7 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
293
293
  if "live" in site.faces and not v.get("port"):
294
294
  v["port"] = max([cfg.port_base - 1] + [s.get("port") or 0 for s in entries.values()]) + 1
295
295
  names: dict[int, str] = {}
296
+ foreign: dict[str, list[str]] = {}
296
297
  with tempfile.TemporaryDirectory(prefix="stx-release-") as w:
297
298
  work = Path(w)
298
299
  code = build.snapshot(cfg, site, work)
@@ -306,8 +307,9 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
306
307
  if html is None:
307
308
  echo(f" ! media per site: {site.site} has no HTML face — its live face serves its whole "
308
309
  "static folder")
309
- elif (mp := md.plan(cfg, site, code, html)).static is not None:
310
+ elif (mp := md.plan(cfg, site, code, html, t.suffix)).static is not None:
310
311
  md.build_served(code, site, mp.kept)
312
+ foreign = mp.foreign
311
313
  echo(f" ✓ media per site: {len(mp.kept)} of {len(mp.files)} file(s) served "
312
314
  f"({md.human(mp.size(mp.kept))})")
313
315
  if mp.named:
@@ -363,10 +365,11 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
363
365
  cuts={str(n): name for n, name in names.items()}, up_to_slide_previous=v.get("up_to_slide"))
364
366
  for old in ("html_release", "html_previous"):
365
367
  v.pop(old, None)
366
- if skew:
367
- v["skew"] = skew
368
+ record_skew(v, skew, skew_version)
369
+ if foreign:
370
+ v["foreign_links"] = foreign
368
371
  else:
369
- v.pop("skew", None)
372
+ v.pop("foreign_links", None)
370
373
  save_state(cfg.root, state)
371
374
  echo(f" ✓ {site.site}: html {release}, live {'ok' if 'live' in site.faces else '-'}"
372
375
  + (f" — port {v['port']}" if v.get("port") else "")
@@ -377,6 +380,64 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
377
380
  echo(f" ⌫ {len(removed)} old release(s) removed: {', '.join(removed)}")
378
381
 
379
382
 
383
+ def foreign_gaps(t: Target, cfg: ReleaseConfig, state: dict) -> tuple[int, list[tuple[str, str, str]]]:
384
+ """Every link of a site's export to another site's ``/app/static/`` file, recorded at publish, checked
385
+ in the live container as its router serves it: (how many, those the other site does not serve) —
386
+ (site, other site, file) (#155)."""
387
+ links = [(a, b, rel) for a, v in sorted(sites_of(state, t.name).items())
388
+ for b, rels in sorted((v.get("foreign_links") or {}).items()) for rel in rels]
389
+ gaps, ask = [], {}
390
+ for a, b, rel in links:
391
+ other = cfg.by_name.get(b)
392
+ if other is None or "live" not in other.faces:
393
+ gaps.append((a, b, rel))
394
+ else:
395
+ ask.setdefault(f"{LIVE_ROOT}/sites/{b}/static/{rel}", []).append((a, b, rel))
396
+ if ask:
397
+ script = ("for p in " + " ".join(shlex.quote(x) for x in ask)
398
+ + "; do [ -f \"$p\" ] || printf '%s\\n' \"$p\"; done")
399
+ r = t.dexec("live", "sh -c " + shlex.quote(script), check=False)
400
+ if r.returncode:
401
+ raise ReleaseError(f"{t.name}: the links to other sites' files could not be checked: "
402
+ f"{(r.stderr or r.stdout)[-300:]}")
403
+ for missing in r.stdout.splitlines():
404
+ gaps += ask.get(missing, [])
405
+ return len(links), sorted(gaps)
406
+
407
+
408
+ def check_foreign_links(t: Target, cfg: ReleaseConfig, state: dict, echo: Echo) -> None:
409
+ n, gaps = foreign_gaps(t, cfg, state)
410
+ if not n:
411
+ return
412
+ if not gaps:
413
+ echo(f"✓ links to other sites' files: {n}, all served by those sites")
414
+ return
415
+ echo(f"! links to other sites' files: {len(gaps)} of {n} not served by the site their address names "
416
+ "(a visitor gets 404) — that site must show or link to the file, or name it in its media_keep:")
417
+ for a, b, rel in gaps:
418
+ echo(f" {a} → {b}: /app/static/{rel}")
419
+
420
+
421
+ def record_skew(v: dict, skew: str, version: str = "") -> None:
422
+ """A site published with --allow-skew is marked; with *version*, the exporting streamtex, when the
423
+ gap was one of version only: the mark is then resolved once the live image runs it (#150)."""
424
+ v.pop("skew", None)
425
+ v.pop("skew_version", None)
426
+ if skew:
427
+ v["skew"] = skew
428
+ if version:
429
+ v["skew_version"] = version
430
+
431
+
432
+ def skew_flag(v: dict, running: str) -> str:
433
+ """The mark status shows: kept, never erased, but said resolved when the image caught up (#150)."""
434
+ if not v.get("skew"):
435
+ return ""
436
+ if v.get("skew_version") and v["skew_version"] == running:
437
+ return f" (skew, resolved: the live image runs {running})"
438
+ return " (skew)"
439
+
440
+
380
441
  def _slides(v: dict) -> str:
381
442
  return "all slides" if v.get("up_to_slide") is None else f"slides 1 to {v['up_to_slide']}"
382
443
 
@@ -524,7 +585,7 @@ def image_version(t: Target) -> str:
524
585
  return r.stdout.strip() if r.returncode == 0 else ""
525
586
 
526
587
 
527
- def status_rows(t: Target, cfg: ReleaseConfig, state: dict, data: dict | None) -> list[tuple]:
588
+ def status_rows(t: Target, cfg: ReleaseConfig, state: dict, data: dict | None, running: str = "") -> list[tuple]:
528
589
  rows = []
529
590
  for name, v in sorted(sites_of(state, t.name).items()):
530
591
  site = cfg.by_name.get(name)
@@ -550,8 +611,7 @@ def status_rows(t: Target, cfg: ReleaseConfig, state: dict, data: dict | None) -
550
611
  flag = f" ← GAP ({'; '.join(problems)})" if problems else ""
551
612
  else:
552
613
  flag = "" if html == v.get("release") else " ← GAP"
553
- if v.get("skew"):
554
- flag += " (skew)"
614
+ flag += skew_flag(v, running)
555
615
  if levels and "html" in site.faces and any(lvl != "all" for lvl in levels.values()):
556
616
  flag += f" ({_levels_line(levels, v.get('up_to_slide'))})"
557
617
  access = "-" if "html" not in site.faces else ("open" if v.get("html_open") else "protected")
@@ -28,11 +28,13 @@ from . import ReleaseError
28
28
 
29
29
  STATE_FILE = ".stx-release.json"
30
30
  #: What a site entry keeps (the prototype's training/kind are replaced by the site list of stx.toml).
31
- SITE_KEYS = ("port", "release", "previous", "date", "asleep", "html_open", "skew",
31
+ SITE_KEYS = ("port", "release", "previous", "date", "asleep", "html_open", "skew", "skew_version",
32
32
  # the HTML face's own release under a slide limit (#146, as the prototype's bascule2/n1)
33
33
  "html_release", "html_previous", "up_to_slide", "up_to_slide_previous", "last", "html_date",
34
34
  # each slide number's cut of the release, now and before (#148)
35
- "cuts", "cuts_previous")
35
+ "cuts", "cuts_previous",
36
+ # the links of its export to other sites' /app/static/ files, checked after a publish (#155)
37
+ "foreign_links")
36
38
 
37
39
 
38
40
  def state_path(root: Path) -> Path:
@@ -72,9 +72,9 @@ class Target:
72
72
  "-o", "ControlMaster=auto", "-o", f"ControlPath={CONTROL_PATH}", "-o", "ControlPersist=120",
73
73
  self.cfg["host"]]
74
74
 
75
- def sh(self, cmd: str, check: bool = True) -> subprocess.CompletedProcess:
75
+ def sh(self, cmd: str, check: bool = True, input: str | None = None) -> subprocess.CompletedProcess:
76
76
  argv = ["sh", "-c", cmd] if self.kind == "local" else [*self.ssh_argv(), cmd]
77
- r = subprocess.run(argv, text=True, capture_output=True)
77
+ r = subprocess.run(argv, input=input, text=True, capture_output=True)
78
78
  if check and r.returncode:
79
79
  raise ReleaseError(f"{self.name}: `{cmd[:120]}` failed: {(r.stderr or r.stdout)[-600:]}")
80
80
  return r
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: streamtex
3
- Version: 0.7.76
3
+ Version: 0.7.78
4
4
  Summary: AI-powered content framework for Streamlit — create presentations, courses, and web-books with Claude or Cursor, no coding required.
5
5
  Author-email: Nicolas Guelfi <nicolas.guelfi@laposte.net>
6
6
  License-Expression: BUSL-1.1
File without changes
File without changes
File without changes
File without changes