streamtex 0.7.74__tar.gz → 0.7.76__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.74/streamtex.egg-info → streamtex-0.7.76}/PKG-INFO +1 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/pyproject.toml +1 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/book.py +13 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/release_cmd.py +76 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deploy/stx_auth.py +1 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/presentation.py +19 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/config.py +23 -4
- streamtex-0.7.76/streamtex/release/media.py +153 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/ops.py +73 -7
- {streamtex-0.7.74 → streamtex-0.7.76/streamtex.egg-info}/PKG-INFO +1 -1
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/SOURCES.txt +1 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/LICENSE +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/MANIFEST.in +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/README.md +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/setup.cfg +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/__main__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/config.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/generate.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/history.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/metadata.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/base.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/fal.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/google.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/openai.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/ai/providers/registry.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/auth.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/banner.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/bib.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/bib_preview.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/block_helpers.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/blocks.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/browser.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_claude_files.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_divergence.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_install_local_packs.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_shared.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_stx_toml.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/_toml_helpers.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/artifact_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/bib_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/build_check.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/cache_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_global.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/claude_project.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/commands.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/component_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/console.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/coolify.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/deploy_auth_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/deploy_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/dev_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/dev_config.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/ds_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/export_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/install_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/kit_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/main.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/base.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/compat.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/registry.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/v0_4_0.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/migrations/v0_4_6.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/pack_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/project_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/project_rules.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/publish_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/published_check.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/run_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/run_set.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/screenshot_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/shortcuts.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/status_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/sync_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/upgrade_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/validate_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/cli/workspace_cmd.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/code.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/collection.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/constants.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/container.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/_frontmatter.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/agent.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/ai_prompt.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/archetype.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/asset.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/discovery.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/guideline.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/install.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/integration.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/loaders.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/palette.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/skill.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/spec.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/artifacts/validation.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/contracts.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/discovery.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/core/validation.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/countdown.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deeplink.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/deploy/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/enums.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_enrich.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_paginate.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/export_widgets.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/facts.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/grid.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/gsheet.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/hover_tooltip.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/i18n.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_crop.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_editor.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/image_utils.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/inspector.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/kept.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/latex.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/latex_utils.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/link_config.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/link_preview.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/list.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/loading.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/markdown.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/marker.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/marker_runtime.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/media_overlay.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/mermaid.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/overlay.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/pagewalk.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/pdf_export.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/plantuml.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/presentation_profile.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/printing.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/build.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/coolify.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/levels.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/passwords.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/state.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/target.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/Dockerfile +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/entrypoint.sh +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/nginx.conf +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/stx-http.conf +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/live/supervisord.conf +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/10-stx-init.sh +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/20-stx-auth.sh +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/Dockerfile +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/closed.html +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/nginx.conf +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/stx-http.conf +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/release/templates/static/stx_auth.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/runtime_flags.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/scripts/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/scripts/migrate_curves_to_relative.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/search.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/slide.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/slide_note.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/space.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/spacing.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/css/stx_global.css +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/default.css +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_countdown.js +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_marker_observer.js +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_scroll_spy.js +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/js/stx_video.js +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/logo-stx-tiny.png +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/static/logo-stx.png +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/__init__.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/base.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/container.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/core.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/scale.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/scale_curves.toml +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/text.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/styles/visibility.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/tikz.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/toc.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/utils.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/watch.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/write.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex/zoom.py +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/dependency_links.txt +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/entry_points.txt +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/streamtex.egg-info/requires.txt +0 -0
- {streamtex-0.7.74 → streamtex-0.7.76}/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.76
|
|
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.76"
|
|
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"
|
|
@@ -58,7 +58,7 @@ from .marker import (
|
|
|
58
58
|
)
|
|
59
59
|
from .marker_runtime import inject_marker_runtime
|
|
60
60
|
from .pdf_export import PdfConfig
|
|
61
|
-
from .presentation import get_presentation_config, st_presentation_footer
|
|
61
|
+
from .presentation import get_presentation_config, presentation_page_scrolls, st_presentation_footer
|
|
62
62
|
from .presentation_profile import (
|
|
63
63
|
_ACTIVE_PROFILE_KEY,
|
|
64
64
|
PresentationProfile,
|
|
@@ -482,6 +482,10 @@ def _offer_export_downloads(html: str, base_name: str,
|
|
|
482
482
|
|
|
483
483
|
submitted = st.form_submit_button("Generate")
|
|
484
484
|
|
|
485
|
+
# A Generate that had to rebuild the full document first is carried over its rerun (#151):
|
|
486
|
+
# a form counts as submitted on one run only, so the reader had to click a second time.
|
|
487
|
+
_pending_key = f"_stx_export_pending_{_pk}"
|
|
488
|
+
submitted = submitted or st.session_state.pop(_pending_key, False)
|
|
485
489
|
if submitted:
|
|
486
490
|
results = {}
|
|
487
491
|
# Resolve which HTML to use based on scope
|
|
@@ -494,6 +498,7 @@ def _offer_export_downloads(html: str, base_name: str,
|
|
|
494
498
|
if rebuild_fn:
|
|
495
499
|
with st.spinner("Generating full document..."):
|
|
496
500
|
rebuild_fn()
|
|
501
|
+
st.session_state[_pending_key] = True
|
|
497
502
|
st.rerun()
|
|
498
503
|
if full_book_html:
|
|
499
504
|
# Built once per session with the view of that moment:
|
|
@@ -2308,6 +2313,11 @@ def _render_page(module, page: dict, pages: list[dict], block_args, block_kwargs
|
|
|
2308
2313
|
st_include(module, *block_args, _inspector_config=inspector, **block_kwargs)
|
|
2309
2314
|
|
|
2310
2315
|
|
|
2316
|
+
def _stops_on_page(markers: list[dict], page: int) -> int:
|
|
2317
|
+
"""How many slides (navigation stops) *page* holds."""
|
|
2318
|
+
return sum(1 for m in markers if is_stop(m) and m.get("page_idx", 0) == page)
|
|
2319
|
+
|
|
2320
|
+
|
|
2311
2321
|
def _footer_first_slide(markers: list[dict], page: int, initial_marker: int | None) -> int:
|
|
2312
2322
|
"""The slide number the footer shows on its first paint (#117).
|
|
2313
2323
|
|
|
@@ -3652,6 +3662,8 @@ def _paginated_book(module_list, toc_config, marker_config, separator,
|
|
|
3652
3662
|
|
|
3653
3663
|
# --- Presentation footer (auto-rendered when PresentationConfig is active) ---
|
|
3654
3664
|
_pres_cfg = get_presentation_config()
|
|
3665
|
+
if _pres_cfg is not None and _stops_on_page(cache.get("markers", []), current_page) > 1:
|
|
3666
|
+
presentation_page_scrolls(_pres_cfg) # several slides on this page (#153)
|
|
3655
3667
|
if _pres_cfg is not None and _pres_cfg.footer:
|
|
3656
3668
|
_n = stop_count() if _pres_cfg.counter_mode == "slide" else 0
|
|
3657
3669
|
if _n:
|
|
@@ -259,6 +259,82 @@ def wake_cmd(site, target):
|
|
|
259
259
|
click.echo(f"{site}: live face running again")
|
|
260
260
|
|
|
261
261
|
|
|
262
|
+
@release.command("remove")
|
|
263
|
+
@click.argument("site")
|
|
264
|
+
@_target
|
|
265
|
+
@click.option("--yes", is_flag=True, help="Do not ask for confirmation.")
|
|
266
|
+
@click.option("--force", is_flag=True, help="Remove a site still declared in stx.toml.")
|
|
267
|
+
def remove_cmd(site, target, yes, force):
|
|
268
|
+
"""Take SITE off the server (#143): its program, its port, its folders, its state entry.
|
|
269
|
+
|
|
270
|
+
Refused while SITE is still declared in stx.toml, unless --force. No other site is touched and the
|
|
271
|
+
project's media stay. The domains are not changed here: `stx release setup --domains --dry-run`
|
|
272
|
+
shows them, then `--domains --deploy` writes them (it redeploys both applications)."""
|
|
273
|
+
cfg, state, t = _ctx(target)
|
|
274
|
+
if site in cfg.by_name and not force:
|
|
275
|
+
raise ReleaseError(f"{site} is still declared in stx.toml — remove it there first, or pass --force")
|
|
276
|
+
plan = ops.remove_plan(t, state, site)
|
|
277
|
+
if not plan:
|
|
278
|
+
raise ReleaseError(f"{site}: nothing on {t.name}")
|
|
279
|
+
click.echo(f"On {t.name}, this removes:")
|
|
280
|
+
for line in plan:
|
|
281
|
+
click.echo(f" - {line}")
|
|
282
|
+
if not yes and not click.confirm("Remove?", default=False):
|
|
283
|
+
raise ReleaseError("nothing removed")
|
|
284
|
+
ops.remove_site(t, cfg, state, site, click.echo)
|
|
285
|
+
if site not in cfg.by_name:
|
|
286
|
+
click.echo("Domains: `stx release setup --domains --dry-run` shows the one to drop; "
|
|
287
|
+
"`--domains --deploy` writes them (redeploys both applications, out of class).")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@release.command("media-report")
|
|
291
|
+
@click.argument("site")
|
|
292
|
+
@click.option("--list", "show", type=click.Choice(["kept", "dropped"]), default=None,
|
|
293
|
+
help="Also list the files kept, or those no longer served.")
|
|
294
|
+
@click.option("--html-env", "html_env", multiple=True, help="KEY=VALUE for the HTML export, as for publish.")
|
|
295
|
+
def media_report_cmd(site, show, html_env):
|
|
296
|
+
"""What SITE's live face would serve with `[release] media_per_site = true` (#142), on this machine.
|
|
297
|
+
|
|
298
|
+
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
|
|
300
|
+
sent: the server is not read nor changed."""
|
|
301
|
+
import tempfile
|
|
302
|
+
from pathlib import Path
|
|
303
|
+
|
|
304
|
+
from ..release import media as md
|
|
305
|
+
|
|
306
|
+
cfg = load_config()
|
|
307
|
+
s = cfg.site(site)
|
|
308
|
+
if "html" not in s.faces:
|
|
309
|
+
raise ReleaseError(f"{site} has no HTML face: its live face serves its whole static folder")
|
|
310
|
+
with tempfile.TemporaryDirectory(prefix="stx-media-") as w:
|
|
311
|
+
work = Path(w)
|
|
312
|
+
code = build.snapshot(cfg, s, work)
|
|
313
|
+
html, _ = build.export(cfg, s, code, work, "media-report", _html_env(html_env))
|
|
314
|
+
mp = md.plan(cfg, s, code, html)
|
|
315
|
+
if mp.static is None:
|
|
316
|
+
raise ReleaseError(f"{site}: {s.doc_dir}/static is not a folder — the live face serves no media")
|
|
317
|
+
kept, dropped = sorted(mp.kept), mp.dropped
|
|
318
|
+
by_export = sum(1 for why in mp.kept.values() if why == "export")
|
|
319
|
+
click.echo(f"{site} — static folder {mp.static} ({len(mp.files)} files, {md.human(mp.size(mp.files))})")
|
|
320
|
+
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")
|
|
322
|
+
click.echo(f" no longer served: {len(dropped)} file(s), {md.human(mp.size(dropped))}")
|
|
323
|
+
if mp.unmatched:
|
|
324
|
+
click.echo(f" {mp.unmatched} export file(s) match no static file (inlined or generated: nothing to keep)")
|
|
325
|
+
if mp.named:
|
|
326
|
+
click.echo(f" ! named in the site's files but not kept — add them to media_keep if the live face "
|
|
327
|
+
f"needs them ({len(mp.named)}):")
|
|
328
|
+
for p in mp.named:
|
|
329
|
+
click.echo(f" {p}")
|
|
330
|
+
if show:
|
|
331
|
+
click.echo(f"{'Kept' if show == 'kept' else 'No longer served'}:")
|
|
332
|
+
for p in (kept if show == "kept" else dropped):
|
|
333
|
+
click.echo(f" {p}" + (f" ({mp.kept[p]})" if show == "kept" else ""))
|
|
334
|
+
click.echo(f"[release] media_per_site = {'true: applied' if cfg.media_per_site else 'false: nothing changes'}"
|
|
335
|
+
" at the next publication")
|
|
336
|
+
|
|
337
|
+
|
|
262
338
|
@release.command("lock")
|
|
263
339
|
def lock_cmd():
|
|
264
340
|
"""Write the live image's requirements.txt and stx-version from uv.lock (then rebuild the live app).
|
|
@@ -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.76"
|
|
111
111
|
AUTH_INTERFACE = 3
|
|
112
112
|
|
|
113
113
|
DEFAULT_ROOT = "/var/lib/stx-auth"
|
|
@@ -47,7 +47,9 @@ class PresentationConfig:
|
|
|
47
47
|
"""CSS aspect-ratio value ("16/9", "4/3", "16/10")."""
|
|
48
48
|
|
|
49
49
|
enforce_ratio: bool = True
|
|
50
|
-
"""When True,
|
|
50
|
+
"""When True, the page is clipped at the screen's height (overflow hidden).
|
|
51
|
+
A page holding several slides is not clipped: it scrolls, so the
|
|
52
|
+
navigation reaches each of its slides (#153)."""
|
|
51
53
|
|
|
52
54
|
# Footer
|
|
53
55
|
footer: bool = True
|
|
@@ -287,6 +289,22 @@ def _inject_presentation_css(config: PresentationConfig) -> None:
|
|
|
287
289
|
st.html(css)
|
|
288
290
|
|
|
289
291
|
|
|
292
|
+
def presentation_page_scrolls(config: PresentationConfig) -> None:
|
|
293
|
+
"""Let the current page scroll when it holds several slides (#153).
|
|
294
|
+
|
|
295
|
+
With ``enforce_ratio`` the page is clipped at the screen's height and,
|
|
296
|
+
with ``center_content``, centred in it: a page of two slides (two
|
|
297
|
+
screens high) had its first slide above the screen and the second below
|
|
298
|
+
it, out of the navigation's reach. Such a page now grows and scrolls as
|
|
299
|
+
without presentation mode, so the navigation reaches each slide. A page
|
|
300
|
+
of one slide keeps the clipping.
|
|
301
|
+
"""
|
|
302
|
+
if not config.enforce_ratio:
|
|
303
|
+
return
|
|
304
|
+
st.html("<style>.stMain .block-container { max-height: none !important; overflow: visible !important; "
|
|
305
|
+
"justify-content: flex-start !important; flex: 0 0 auto !important; }</style>")
|
|
306
|
+
|
|
307
|
+
|
|
290
308
|
# ---------------------------------------------------------------------------
|
|
291
309
|
# Footer rendering
|
|
292
310
|
# ---------------------------------------------------------------------------
|
|
@@ -9,6 +9,9 @@
|
|
|
9
9
|
snapshot = ["content", "shared", "trainings_pack"] # in every code snapshot, besides the site's paths
|
|
10
10
|
media = "assets" # copied once per project, linked into every snapshot (optional)
|
|
11
11
|
media_exclude = [".DS_Store", "*-original.*"]
|
|
12
|
+
media_per_site = true # the live face serves only the site's own media (#142; default false):
|
|
13
|
+
# those its HTML export shows, plus media_keep — see release/media.py
|
|
14
|
+
media_keep = ["media/videos/*"] # globs under the site's static folder, served by every site
|
|
12
15
|
pythonpath = ["trainings_pack"] # PYTHONPATH of the live process, relative to the snapshot
|
|
13
16
|
lock_exclude = ["trainings-pack"] # local packages kept out of the live image's requirements
|
|
14
17
|
lock_no_sources = false # true: requirements resolved without [tool.uv.sources] (git packs)
|
|
@@ -26,6 +29,7 @@
|
|
|
26
29
|
exclude = ["trainings/CHAT1D/pack"] # removed from the snapshot
|
|
27
30
|
explicit = false # true: published only when named (a home page)
|
|
28
31
|
zoom = 80 # the HTML face opens as at this browser zoom, in percent (optional)
|
|
32
|
+
media_keep = ["downloads/chat1d/*"] # served by this site besides [release] media_keep (#142)
|
|
29
33
|
"""
|
|
30
34
|
|
|
31
35
|
from __future__ import annotations
|
|
@@ -41,10 +45,10 @@ from . import ReleaseError
|
|
|
41
45
|
|
|
42
46
|
FACES = ("live", "html")
|
|
43
47
|
SITE_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$")
|
|
44
|
-
_SITE_KEYS = {"site", "book", "group", "faces", "paths", "exclude", "explicit", "legacy", "zoom"}
|
|
48
|
+
_SITE_KEYS = {"site", "book", "group", "faces", "paths", "exclude", "explicit", "legacy", "zoom", "media_keep"}
|
|
45
49
|
_RELEASE_KEYS = {"project", "langs", "snapshot", "media", "media_exclude", "pythonpath", "lock_exclude",
|
|
46
50
|
"lock_no_sources", "apt", "min_words", "port_base", "dir", "sites", "sites_from", "checks",
|
|
47
|
-
"verify"}
|
|
51
|
+
"verify", "media_per_site", "media_keep"}
|
|
48
52
|
_APT_RE = re.compile(r"^[a-z0-9][a-z0-9.+-]+$")
|
|
49
53
|
|
|
50
54
|
|
|
@@ -61,6 +65,7 @@ class Site:
|
|
|
61
65
|
explicit: bool = False
|
|
62
66
|
legacy: tuple[str, ...] = () # the prototype's (training, kind), for convert-passwords
|
|
63
67
|
zoom: float | None = None # the HTML face's zoom in percent (stx export html --zoom)
|
|
68
|
+
media_keep: tuple[str, ...] = () # globs served by this site's live face besides its export's (#142)
|
|
64
69
|
|
|
65
70
|
@property
|
|
66
71
|
def doc_dir(self) -> str:
|
|
@@ -91,6 +96,8 @@ class ReleaseConfig:
|
|
|
91
96
|
dir: str = ".stx-release"
|
|
92
97
|
checks: str | None = None
|
|
93
98
|
verify: str | None = None # the project's check of each HTML export (#144)
|
|
99
|
+
media_per_site: bool = False # the live face serves only the site's own media (#142)
|
|
100
|
+
media_keep: tuple[str, ...] = () # globs served by every site's live face besides its export's
|
|
94
101
|
by_name: dict[str, Site] = field(default_factory=dict)
|
|
95
102
|
|
|
96
103
|
def __post_init__(self):
|
|
@@ -139,6 +146,13 @@ def _call(spec: str, root: Path, what: str):
|
|
|
139
146
|
sys.path.remove(str(root))
|
|
140
147
|
|
|
141
148
|
|
|
149
|
+
def _globs(raw: object, where: str) -> tuple[str, ...]:
|
|
150
|
+
if not isinstance(raw, list) or not all(isinstance(x, str) and x and not x.startswith("/") for x in raw):
|
|
151
|
+
raise ReleaseError(f"{where}: media_keep — a list of globs under the static folder, "
|
|
152
|
+
"such as [\"media/videos/*\"]")
|
|
153
|
+
return tuple(raw)
|
|
154
|
+
|
|
155
|
+
|
|
142
156
|
def _site(raw: object, where: str) -> Site:
|
|
143
157
|
if not isinstance(raw, dict):
|
|
144
158
|
raise ReleaseError(f"{where}: a site is a table")
|
|
@@ -162,7 +176,8 @@ def _site(raw: object, where: str) -> Site:
|
|
|
162
176
|
raise ReleaseError(f"{where} ({name}): zoom {zoom!r} — a positive percentage, such as 80")
|
|
163
177
|
return Site(site=name, book=book, group=str(raw.get("group") or name), faces=faces, paths=paths,
|
|
164
178
|
exclude=tuple(raw.get("exclude", ())), explicit=bool(raw.get("explicit", False)), legacy=legacy,
|
|
165
|
-
zoom=float(zoom) if zoom is not None else None
|
|
179
|
+
zoom=float(zoom) if zoom is not None else None,
|
|
180
|
+
media_keep=_globs(raw.get("media_keep", []), f"{where} ({name})"))
|
|
166
181
|
|
|
167
182
|
|
|
168
183
|
def load_config(root: Path | None = None) -> ReleaseConfig:
|
|
@@ -201,6 +216,9 @@ def load_config(root: Path | None = None) -> ReleaseConfig:
|
|
|
201
216
|
verify = data.get("verify")
|
|
202
217
|
if verify is not None and not (isinstance(verify, str) and re.fullmatch(r"[\w.]+:\w+", verify)):
|
|
203
218
|
raise ReleaseError(f"[release] verify = {verify!r} — expected 'module:function'")
|
|
219
|
+
per_site = data.get("media_per_site", False)
|
|
220
|
+
if not isinstance(per_site, bool):
|
|
221
|
+
raise ReleaseError(f"[release] media_per_site = {per_site!r} — true or false")
|
|
204
222
|
return ReleaseConfig(
|
|
205
223
|
root=root, project=project, sites=sites, langs=langs,
|
|
206
224
|
snapshot=tuple(data.get("snapshot", ())), media=data.get("media"),
|
|
@@ -208,7 +226,8 @@ def load_config(root: Path | None = None) -> ReleaseConfig:
|
|
|
208
226
|
pythonpath=tuple(data.get("pythonpath", ())), lock_exclude=tuple(data.get("lock_exclude", ())),
|
|
209
227
|
lock_no_sources=bool(data.get("lock_no_sources", False)), apt=apt,
|
|
210
228
|
min_words=int(data.get("min_words", 100)), port_base=int(data.get("port_base", 9000)),
|
|
211
|
-
dir=str(data.get("dir", ".stx-release")), checks=data.get("checks"), verify=verify
|
|
229
|
+
dir=str(data.get("dir", ".stx-release")), checks=data.get("checks"), verify=verify,
|
|
230
|
+
media_per_site=per_site, media_keep=_globs(data.get("media_keep", []), "[release]"))
|
|
212
231
|
|
|
213
232
|
|
|
214
233
|
def run_checks(cfg: ReleaseConfig, sites: list[Site]) -> None:
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""Media per site (#142): the live face serves only the files its site shows.
|
|
2
|
+
|
|
3
|
+
The router serves ``/app/static/`` from ``live/sites/<site>/static``, a link to the site's
|
|
4
|
+
``<doc>/static`` folder — in a project whose documents share one media folder, every file of every
|
|
5
|
+
document. With ``[release] media_per_site = true``, each release of the code holds a folder
|
|
6
|
+
``.stx-served`` of links to the files the site uses, and the router serves that folder instead:
|
|
7
|
+
|
|
8
|
+
* the files its HTML export shows: each file of the export's ``data/`` folders, found again in the
|
|
9
|
+
static folder by its content (size, then SHA-256) — the export copies a served file unchanged;
|
|
10
|
+
* the files ``media_keep`` names (``[release]`` and the site's table): glob patterns relative to the
|
|
11
|
+
static folder, as in ``/app/static/<path>``; ``*`` also crosses ``/``.
|
|
12
|
+
|
|
13
|
+
The code itself is unchanged: a block that reads a file of the static folder still finds it. A site
|
|
14
|
+
without an HTML face keeps serving its whole static folder. ``stx release media-report SITE`` shows
|
|
15
|
+
the plan without changing anything.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
from collections import defaultdict
|
|
24
|
+
from dataclasses import dataclass, field
|
|
25
|
+
from fnmatch import fnmatchcase
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
from .config import ReleaseConfig, Site
|
|
29
|
+
|
|
30
|
+
SERVED = ".stx-served"
|
|
31
|
+
|
|
32
|
+
#: A path under the static folder named in a site's own files: ``static/…`` or ``app/static/…``.
|
|
33
|
+
_NAMED_RE = re.compile(r"""(?:app/)?static/([\w.~/-]+)""")
|
|
34
|
+
_TEXT = {".py", ".md", ".html", ".css", ".js", ".toml", ".json", ".txt", ".yaml", ".yml"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class MediaPlan:
|
|
39
|
+
"""What the live face of one site serves."""
|
|
40
|
+
|
|
41
|
+
static: Path | None # the site's static folder, resolved (None: it has none)
|
|
42
|
+
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
|
|
44
|
+
unmatched: int = 0 # export files found in no static file (inlined, generated)
|
|
45
|
+
named: list[str] = field(default_factory=list) # named in the site's files, not kept
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def dropped(self) -> list[str]:
|
|
49
|
+
return sorted(set(self.files) - set(self.kept))
|
|
50
|
+
|
|
51
|
+
def size(self, paths) -> int:
|
|
52
|
+
return sum(self.files[p] for p in paths)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _walk(root: Path) -> dict[str, int]:
|
|
56
|
+
out: dict[str, int] = {}
|
|
57
|
+
for d, dirs, names in os.walk(root, followlinks=True):
|
|
58
|
+
dirs[:] = [x for x in dirs if x != SERVED]
|
|
59
|
+
for n in names:
|
|
60
|
+
p = Path(d) / n
|
|
61
|
+
try:
|
|
62
|
+
out[p.relative_to(root).as_posix()] = p.stat().st_size
|
|
63
|
+
except OSError: # a broken link: nothing to serve
|
|
64
|
+
pass
|
|
65
|
+
return out
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _digest(p: Path) -> str:
|
|
69
|
+
h = hashlib.sha256()
|
|
70
|
+
with open(p, "rb") as f:
|
|
71
|
+
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
72
|
+
h.update(chunk)
|
|
73
|
+
return h.hexdigest()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def keep_patterns(cfg: ReleaseConfig, site: Site) -> tuple[str, ...]:
|
|
77
|
+
return (*cfg.media_keep, *site.media_keep)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
|
|
81
|
+
"""The files the live face of *site* serves, from its snapshot *code* and its HTML export *html*,
|
|
82
|
+
both on this machine; the snapshot's media folder is linked to the project's while it is read."""
|
|
83
|
+
linked = None
|
|
84
|
+
if cfg.media and not (code / cfg.media).exists() and (cfg.root / cfg.media).is_dir():
|
|
85
|
+
linked = code / cfg.media
|
|
86
|
+
if linked.is_symlink(): # a link left dangling
|
|
87
|
+
linked.unlink()
|
|
88
|
+
linked.parent.mkdir(parents=True, exist_ok=True)
|
|
89
|
+
linked.symlink_to(cfg.root / cfg.media)
|
|
90
|
+
try:
|
|
91
|
+
return _plan(cfg, site, code, html)
|
|
92
|
+
finally:
|
|
93
|
+
if linked is not None:
|
|
94
|
+
linked.unlink()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _plan(cfg: ReleaseConfig, site: Site, code: Path, html: Path) -> MediaPlan:
|
|
98
|
+
folder = code / site.doc_dir / "static"
|
|
99
|
+
if not folder.is_dir():
|
|
100
|
+
return MediaPlan(static=None)
|
|
101
|
+
mp = MediaPlan(static=folder.resolve(), files=_walk(folder))
|
|
102
|
+
by_size: dict[int, list[str]] = defaultdict(list)
|
|
103
|
+
for rel, size in mp.files.items():
|
|
104
|
+
by_size[size].append(rel)
|
|
105
|
+
digests: dict[str, str] = {}
|
|
106
|
+
for f in sorted(html.rglob("*")):
|
|
107
|
+
if not f.is_file() or "data" not in f.relative_to(html).parts[:-1]:
|
|
108
|
+
continue
|
|
109
|
+
same = by_size.get(f.stat().st_size, [])
|
|
110
|
+
found = False
|
|
111
|
+
if same:
|
|
112
|
+
want = _digest(f)
|
|
113
|
+
for rel in same:
|
|
114
|
+
if rel not in digests:
|
|
115
|
+
digests[rel] = _digest(folder / rel)
|
|
116
|
+
if digests[rel] == want:
|
|
117
|
+
mp.kept.setdefault(rel, "export")
|
|
118
|
+
found = True
|
|
119
|
+
mp.unmatched += not found
|
|
120
|
+
for pat in keep_patterns(cfg, site):
|
|
121
|
+
for rel in mp.files:
|
|
122
|
+
if fnmatchcase(rel, pat):
|
|
123
|
+
mp.kept.setdefault(rel, pat)
|
|
124
|
+
named = set()
|
|
125
|
+
for d, dirs, names in os.walk(code / site.doc_dir):
|
|
126
|
+
dirs[:] = [x for x in dirs if x not in ("static", SERVED, "__pycache__")]
|
|
127
|
+
for n in names:
|
|
128
|
+
if Path(n).suffix in _TEXT:
|
|
129
|
+
text = (Path(d) / n).read_text(encoding="utf-8", errors="replace")
|
|
130
|
+
named.update(m.rstrip("/.") for m in _NAMED_RE.findall(text))
|
|
131
|
+
mp.named = sorted(p for p in named if p in mp.files and p not in mp.kept)
|
|
132
|
+
return mp
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def build_served(code: Path, site: Site, kept) -> Path:
|
|
136
|
+
"""The folder ``.stx-served`` of the snapshot: one relative link per kept file, to the same file
|
|
137
|
+
under ``<doc>/static`` — the links resolve on the server as they do here."""
|
|
138
|
+
served = code / SERVED
|
|
139
|
+
static = code / site.doc_dir / "static"
|
|
140
|
+
for rel in sorted(kept):
|
|
141
|
+
link = served / rel
|
|
142
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
143
|
+
link.symlink_to(os.path.relpath(static / rel, link.parent))
|
|
144
|
+
served.mkdir(exist_ok=True) # no file kept: the folder exists, empty — nothing served
|
|
145
|
+
return served
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def human(n: int) -> str:
|
|
149
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
150
|
+
if n < 1024 or unit == "GB":
|
|
151
|
+
return f"{n:.0f} {unit}" if unit == "B" else f"{n:.1f} {unit}"
|
|
152
|
+
n /= 1024
|
|
153
|
+
return f"{n} B"
|
|
@@ -11,6 +11,7 @@ On the target (``<root>`` of the target, ``/srv/<face>`` in the containers)::
|
|
|
11
11
|
live/sites/<site>/current -> releases/<release>
|
|
12
12
|
live/sites/<site>/pass the live face's records
|
|
13
13
|
live/sites/<site>/static -> current/<doc>/static the site's media, served by the router
|
|
14
|
+
-> current/.stx-served with media_per_site: its own media alone (#142)
|
|
14
15
|
live/media/<project>/<media>/ the project's media, shared by every snapshot
|
|
15
16
|
live/conf/programs/<site>.conf the site's supervisord program
|
|
16
17
|
live/conf/ports.map site → port, read by the router
|
|
@@ -38,9 +39,10 @@ from pathlib import Path
|
|
|
38
39
|
|
|
39
40
|
from . import ReleaseError, build
|
|
40
41
|
from . import levels as lv
|
|
42
|
+
from . import media as md
|
|
41
43
|
from . import passwords as pw
|
|
42
|
-
from .config import ReleaseConfig, Site
|
|
43
|
-
from .state import save_state, sites_of
|
|
44
|
+
from .config import SITE_RE, ReleaseConfig, Site
|
|
45
|
+
from .state import STATE_FILE, save_state, sites_of
|
|
44
46
|
from .target import LIVE_ROOT, Target
|
|
45
47
|
|
|
46
48
|
TEMPLATES = Path(__file__).parent / "templates"
|
|
@@ -78,9 +80,21 @@ def html_of(v: dict) -> str | None:
|
|
|
78
80
|
|
|
79
81
|
|
|
80
82
|
def switch(t: Target, site: Site, release: str, faces: list[str] | None = None) -> None:
|
|
81
|
-
"""Each face's ``current`` link on *release
|
|
82
|
-
|
|
83
|
+
"""Each face's ``current`` link on *release*; the live face's media follow it."""
|
|
84
|
+
dirs = faces or _faces_dirs(site)
|
|
85
|
+
for face in dirs:
|
|
83
86
|
t.sh(t.switch_cmd(t.path(face, "sites", site.site), release))
|
|
87
|
+
if "live" in dirs:
|
|
88
|
+
link_static(t, site)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def link_static(t: Target, site: Site) -> None:
|
|
92
|
+
"""The media the router serves for *site*: the release's ``.stx-served`` when it has one (#142),
|
|
93
|
+
else its whole ``<doc>/static`` — a rollback to a release of before brings its media back."""
|
|
94
|
+
cur = f"{LIVE_ROOT}/sites/{site.site}/current"
|
|
95
|
+
t.sh(f"cd {shlex.quote(t.path('live', 'sites', site.site))} && "
|
|
96
|
+
f"if [ -d current/{md.SERVED} ]; then ln -sfn {shlex.quote(f'{cur}/{md.SERVED}')} static; "
|
|
97
|
+
f"else ln -sfn {shlex.quote(f'{cur}/{site.doc_dir}/static')} static; fi")
|
|
84
98
|
|
|
85
99
|
|
|
86
100
|
def server_records(t: Target) -> str:
|
|
@@ -288,6 +302,18 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
|
|
|
288
302
|
build.verify_export(cfg, site, html)
|
|
289
303
|
if cfg.verify:
|
|
290
304
|
echo(f" ✓ export accepted by {cfg.verify}")
|
|
305
|
+
if cfg.media_per_site and "live" in site.faces:
|
|
306
|
+
if html is None:
|
|
307
|
+
echo(f" ! media per site: {site.site} has no HTML face — its live face serves its whole "
|
|
308
|
+
"static folder")
|
|
309
|
+
elif (mp := md.plan(cfg, site, code, html)).static is not None:
|
|
310
|
+
md.build_served(code, site, mp.kept)
|
|
311
|
+
echo(f" ✓ media per site: {len(mp.kept)} of {len(mp.files)} file(s) served "
|
|
312
|
+
f"({md.human(mp.size(mp.kept))})")
|
|
313
|
+
if mp.named:
|
|
314
|
+
echo(f" ! named in the site's files, not served: {', '.join(mp.named[:5])}"
|
|
315
|
+
+ (f" (+{len(mp.named) - 5})" if len(mp.named) > 5 else "")
|
|
316
|
+
+ " — add them to media_keep")
|
|
291
317
|
if "live" in site.faces:
|
|
292
318
|
dirs = [t.path("live", "sites", site.site, "releases")]
|
|
293
319
|
if cfg.media:
|
|
@@ -315,9 +341,6 @@ def publish_site(t: Target, cfg: ReleaseConfig, state: dict, site: Site, release
|
|
|
315
341
|
media.symlink_to(f"{LIVE_ROOT}/media/{cfg.project}/{cfg.media}")
|
|
316
342
|
t.rsync(str(code) + "/", t.path("live", "sites", site.site, "releases", release) + "/", "--delete")
|
|
317
343
|
t.sh(f"chmod -R a+rX {shlex.quote(t.path('live', 'sites', site.site, 'releases', release))}")
|
|
318
|
-
if "live" in site.faces:
|
|
319
|
-
t.sh(f"ln -sfn {LIVE_ROOT}/sites/{site.site}/current/{site.doc_dir}/static "
|
|
320
|
-
f"{shlex.quote(t.path('live', 'sites', site.site, 'static'))}")
|
|
321
344
|
apply_passwords(t, cfg, state, site, faces, pool)
|
|
322
345
|
write_maps(t, state)
|
|
323
346
|
if "html" in site.faces:
|
|
@@ -452,6 +475,49 @@ def sleep_wake(t: Target, cfg: ReleaseConfig, state: dict, site: Site, asleep: b
|
|
|
452
475
|
save_state(cfg.root, state)
|
|
453
476
|
|
|
454
477
|
|
|
478
|
+
def remove_plan(t: Target, state: dict, name: str) -> list[str]:
|
|
479
|
+
"""What ``stx release remove`` takes off the target for site *name* (#143): ``[]`` when nothing."""
|
|
480
|
+
if not SITE_RE.match(name):
|
|
481
|
+
raise ReleaseError(f"{name!r} is not a site name")
|
|
482
|
+
out = []
|
|
483
|
+
for face in ("static", "live"):
|
|
484
|
+
d = t.path(face, "sites", name)
|
|
485
|
+
if t.sh(f"test -d {shlex.quote(d)}", check=False).returncode == 0:
|
|
486
|
+
out.append(f"folder {d}")
|
|
487
|
+
conf = t.path("live", "conf", "programs", f"{name}.conf")
|
|
488
|
+
if t.sh(f"test -f {shlex.quote(conf)}", check=False).returncode == 0:
|
|
489
|
+
out.append(f"program {conf} (stopped first)")
|
|
490
|
+
v = sites_of(state, t.name).get(name)
|
|
491
|
+
if v is not None:
|
|
492
|
+
if v.get("port"):
|
|
493
|
+
out.append(f"line '{name} {v['port']};' of {t.path('live', 'conf', 'ports.map')}")
|
|
494
|
+
out.append(f"entry {name} of {STATE_FILE} (target {t.name})")
|
|
495
|
+
return out
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def remove_site(t: Target, cfg: ReleaseConfig, state: dict, name: str, echo: Echo) -> None:
|
|
499
|
+
"""Take site *name* off the target: its program, its port, its folders and its state entry. No
|
|
500
|
+
other site is touched; the project's media stay (shared by every site)."""
|
|
501
|
+
if not SITE_RE.match(name):
|
|
502
|
+
raise ReleaseError(f"{name!r} is not a site name")
|
|
503
|
+
conf = t.path("live", "conf", "programs", f"{name}.conf")
|
|
504
|
+
t.dexec("live", f"supervisorctl stop {name}", check=False)
|
|
505
|
+
t.sh(f"rm -f {shlex.quote(conf)}")
|
|
506
|
+
sites_of(state, t.name).pop(name, None)
|
|
507
|
+
write_maps(t, state)
|
|
508
|
+
t.dexec("live", "supervisorctl reread", check=False)
|
|
509
|
+
t.dexec("live", "supervisorctl update", check=False)
|
|
510
|
+
t.dexec("static", "nginx -s reload", check=False)
|
|
511
|
+
t.dexec("live", "nginx -s reload", check=False)
|
|
512
|
+
for face in ("static", "live"):
|
|
513
|
+
d = t.path(face, "sites", name)
|
|
514
|
+
if not (SITE_RE.match(name) and d.rstrip("/").endswith(f"sites/{name}")):
|
|
515
|
+
raise ReleaseError(f"refused to remove {d!r}")
|
|
516
|
+
t.sh(f"rm -rf {shlex.quote(d)}")
|
|
517
|
+
save_state(cfg.root, state)
|
|
518
|
+
echo(f" ✓ {name} removed from {t.name}")
|
|
519
|
+
|
|
520
|
+
|
|
455
521
|
def image_version(t: Target) -> str:
|
|
456
522
|
"""The streamtex version the live application actually runs, or ``""`` when it cannot be read."""
|
|
457
523
|
r = t.dexec("live", "/app/.venv/bin/python -c 'import streamtex; print(streamtex.__version__)'", check=False)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: streamtex
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.76
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|