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.
- {streamtex-0.7.76/streamtex.egg-info → streamtex-0.7.78}/PKG-INFO +1 -1
- {streamtex-0.7.76 → streamtex-0.7.78}/pyproject.toml +1 -1
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/release_cmd.py +43 -16
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deploy/stx_auth.py +1 -1
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/build.py +14 -3
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/coolify.py +118 -7
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/media.py +45 -4
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/ops.py +68 -8
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/state.py +4 -2
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/target.py +2 -2
- {streamtex-0.7.76 → streamtex-0.7.78/streamtex.egg-info}/PKG-INFO +1 -1
- {streamtex-0.7.76 → streamtex-0.7.78}/LICENSE +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/MANIFEST.in +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/README.md +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/setup.cfg +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/__main__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/config.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/generate.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/history.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/metadata.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/base.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/fal.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/google.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/openai.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/ai/providers/registry.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/auth.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/banner.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/bib.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/bib_preview.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/block_helpers.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/blocks.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/book.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/browser.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_claude_files.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_divergence.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_install_local_packs.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_shared.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_stx_toml.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/_toml_helpers.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/artifact_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/bib_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/build_check.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/cache_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_global.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/claude_project.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/commands.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/component_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/console.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/coolify.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/deploy_auth_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/deploy_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/dev_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/dev_config.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/ds_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/export_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/install_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/kit_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/main.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/base.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/compat.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/registry.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/v0_4_0.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/migrations/v0_4_6.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/pack_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/project_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/project_rules.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/publish_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/published_check.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/run_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/run_set.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/screenshot_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/shortcuts.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/status_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/sync_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/upgrade_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/validate_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/cli/workspace_cmd.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/code.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/collection.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/constants.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/container.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/_frontmatter.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/agent.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/ai_prompt.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/archetype.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/asset.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/discovery.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/guideline.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/install.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/integration.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/loaders.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/palette.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/skill.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/spec.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/artifacts/validation.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/contracts.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/discovery.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/core/validation.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/countdown.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deeplink.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/deploy/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/enums.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_enrich.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_paginate.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/export_widgets.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/facts.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/grid.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/gsheet.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/hover_tooltip.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/i18n.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_crop.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_editor.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/image_utils.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/inspector.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/kept.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/latex.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/latex_utils.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/link_config.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/link_preview.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/list.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/loading.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/markdown.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/marker.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/marker_runtime.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/media_overlay.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/mermaid.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/overlay.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/pagewalk.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/pdf_export.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/plantuml.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/presentation.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/presentation_profile.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/printing.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/config.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/levels.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/passwords.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/Dockerfile +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/entrypoint.sh +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/nginx.conf +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/stx-http.conf +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/live/supervisord.conf +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/10-stx-init.sh +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/20-stx-auth.sh +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/Dockerfile +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/closed.html +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/nginx.conf +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/stx-http.conf +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/release/templates/static/stx_auth.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/runtime_flags.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/scripts/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/scripts/migrate_curves_to_relative.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/search.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/slide.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/slide_note.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/space.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/spacing.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/css/stx_global.css +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/default.css +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_countdown.js +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_marker_observer.js +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_scroll_spy.js +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/js/stx_video.js +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/logo-stx-tiny.png +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/static/logo-stx.png +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/__init__.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/base.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/container.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/core.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/scale.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/scale_curves.toml +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/text.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/styles/visibility.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/tikz.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/toc.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/utils.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/watch.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/write.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex/zoom.py +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/SOURCES.txt +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/dependency_links.txt +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/entry_points.txt +0 -0
- {streamtex-0.7.76 → streamtex-0.7.78}/streamtex.egg-info/requires.txt +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
356
|
-
|
|
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.
|
|
359
|
-
|
|
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.
|
|
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
|
|
123
|
-
"""
|
|
124
|
-
|
|
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>``)
|
|
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
|
-
|
|
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"
|
|
208
|
-
|
|
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
|
|
321
|
-
|
|
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"
|
|
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
|
|
95
|
+
def _rel(match: str) -> str:
|
|
96
|
+
return unquote(match.replace("&", "&")).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
|
-
|
|
367
|
-
|
|
368
|
+
record_skew(v, skew, skew_version)
|
|
369
|
+
if foreign:
|
|
370
|
+
v["foreign_links"] = foreign
|
|
368
371
|
else:
|
|
369
|
-
v.pop("
|
|
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
|
-
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|