horus-runtime 0.2.1__tar.gz → 0.2.2__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.
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/PKG-INFO +1 -1
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/tui_subscriber.py +30 -13
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/map.py +77 -5
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/scheduler.py +27 -5
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/expander.py +41 -7
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/lowering.py +16 -1
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/base.py +5 -6
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/exceptions.py +9 -2
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/sanitize.py +71 -29
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_tui_subscriber.py +32 -0
- horus_runtime-0.2.2/tests/unit/test_sanitize.py +456 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_conditions.py +2 -2
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_edges.py +29 -18
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_map.py +145 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_scheduler.py +44 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_subworkflow.py +49 -10
- horus_runtime-0.2.1/tests/unit/test_sanitize.py +0 -206
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/.gitignore +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/LICENSE +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/pyproject.toml +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/file.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/folder.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/json.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/artifact/pickle.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/artifact_event.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/log_subscriber.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/task_event.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/event/workflow_event.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/_cwd_lock.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/python_exec.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/python_fn.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/executor/shell.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/cli.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/confirm.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/dropdown.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/file.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/interaction/common/string.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/task_log_file.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/task_time.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/middleware/workflow_time.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/py.typed +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/command.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python_script.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/python_string.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/runtime/substitution.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/target/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/target/local.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/function.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/task/horus_task.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/transfer/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/transfer/local_noop.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/tui.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/branch.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/condition.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/dag.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/horus_workflow.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/loop.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/errors.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/ports.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/cli.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/context.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/artifact/store.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/executor/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/renderer.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/interaction/transport.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/placement.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/resources.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/runtime/events.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/channel.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/target/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/task/status.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/generic.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/transfer/strategy.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/condition.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/edge.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/core/workflow/status.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/async_loop.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/bus.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/subscriber.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/event/transport.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/i18n.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/es/LC_MESSAGES/horus_runtime.mo +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/es/LC_MESSAGES/horus_runtime.po +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/locale/messages.pot +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/logging.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/auto_middleware.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/executor.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/interaction.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/runtime.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/target.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/target_command.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/task.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/transfer.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/middleware/workflow.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/packaging.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/py.typed +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/auto_registry.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/auto_registry_product.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/registry/exceptions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/settings.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_runtime/version.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/__init__.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/conftest.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/integration/test_runtime_integration.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_artifact_base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_builtin_artifact.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/artifact/test_store.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/boot/test_boot.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/cli/test_cli.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/cli/test_run.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/core/test_placement.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/core/test_resources.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/common.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_builtin_log_subscriber.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/event/test_event_system.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_builtin_executor.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_collect_side_artifacts.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/executor/test_executor_base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/i18n/test_i18n.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/interaction/test_builtin_interactions.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/interaction/test_interaction.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_middleware.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_target_command.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/middleware/test_task_log_file.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/registry/test_auto_registry.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/registry/test_schema_generation.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_anchor_local_paths.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_builtin_runtime.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_python_script.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_python_string.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_runtime_base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/runtime/test_substitution.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_channel.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_detach.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_local_target.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/target/test_target_base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/conftest.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_builtin_function_task.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_builtin_task.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/task/test_task_base.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_logging.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_main.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/test_packaging.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/transfer/test_builtin_transfer.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/transfer/test_transfer.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_branch_router.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_builtin_workflow.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_dynamic_mutation.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_loops.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_run_layout.py +0 -0
- {horus_runtime-0.2.1 → horus_runtime-0.2.2}/tests/unit/workflow/test_workflow.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: horus-runtime
|
|
3
|
-
Version: 0.2.
|
|
3
|
+
Version: 0.2.2
|
|
4
4
|
Summary: The Horus workflow manager runtime. Execute advanced workflows with multi-environment support, HPC integration, automatic artifact management, and more.
|
|
5
5
|
License-File: LICENSE
|
|
6
6
|
Classifier: License :: OSI Approved :: GNU Affero General Public License v3
|
|
@@ -243,8 +243,9 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
|
|
|
243
243
|
_live: Live | None = PrivateAttr(default=None)
|
|
244
244
|
_workflow: BaseWorkflow | None = PrivateAttr(default=None)
|
|
245
245
|
|
|
246
|
-
#
|
|
247
|
-
|
|
246
|
+
# Task the run was triggered from; the execution scope behind the
|
|
247
|
+
# progress total is re-planned from it on every frame (see _scope_ids).
|
|
248
|
+
_trigger: str | None = PrivateAttr(default=None)
|
|
248
249
|
_started_at: float | None = PrivateAttr(default=None)
|
|
249
250
|
|
|
250
251
|
# Per-task timing, derived from status transitions / completion events.
|
|
@@ -268,18 +269,34 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
|
|
|
268
269
|
Render *workflow*'s tasks before the run starts.
|
|
269
270
|
"""
|
|
270
271
|
self._workflow = workflow
|
|
272
|
+
self._trigger = trigger_id or (
|
|
273
|
+
workflow.tasks[0].id if workflow.tasks else None
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
def _scope_ids(self, workflow: BaseWorkflow) -> set[str]:
|
|
277
|
+
"""
|
|
278
|
+
Ids in the run's execution scope, re-planned from *workflow*'s
|
|
279
|
+
current tasks and edges.
|
|
280
|
+
|
|
281
|
+
A ``map:`` or ``sub:`` expander adds its clones, and the edges that
|
|
282
|
+
reach the gather task, only when it runs (see
|
|
283
|
+
:meth:`~horus_runtime.core.workflow.base.BaseWorkflow.expand`), so a
|
|
284
|
+
plan frozen before the run undercounts every fan-out: a five-clone
|
|
285
|
+
loop map reported ``1/1 tasks``. Re-planning per frame counts the
|
|
286
|
+
DAG as it actually is.
|
|
287
|
+
"""
|
|
288
|
+
if self._trigger is None:
|
|
289
|
+
return {t.id for t in workflow.tasks}
|
|
271
290
|
try:
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
execution_plan(
|
|
278
|
-
workflow.tasks, trigger_id=target, edges=workflow.edges
|
|
279
|
-
)
|
|
291
|
+
return set(
|
|
292
|
+
execution_plan(
|
|
293
|
+
workflow.tasks,
|
|
294
|
+
trigger_id=self._trigger,
|
|
295
|
+
edges=workflow.edges,
|
|
280
296
|
)
|
|
297
|
+
)
|
|
281
298
|
except Exception:
|
|
282
|
-
|
|
299
|
+
return {t.id for t in workflow.tasks}
|
|
283
300
|
|
|
284
301
|
@contextmanager
|
|
285
302
|
def live(self) -> Iterator[None]:
|
|
@@ -470,7 +487,7 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
|
|
|
470
487
|
return Panel(line, border_style=style, padding=(0, 1))
|
|
471
488
|
|
|
472
489
|
def _render_progress(self, workflow: BaseWorkflow) -> RenderableType:
|
|
473
|
-
scope = self.
|
|
490
|
+
scope = self._scope_ids(workflow)
|
|
474
491
|
total = len(scope)
|
|
475
492
|
tasks = [t for t in workflow.tasks if t.id in scope]
|
|
476
493
|
done = sum(1 for t in tasks if t.status in _TERMINAL)
|
|
@@ -586,7 +603,7 @@ class WorkflowTUISubscriber(BaseEventSubscriber):
|
|
|
586
603
|
if workflow is None:
|
|
587
604
|
return Text(_("No workflow ran."), style="dim")
|
|
588
605
|
|
|
589
|
-
scope = self.
|
|
606
|
+
scope = self._scope_ids(workflow)
|
|
590
607
|
tasks = [t for t in workflow.tasks if t.id in scope]
|
|
591
608
|
done = sum(1 for t in tasks if t.status in _TERMINAL)
|
|
592
609
|
elapsed = (
|
|
@@ -334,7 +334,9 @@ class MapExpander(HorusTask):
|
|
|
334
334
|
)
|
|
335
335
|
self._set_input_path(clone, self.over.index_input, index_path)
|
|
336
336
|
|
|
337
|
-
self._set_output_path(
|
|
337
|
+
await self._set_output_path(
|
|
338
|
+
clone, gathered_root / str(i), orchestrator
|
|
339
|
+
)
|
|
338
340
|
|
|
339
341
|
clones.append(clone)
|
|
340
342
|
edges.append(
|
|
@@ -592,8 +594,31 @@ class MapExpander(HorusTask):
|
|
|
592
594
|
)
|
|
593
595
|
self._pin_path(artifact, path)
|
|
594
596
|
|
|
595
|
-
def _set_output_path(
|
|
596
|
-
|
|
597
|
+
async def _set_output_path(
|
|
598
|
+
self, clone: BaseTask, slot_dir: Path, orchestrator: BaseTarget
|
|
599
|
+
) -> None:
|
|
600
|
+
"""
|
|
601
|
+
Pin *clone*'s (sole) declared output under its ``{i}/`` slot dir.
|
|
602
|
+
|
|
603
|
+
A :class:`.FolderArtifact` output already names a directory, so it
|
|
604
|
+
is pinned straight at *slot_dir* (the gather task then sees
|
|
605
|
+
``{i}/<whatever the clone wrote>``). Any other output keeps its
|
|
606
|
+
declared filename, pinned at ``slot_dir / <filename>``; since
|
|
607
|
+
nothing else creates *slot_dir* for a bare file output, it is
|
|
608
|
+
created here first (only in this branch -- pre-creating it for a
|
|
609
|
+
folder output would make the clone look already-complete and skip
|
|
610
|
+
it on resume).
|
|
611
|
+
|
|
612
|
+
A :class:`~horus_builtin.workflow.subworkflow.expander.
|
|
613
|
+
SubworkflowExpander` clone is a third case: its sole declared
|
|
614
|
+
output is always a never-written ``FileArtifact`` port placeholder,
|
|
615
|
+
regardless of what the real inner producer's output kind is. Its
|
|
616
|
+
own ``_run`` re-points that real producer at wherever the
|
|
617
|
+
placeholder got pinned (see ``SubworkflowExpander._pin_out_port``),
|
|
618
|
+
so it is pinned straight at *slot_dir* like a folder output,
|
|
619
|
+
leaving that re-pointing free to hand the real producer's own
|
|
620
|
+
declared filename onward.
|
|
621
|
+
"""
|
|
597
622
|
if len(clone.outputs) != 1:
|
|
598
623
|
raise MapConfigurationError(
|
|
599
624
|
_(
|
|
@@ -602,7 +627,20 @@ class MapExpander(HorusTask):
|
|
|
602
627
|
)
|
|
603
628
|
% {"id": self.id}
|
|
604
629
|
)
|
|
605
|
-
|
|
630
|
+
# Imported here, as in BaseWorkflow.sub, to avoid a
|
|
631
|
+
# base.py <-> map.py <-> expander.py cycle at module load time.
|
|
632
|
+
from horus_builtin.workflow.subworkflow.expander import ( # noqa: PLC0415
|
|
633
|
+
SubworkflowExpander,
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
artifact = clone.outputs[0]
|
|
637
|
+
if isinstance(artifact, FolderArtifact) or isinstance(
|
|
638
|
+
clone, SubworkflowExpander
|
|
639
|
+
):
|
|
640
|
+
self._pin_path(artifact, slot_dir)
|
|
641
|
+
else:
|
|
642
|
+
await orchestrator.mkdir(str(slot_dir))
|
|
643
|
+
self._pin_path(artifact, slot_dir / artifact.path.name)
|
|
606
644
|
|
|
607
645
|
@staticmethod
|
|
608
646
|
def _clone_output_id(clone: BaseTask) -> str:
|
|
@@ -665,9 +703,30 @@ def lower_map_entry(
|
|
|
665
703
|
``(expander_dict, edges)`` — a ``kind: map_expander`` task dict
|
|
666
704
|
ready for ``BaseTask.model_validate``, and any edges (as plain
|
|
667
705
|
dicts) that must accompany it into the workflow's ``edges`` list.
|
|
706
|
+
|
|
707
|
+
Raises:
|
|
708
|
+
MapConfigurationError: If *entry* is missing the ``id`` key, or its
|
|
709
|
+
``map`` block is missing ``template`` or ``gather``, or
|
|
710
|
+
``gather`` is missing ``task`` or ``input``.
|
|
668
711
|
"""
|
|
669
|
-
task_id = entry
|
|
712
|
+
task_id = entry.get("id")
|
|
713
|
+
if task_id is None:
|
|
714
|
+
raise MapConfigurationError(
|
|
715
|
+
_("A task with a 'map' block is missing required key 'id'.")
|
|
716
|
+
)
|
|
670
717
|
map_block = entry["map"]
|
|
718
|
+
|
|
719
|
+
if "template" not in map_block:
|
|
720
|
+
raise MapConfigurationError(
|
|
721
|
+
_("Map task '%(id)s' is missing required key 'map.template'.")
|
|
722
|
+
% {"id": task_id}
|
|
723
|
+
)
|
|
724
|
+
if "gather" not in map_block:
|
|
725
|
+
raise MapConfigurationError(
|
|
726
|
+
_("Map task '%(id)s' is missing required key 'map.gather'.")
|
|
727
|
+
% {"id": task_id}
|
|
728
|
+
)
|
|
729
|
+
|
|
671
730
|
over_block = map_block.get("over") or {}
|
|
672
731
|
range_value = map_block.get("range")
|
|
673
732
|
index_input = map_block.get("index_input", over_block.get("index_input"))
|
|
@@ -681,6 +740,19 @@ def lower_map_entry(
|
|
|
681
740
|
}
|
|
682
741
|
|
|
683
742
|
gather_block = map_block["gather"]
|
|
743
|
+
if not isinstance(gather_block, dict) or "task" not in gather_block:
|
|
744
|
+
raise MapConfigurationError(
|
|
745
|
+
_("Map task '%(id)s' 'map.gather' is missing required key 'task'.")
|
|
746
|
+
% {"id": task_id}
|
|
747
|
+
)
|
|
748
|
+
if "input" not in gather_block:
|
|
749
|
+
raise MapConfigurationError(
|
|
750
|
+
_(
|
|
751
|
+
"Map task '%(id)s' 'map.gather' is missing required "
|
|
752
|
+
"key 'input'."
|
|
753
|
+
)
|
|
754
|
+
% {"id": task_id}
|
|
755
|
+
)
|
|
684
756
|
|
|
685
757
|
expander: dict[str, Any] = {
|
|
686
758
|
"kind": "map_expander",
|
|
@@ -182,8 +182,21 @@ async def _execute_ready_task(
|
|
|
182
182
|
await workflow.transfer_artifacts(task, source_map)
|
|
183
183
|
|
|
184
184
|
# Execute the task on its target and wait for it to finish.
|
|
185
|
-
|
|
186
|
-
|
|
185
|
+
try:
|
|
186
|
+
await target.dispatch(task)
|
|
187
|
+
await target.wait()
|
|
188
|
+
except asyncio.CancelledError:
|
|
189
|
+
# Cancelling this wrapper also cancels the inner task future
|
|
190
|
+
# (cancellation propagates through `target.wait()`'s await),
|
|
191
|
+
# so by now the task has recorded CANCELED and the future is
|
|
192
|
+
# done; `target.cancel()` would early-return. Call the
|
|
193
|
+
# executor's kill hook directly so any external process
|
|
194
|
+
# (container, remote job) that does not die with the
|
|
195
|
+
# coroutine is terminated. Shielded so a second cancel
|
|
196
|
+
# cannot abort the kill mid-flight.
|
|
197
|
+
if task.status is TaskStatus.CANCELED:
|
|
198
|
+
await asyncio.shield(task.executor.cancel_execution())
|
|
199
|
+
raise
|
|
187
200
|
finally:
|
|
188
201
|
task.target = declared_target
|
|
189
202
|
pool.release(declared_target, target)
|
|
@@ -379,9 +392,18 @@ async def run_schedule(workflow: BaseWorkflow, trigger_id: str) -> None:
|
|
|
379
392
|
)
|
|
380
393
|
running[wrapper] = task_id
|
|
381
394
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
395
|
+
try:
|
|
396
|
+
done, _pending = await asyncio.wait(
|
|
397
|
+
running, return_when=asyncio.FIRST_COMPLETED
|
|
398
|
+
)
|
|
399
|
+
except asyncio.CancelledError:
|
|
400
|
+
# Workflow-level cancellation: `asyncio.wait` does not cancel the
|
|
401
|
+
# tasks it waits on, so propagate the cancel to every in-flight
|
|
402
|
+
# wrapper (each one kills its executor via `target.cancel()`, see
|
|
403
|
+
# `_execute_ready_task`) and wait for them to unwind before
|
|
404
|
+
# re-raising.
|
|
405
|
+
await _cancel_running(running)
|
|
406
|
+
raise
|
|
385
407
|
|
|
386
408
|
failures = _collect_completions(done, running, completed)
|
|
387
409
|
if failures and workflow.failure_policy == "fail_fast":
|
{horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/expander.py
RENAMED
|
@@ -252,7 +252,7 @@ class SubworkflowExpander(HorusTask):
|
|
|
252
252
|
)
|
|
253
253
|
)
|
|
254
254
|
|
|
255
|
-
edges.extend(self._out_port_edges(wf, out_ports))
|
|
255
|
+
edges.extend(self._out_port_edges(wf, out_ports, by_id))
|
|
256
256
|
|
|
257
257
|
wf.expand(tasks=tasks, edges=edges, artifacts=artifacts)
|
|
258
258
|
horus_logger.log.debug(
|
|
@@ -442,7 +442,10 @@ class SubworkflowExpander(HorusTask):
|
|
|
442
442
|
)
|
|
443
443
|
|
|
444
444
|
def _out_port_edges(
|
|
445
|
-
self,
|
|
445
|
+
self,
|
|
446
|
+
wf: BaseWorkflow,
|
|
447
|
+
out_ports: list[SubworkflowPort],
|
|
448
|
+
by_id: dict[str, BaseTask],
|
|
446
449
|
) -> list[WorkflowEdge]:
|
|
447
450
|
"""
|
|
448
451
|
Re-emit every parent edge sourcing an out-port from the real inner
|
|
@@ -451,12 +454,16 @@ class SubworkflowExpander(HorusTask):
|
|
|
451
454
|
"""
|
|
452
455
|
edges: list[WorkflowEdge] = []
|
|
453
456
|
for port in out_ports:
|
|
454
|
-
if port.task is None
|
|
457
|
+
if port.task is None:
|
|
458
|
+
continue
|
|
459
|
+
if _is_pinned(self.outputs, port.name):
|
|
455
460
|
# An enclosing construct (a MapExpander materializing this
|
|
456
|
-
# clone's slot) has taken the port over and
|
|
457
|
-
# placeholder
|
|
458
|
-
#
|
|
459
|
-
#
|
|
461
|
+
# clone's slot) has taken the port over and pinned the
|
|
462
|
+
# placeholder to the slot's absolute path. Point the real
|
|
463
|
+
# inner producer's output at that same path instead, so
|
|
464
|
+
# the slot receives the clone's actual result rather than
|
|
465
|
+
# the never-written placeholder.
|
|
466
|
+
self._pin_out_port(by_id, port)
|
|
460
467
|
continue
|
|
461
468
|
for edge in wf.edges:
|
|
462
469
|
if edge.source != self.id or edge.source_output != port.name:
|
|
@@ -487,6 +494,33 @@ class SubworkflowExpander(HorusTask):
|
|
|
487
494
|
)
|
|
488
495
|
return edges
|
|
489
496
|
|
|
497
|
+
def _pin_out_port(
|
|
498
|
+
self, by_id: dict[str, BaseTask], port: SubworkflowPort
|
|
499
|
+
) -> None:
|
|
500
|
+
"""
|
|
501
|
+
Point the real inner producer's output artifact at the absolute
|
|
502
|
+
path an enclosing construct pinned *port*'s placeholder to.
|
|
503
|
+
|
|
504
|
+
Mirrors the "pinned" branch of :meth:`_resolve_in_port`: instead of
|
|
505
|
+
the placeholder, the actual producer now writes directly to the
|
|
506
|
+
slot the enclosing construct materialized, so it is filled with
|
|
507
|
+
the real result rather than staying empty.
|
|
508
|
+
"""
|
|
509
|
+
assert port.task is not None
|
|
510
|
+
producer = by_id.get(self._prefixed(port.task))
|
|
511
|
+
if producer is None:
|
|
512
|
+
return
|
|
513
|
+
artifact = next(
|
|
514
|
+
(a for a in producer.outputs if a.id == port.artifact), None
|
|
515
|
+
)
|
|
516
|
+
placeholder = next(
|
|
517
|
+
(a for a in self.outputs if a.id == port.name), None
|
|
518
|
+
)
|
|
519
|
+
if artifact is None or placeholder is None:
|
|
520
|
+
return
|
|
521
|
+
assert placeholder.declared_path is not None
|
|
522
|
+
_pin(artifact, placeholder.declared_path)
|
|
523
|
+
|
|
490
524
|
|
|
491
525
|
def _is_pinned(artifacts: list[BaseArtifact], port_name: str) -> bool:
|
|
492
526
|
"""Whether *port_name*'s placeholder was pinned to an absolute path."""
|
{horus_runtime-0.2.1 → horus_runtime-0.2.2}/src/horus_builtin/workflow/subworkflow/lowering.py
RENAMED
|
@@ -21,6 +21,9 @@ YAML sugar lowering for the subworkflow construct.
|
|
|
21
21
|
|
|
22
22
|
from typing import Any
|
|
23
23
|
|
|
24
|
+
from horus_builtin.workflow.subworkflow.errors import SubworkflowError
|
|
25
|
+
from horus_runtime.i18n import tr as _
|
|
26
|
+
|
|
24
27
|
|
|
25
28
|
def lower_subworkflow_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
26
29
|
"""
|
|
@@ -37,8 +40,20 @@ def lower_subworkflow_entry(entry: dict[str, Any]) -> dict[str, Any]:
|
|
|
37
40
|
Returns:
|
|
38
41
|
A ``kind: subworkflow`` task dict ready for
|
|
39
42
|
``BaseTask.model_validate``.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
SubworkflowError: If *entry* is missing the ``id`` or ``sub`` key.
|
|
40
46
|
"""
|
|
41
|
-
task_id = entry
|
|
47
|
+
task_id = entry.get("id")
|
|
48
|
+
if task_id is None:
|
|
49
|
+
raise SubworkflowError(
|
|
50
|
+
_("A task with a 'sub' block is missing required key 'id'.")
|
|
51
|
+
)
|
|
52
|
+
if "sub" not in entry:
|
|
53
|
+
raise SubworkflowError(
|
|
54
|
+
_("Subworkflow task '%(id)s' is missing required key 'sub'.")
|
|
55
|
+
% {"id": task_id}
|
|
56
|
+
)
|
|
42
57
|
data: dict[str, Any] = {
|
|
43
58
|
"kind": "subworkflow",
|
|
44
59
|
"id": task_id,
|
|
@@ -253,12 +253,11 @@ class BaseWorkflow(AutoRegistry, entry_point="workflow"):
|
|
|
253
253
|
_revision: int = PrivateAttr(default=0)
|
|
254
254
|
"""
|
|
255
255
|
Monotonically increasing counter for the workflow's DAG structure
|
|
256
|
-
(tasks/edges/artifacts).
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
scheduler itself.
|
|
256
|
+
(tasks/edges/artifacts). Bumped by :meth:`add_task`, :meth:`add_edge`,
|
|
257
|
+
:meth:`add_artifact` and :meth:`expand` whenever the graph mutates
|
|
258
|
+
mid-run (e.g. a dynamic fan-out/map/loop expansion), so the scheduler's
|
|
259
|
+
cached source map (see :meth:`cached_source_map`) picks up the change
|
|
260
|
+
without any changes to the scheduler itself.
|
|
262
261
|
"""
|
|
263
262
|
|
|
264
263
|
@model_validator(mode="before")
|
|
@@ -27,8 +27,15 @@ if TYPE_CHECKING:
|
|
|
27
27
|
from horus_runtime.core.workflow.base import BaseWorkflow
|
|
28
28
|
|
|
29
29
|
|
|
30
|
-
class WorkflowError(
|
|
31
|
-
"""
|
|
30
|
+
class WorkflowError(ValueError):
|
|
31
|
+
"""
|
|
32
|
+
Base exception for workflow-related errors.
|
|
33
|
+
|
|
34
|
+
Derives from :class:`ValueError` so that any subclass raised inside a
|
|
35
|
+
Pydantic validator (e.g. ``BaseWorkflow``'s ``model_validator`` hooks)
|
|
36
|
+
is automatically wrapped into a :class:`pydantic.ValidationError` by
|
|
37
|
+
Pydantic, instead of escaping as a raw, unwrapped exception.
|
|
38
|
+
"""
|
|
32
39
|
|
|
33
40
|
|
|
34
41
|
class OneWorkflowAtATimeError(WorkflowError):
|
|
@@ -40,9 +40,13 @@ the comments and the executor anchor that these files are written around.
|
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
import re
|
|
43
|
-
from dataclasses import dataclass
|
|
43
|
+
from dataclasses import dataclass, replace
|
|
44
44
|
from pathlib import Path
|
|
45
45
|
|
|
46
|
+
import yaml
|
|
47
|
+
|
|
48
|
+
from horus_builtin.workflow.map import MapExpander
|
|
49
|
+
from horus_runtime.core.artifact.base import BaseArtifact
|
|
46
50
|
from horus_runtime.core.workflow.base import BaseWorkflow
|
|
47
51
|
|
|
48
52
|
|
|
@@ -55,17 +59,31 @@ class RootInput:
|
|
|
55
59
|
"""An unwired task input, and the root artifact it would be promoted to."""
|
|
56
60
|
|
|
57
61
|
root_id: str
|
|
58
|
-
"""Id for the new root artifact
|
|
59
|
-
|
|
60
|
-
kind: str
|
|
61
|
-
"""Artifact kind, copied from the task input."""
|
|
62
|
+
"""Id for the new root artifact; differs from the input's own id only
|
|
63
|
+
when that id is already taken (see :func:`_root_id`)."""
|
|
62
64
|
|
|
63
|
-
|
|
64
|
-
"""
|
|
65
|
+
artifact: BaseArtifact
|
|
66
|
+
"""
|
|
67
|
+
The task input being promoted, kept whole rather than field-by-field so
|
|
68
|
+
every declared field (name, description, and whatever a plugin artifact
|
|
69
|
+
kind adds) travels to the root artifact without this module knowing it.
|
|
70
|
+
"""
|
|
65
71
|
|
|
66
72
|
consumers: tuple[tuple[str, str], ...]
|
|
67
73
|
"""``(task_id, input_id)`` pairs to wire, one edge each."""
|
|
68
74
|
|
|
75
|
+
@property
|
|
76
|
+
def path(self) -> Path:
|
|
77
|
+
"""Workflow-relative path, exactly as declared."""
|
|
78
|
+
declared = self.artifact.declared_path
|
|
79
|
+
assert declared is not None # only set for inputs with a declared path
|
|
80
|
+
return declared
|
|
81
|
+
|
|
82
|
+
@property
|
|
83
|
+
def kind(self) -> str:
|
|
84
|
+
"""Artifact kind, as declared on the task input."""
|
|
85
|
+
return self.artifact.kind
|
|
86
|
+
|
|
69
87
|
|
|
70
88
|
@dataclass(frozen=True)
|
|
71
89
|
class MissingEdge:
|
|
@@ -119,6 +137,14 @@ def find_root_inputs(
|
|
|
119
137
|
for edge in workflow.edges
|
|
120
138
|
if edge.target_input is not None
|
|
121
139
|
}
|
|
140
|
+
# A map's fan-in input is wired by MapExpander at run time, not by a
|
|
141
|
+
# static edge, so it would otherwise look like an author-supplied root
|
|
142
|
+
# input and get promoted to one.
|
|
143
|
+
wired |= {
|
|
144
|
+
(task.gather_task, task.gather_input)
|
|
145
|
+
for task in workflow.tasks
|
|
146
|
+
if isinstance(task, MapExpander)
|
|
147
|
+
}
|
|
122
148
|
# Reuses the runtime's own produced-vs-external rule rather than
|
|
123
149
|
# restating it here, so sanitizing can never drift from packaging.
|
|
124
150
|
produced = workflow._produced_declared_paths() # noqa: SLF001
|
|
@@ -151,13 +177,12 @@ def find_root_inputs(
|
|
|
151
177
|
)
|
|
152
178
|
continue
|
|
153
179
|
# One file consumed by several tasks travels as one root
|
|
154
|
-
# artifact with one edge per consumer
|
|
180
|
+
# artifact with one edge per consumer; the first consumer's
|
|
181
|
+
# declaration is the one promoted.
|
|
155
182
|
existing = by_path.get(declared)
|
|
156
183
|
if existing is not None:
|
|
157
|
-
by_path[declared] =
|
|
158
|
-
|
|
159
|
-
kind=existing.kind,
|
|
160
|
-
path=existing.path,
|
|
184
|
+
by_path[declared] = replace(
|
|
185
|
+
existing,
|
|
161
186
|
consumers=(*existing.consumers, (task.id, artifact.id)),
|
|
162
187
|
)
|
|
163
188
|
continue
|
|
@@ -165,25 +190,41 @@ def find_root_inputs(
|
|
|
165
190
|
taken.add(root_id)
|
|
166
191
|
by_path[declared] = RootInput(
|
|
167
192
|
root_id=root_id,
|
|
168
|
-
|
|
169
|
-
path=declared,
|
|
193
|
+
artifact=artifact,
|
|
170
194
|
consumers=((task.id, artifact.id),),
|
|
171
195
|
)
|
|
172
196
|
|
|
173
197
|
return sorted(by_path.values(), key=lambda r: r.path), missing
|
|
174
198
|
|
|
175
199
|
|
|
200
|
+
def _artifact_block(root: RootInput) -> list[str]:
|
|
201
|
+
"""Render one promoted artifact, with every field the author declared."""
|
|
202
|
+
# exclude_defaults keeps the block to what the author actually wrote. id
|
|
203
|
+
# and path are then overridden: the model holds the artifact's own id
|
|
204
|
+
# (the root may have been renamed to avoid a clash) and a CWD-resolved
|
|
205
|
+
# absolute path (see BaseArtifact.resolve_path), neither of which belongs
|
|
206
|
+
# in a portable workflow file.
|
|
207
|
+
data = root.artifact.model_dump(mode="json", exclude_defaults=True)
|
|
208
|
+
data["id"] = root.root_id
|
|
209
|
+
data["path"] = root.path.as_posix()
|
|
210
|
+
data.setdefault("kind", root.kind) # kind is a default on every subclass
|
|
211
|
+
# default_name assigns name = id after validation, so an unset name
|
|
212
|
+
# survives exclude_defaults as an echo of the id, carrying nothing the
|
|
213
|
+
# id doesn't already say.
|
|
214
|
+
if data.get("name") == root.artifact.id:
|
|
215
|
+
data.pop("name")
|
|
216
|
+
block = yaml.safe_dump([data], sort_keys=False, allow_unicode=True)
|
|
217
|
+
# Blank lines occur inside a quoted multi-line scalar, where they are the
|
|
218
|
+
# line break itself; indenting them would only add trailing whitespace.
|
|
219
|
+
return [f" {line}" if line else "" for line in block.splitlines()] + [""]
|
|
220
|
+
|
|
221
|
+
|
|
176
222
|
def _render(root_inputs: list[RootInput]) -> tuple[list[str], list[str]]:
|
|
177
223
|
"""Render the ``artifacts:`` entries and ``edges:`` entries to insert."""
|
|
178
224
|
artifacts: list[str] = []
|
|
179
225
|
edges: list[str] = []
|
|
180
226
|
for root in root_inputs:
|
|
181
|
-
artifacts +=
|
|
182
|
-
f" - id: {root.root_id}",
|
|
183
|
-
f" kind: {root.kind}",
|
|
184
|
-
f" path: {root.path.as_posix()}",
|
|
185
|
-
"",
|
|
186
|
-
]
|
|
227
|
+
artifacts += _artifact_block(root)
|
|
187
228
|
for task_id, input_id in root.consumers:
|
|
188
229
|
edges += [
|
|
189
230
|
f" - source: artifact-{root.root_id}",
|
|
@@ -228,22 +269,23 @@ def apply_promotions(text: str, root_inputs: list[RootInput]) -> str:
|
|
|
228
269
|
artifact_lines, edge_lines = _render(root_inputs)
|
|
229
270
|
keys = _top_level_keys(lines)
|
|
230
271
|
|
|
231
|
-
if "edges" not in keys:
|
|
232
|
-
raise SanitizeError(
|
|
233
|
-
"Workflow has no top-level 'edges:' block to extend."
|
|
234
|
-
)
|
|
235
|
-
|
|
236
272
|
# Insert the later block first so the earlier block's index stays valid.
|
|
237
|
-
|
|
238
|
-
|
|
273
|
+
if "edges" in keys:
|
|
274
|
+
edges_at = _block_end(lines, keys["edges"], keys)
|
|
275
|
+
lines[edges_at:edges_at] = ["", *edge_lines] if edge_lines else []
|
|
276
|
+
else:
|
|
277
|
+
# A map:-only workflow has no top-level edges: block of its own (the
|
|
278
|
+
# map block describes all its wiring); create one at the end.
|
|
279
|
+
lines += ["", "edges:", *edge_lines]
|
|
239
280
|
|
|
240
281
|
if "artifacts" in keys:
|
|
241
282
|
at = _block_end(lines, keys["artifacts"], keys)
|
|
242
283
|
lines[at:at] = artifact_lines
|
|
243
284
|
else:
|
|
244
285
|
# Root inputs read best above the tasks that consume them; fall back
|
|
245
|
-
# to the edges block
|
|
246
|
-
|
|
286
|
+
# to the edges block (just created above if it didn't already
|
|
287
|
+
# exist) for a workflow with no tasks key of its own.
|
|
288
|
+
anchor = keys.get("tasks", keys.get("edges", len(lines)))
|
|
247
289
|
lines[anchor:anchor] = ["artifacts:", *artifact_lines]
|
|
248
290
|
|
|
249
291
|
return "\n".join(lines) + "\n"
|
|
@@ -34,6 +34,7 @@ from horus_runtime.core.interaction.transport import (
|
|
|
34
34
|
)
|
|
35
35
|
from horus_runtime.core.task.base import BaseTask
|
|
36
36
|
from horus_runtime.core.task.status import TaskStatus
|
|
37
|
+
from horus_runtime.core.workflow.edge import WorkflowEdge
|
|
37
38
|
from horus_runtime.event.subscriber import BaseEventSubscriber
|
|
38
39
|
|
|
39
40
|
|
|
@@ -116,6 +117,37 @@ class TestWorkflowTUISubscriber:
|
|
|
116
117
|
tui.handle(answered)
|
|
117
118
|
assert tui._paused is False
|
|
118
119
|
|
|
120
|
+
def test_progress_total_follows_a_growing_dag(self) -> None:
|
|
121
|
+
"""
|
|
122
|
+
Tasks added after ``track()`` count towards the total.
|
|
123
|
+
|
|
124
|
+
A ``map:``/``sub:`` expander only adds its clones once it runs, so a
|
|
125
|
+
scope planned before the run reported ``1/1 tasks`` for a five-clone
|
|
126
|
+
loop map.
|
|
127
|
+
"""
|
|
128
|
+
wf = _workflow()
|
|
129
|
+
wf.edges.append(WorkflowEdge(source="a", target="b"))
|
|
130
|
+
tui = WorkflowTUISubscriber()
|
|
131
|
+
tui.track(wf, trigger_id="a")
|
|
132
|
+
|
|
133
|
+
console = Console(record=True, width=120)
|
|
134
|
+
console.print(tui.render())
|
|
135
|
+
assert "0/2 " in console.export_text()
|
|
136
|
+
|
|
137
|
+
wf.tasks.append(
|
|
138
|
+
HorusTask(
|
|
139
|
+
id="c",
|
|
140
|
+
name="Gamma",
|
|
141
|
+
runtime=CommandRuntime(command="true"),
|
|
142
|
+
executor=ShellExecutor(),
|
|
143
|
+
)
|
|
144
|
+
)
|
|
145
|
+
wf.edges.append(WorkflowEdge(source="b", target="c"))
|
|
146
|
+
|
|
147
|
+
console = Console(record=True, width=120)
|
|
148
|
+
console.print(tui.render())
|
|
149
|
+
assert "0/3 " in console.export_text()
|
|
150
|
+
|
|
119
151
|
def test_setup_is_noop(self) -> None:
|
|
120
152
|
"""setup() is a no-op and must not raise."""
|
|
121
153
|
WorkflowTUISubscriber().setup()
|