diff --git a/cli/src/flowmesh_cli/core/assets.py b/cli/src/flowmesh_cli/core/assets.py index 58be4163..d6b38c05 100644 --- a/cli/src/flowmesh_cli/core/assets.py +++ b/cli/src/flowmesh_cli/core/assets.py @@ -18,9 +18,6 @@ def asset_path(package: str, *parts: str) -> Path: resource = resources.files(package) for part in parts: resource /= part - if not resources.is_resource(package, Path(*parts).as_posix()): - # is_resource only checks files, so we allow directories to pass through. - pass try: with resources.as_file(resource) as path: return Path(path) diff --git a/cli/stack/src/flowmesh_cli_stack/bundle.py b/cli/stack/src/flowmesh_cli_stack/bundle.py index a6de196a..4b98f870 100644 --- a/cli/stack/src/flowmesh_cli_stack/bundle.py +++ b/cli/stack/src/flowmesh_cli_stack/bundle.py @@ -5,42 +5,54 @@ import sys import tarfile import tempfile -from importlib.metadata import PackageNotFoundError, version from pathlib import Path import typer +from flowmesh.models.nodes import NodeRole from flowmesh_cli.core import logging from flowmesh_cli.core.typer import get_typer from packaging.version import InvalidVersion, Version +from . import stack as stack_module +from .utils import DEFAULT_ENV_FILE, parse_node_role, resolve_package_version + app = get_typer( help="Package FlowMesh deployments into portable bundles for distribution." ) +_TLS_SERVER_SUBDIR = "secrets/tls/server" +_TLS_REDIS_SUBDIR = "secrets/tls/redis" +_WORKER_CONFIG_FILE = "configs/worker_config.yaml" + +_SERVER_TLS_SOURCES = ( + Path(_TLS_SERVER_SUBDIR) / "server-ca.pem", + Path(_TLS_SERVER_SUBDIR) / "server.key", + Path(_TLS_SERVER_SUBDIR) / "server.pem", +) +_REDIS_TLS_CA_SOURCE = Path(_TLS_REDIS_SUBDIR) / "redis-ca.pem" +_REDIS_TLS_CERT_SOURCES = ( + Path(_TLS_REDIS_SUBDIR) / "redis-server.pem", + Path(_TLS_REDIS_SUBDIR) / "redis-server.key", +) +_WORKER_CONFIG_SOURCE = Path(_WORKER_CONFIG_FILE) + def _copy_redis_tls_assets(dest: Path, include_tls: bool, *, ca_only: bool) -> None: if not include_tls: return - tls_dir = dest / "tls" / "redis" - ca_src = Path("secrets/tls/redis/redis-ca.pem") - cert_src = Path("secrets/tls/redis/redis-server.pem") - key_src = Path("secrets/tls/redis/redis-server.key") + tls_dir = dest / _TLS_REDIS_SUBDIR + sources: tuple[Path, ...] = (_REDIS_TLS_CA_SOURCE,) + if not ca_only: + sources = sources + _REDIS_TLS_CERT_SOURCES copied = False missing: list[str] = [] - if ca_src.exists(): - tls_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(ca_src, tls_dir / ca_src.name) - copied = True - else: - missing.append(ca_src.name) - if not ca_only: - for src in (cert_src, key_src): - if src.exists(): - tls_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(src, tls_dir / src.name) - copied = True - else: - missing.append(src.name) + for src in sources: + if src.exists(): + tls_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, tls_dir / src.name) + copied = True + else: + missing.append(src.name) if not copied: logging.warning( "Warning: Redis TLS assets not found; bundle created without TLS." @@ -50,15 +62,14 @@ def _copy_redis_tls_assets(dest: Path, include_tls: bool, *, ca_only: bool) -> N logging.warning(f"Warning: Redis TLS assets missing: {missing_str}") -def _copy_server_assets(dest: Path, include_tls: bool) -> None: +def _copy_server_assets( + dest: Path, include_tls: bool, role: NodeRole = NodeRole.ROOT +) -> None: if include_tls: - tls_dir = dest / "tls" / "server" - ca_src = Path("secrets/tls/server/server-ca.pem") - key_src = Path("secrets/tls/server/server.key") - pem_src = Path("secrets/tls/server/server.pem") + tls_dir = dest / _TLS_SERVER_SUBDIR copied = False missing: list[str] = [] - for src in (ca_src, key_src, pem_src): + for src in _SERVER_TLS_SOURCES: if src.exists(): tls_dir.mkdir(parents=True, exist_ok=True) shutil.copy2(src, tls_dir / src.name) @@ -72,13 +83,36 @@ def _copy_server_assets(dest: Path, include_tls: bool) -> None: elif missing: missing_str = ", ".join(missing) logging.warning(f"Warning: TLS assets missing: {missing_str}") - _copy_redis_tls_assets(dest, include_tls, ca_only=True) + # Worker nodes don't host Redis (compose root profile gates it), so they + # only need the CA to verify the root's TLS. + _copy_redis_tls_assets(dest, include_tls, ca_only=role == NodeRole.WORKER) - default_worker_config = Path("configs/worker_config.yaml") - if default_worker_config.exists(): - shutil.copy2(default_worker_config, dest / "worker_config.yaml") + if _WORKER_CONFIG_SOURCE.exists(): + worker_config_dest = dest / _WORKER_CONFIG_FILE + worker_config_dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(_WORKER_CONFIG_SOURCE, worker_config_dest) else: - logging.warning(f"Warning: worker config not found: {default_worker_config}") + logging.warning(f"Warning: worker config not found: {_WORKER_CONFIG_SOURCE}") + + +def _scaffold_server_assets(dest: Path, include_tls: bool) -> None: + """Scaffold the bundle directory layout in-place at ``dest``.""" + dest.mkdir(parents=True, exist_ok=True) + + if include_tls: + for subdir in (_TLS_SERVER_SUBDIR, _TLS_REDIS_SUBDIR): + target = dest / subdir + existed = target.is_dir() + target.mkdir(parents=True, exist_ok=True) + logging.log(f"{'kept' if existed else 'created'} {subdir}/") + + worker_config = dest / _WORKER_CONFIG_FILE + if worker_config.exists(): + logging.log(f"kept {_WORKER_CONFIG_FILE}") + else: + worker_config.parent.mkdir(parents=True, exist_ok=True) + worker_config.touch() + logging.log(f"created {_WORKER_CONFIG_FILE}") def _build_cli_wheels(wheel_dir: Path) -> None: @@ -115,9 +149,8 @@ def _build_cli_wheels(wheel_dir: Path) -> None: def _published_cli_spec() -> str: """Return the published FlowMesh CLI package spec for this release.""" - try: - package_version = version("flowmesh-cli-stack") - except PackageNotFoundError: + package_version = resolve_package_version() + if package_version is None: logging.error("Unable to resolve installed flowmesh-cli-stack version.") raise typer.Exit(code=1) from None try: @@ -143,10 +176,16 @@ def _published_cli_spec() -> str: def _write_install_script( - dest: Path, *, package_spec: str | None = None, include_wheels: bool = False + dest: Path, + *, + package_spec: str | None = None, + include_wheels: bool = False, + role: NodeRole = NodeRole.ROOT, ) -> None: """Write an install.sh script to set up a venv and install FlowMesh CLI.""" script_path = dest / "install.sh" + role_arg = "" if role == NodeRole.ROOT else f" --role {role.value}" + deploy_arg = " --deploy" if include_wheels: install_block = '"$UV_BIN" pip install ./wheels/*.whl' else: @@ -169,6 +208,10 @@ def _write_install_script( script = f"""#!/usr/bin/env bash set -euo pipefail +# Anchor all relative paths (./wheels, .venv, .env, secrets/, configs/) to +# the bundle directory so the operator can run this from anywhere. +cd "$(dirname "$0")" + VENV_DIR="${{VENV_DIR:-.venv}}" UV_BIN="${{UV_BIN:-uv}}" PYTHON_REQ="${{FLOWMESH_PYTHON:-3.12}}" @@ -197,7 +240,7 @@ def _write_install_script( echo "Installed flowmesh CLI into $VENV_DIR." echo "Activate it with 'source $VENV_DIR/bin/activate'." if [ ! -f "$ENV_FILE" ]; then - flowmesh stack init --env-file "$ENV_FILE" + flowmesh stack init --env-file "$ENV_FILE"{role_arg}{deploy_arg} fi echo "Configure $ENV_FILE before executing FlowMesh." echo "Then run:" @@ -211,7 +254,11 @@ def _write_install_script( def _create_bundle_tarball( - tar_path: Path, include_tls: bool, *, include_wheels: bool + tar_path: Path, + include_tls: bool, + *, + include_wheels: bool, + role: NodeRole = NodeRole.ROOT, ) -> None: """Create a deployable bundle as a tar.gz with a top-level prefix.""" tar_path.parent.mkdir(parents=True, exist_ok=True) @@ -219,16 +266,17 @@ def _create_bundle_tarball( with tempfile.TemporaryDirectory(prefix="flowmesh-bundle-") as tmp: staging_root = Path(tmp) / prefix staging_root.mkdir(parents=True, exist_ok=True) - _copy_server_assets(staging_root, include_tls=include_tls) + _copy_server_assets(staging_root, include_tls=include_tls, role=role) if include_wheels: wheel_dir = staging_root / "wheels" _build_cli_wheels(wheel_dir) - _write_install_script(staging_root, include_wheels=True) + _write_install_script(staging_root, include_wheels=True, role=role) else: _write_install_script( staging_root, package_spec=_published_cli_spec(), include_wheels=False, + role=role, ) with tarfile.open(tar_path, mode="w:gz") as tf: # Ensure we archive the top-level prefix directory. @@ -237,6 +285,10 @@ def _create_bundle_tarball( @app.command("export") def bundle_export( + role: str = typer.Argument( + NodeRole.ROOT.value, + help="Target NODE_ROLE for the bundle (root|worker).", + ), output: Path = typer.Option( None, "--output", @@ -254,12 +306,79 @@ def bundle_export( ), ) -> None: """Create a deployment bundle for the server.""" - logging.info("Creating server bundle...") + node_role = parse_node_role(role) + logging.info(f"Creating server bundle for role={node_role.value}...") tar_path = output if tar_path is None: tar_path = Path("./dist/flowmesh_server_bundle.tar.gz") tar_path.parent.mkdir(parents=True, exist_ok=True) _create_bundle_tarball( - tar_path, include_tls=not no_tls, include_wheels=include_wheels + tar_path, + include_tls=not no_tls, + include_wheels=include_wheels, + role=node_role, ) logging.success(f"Bundle created at {tar_path}") + + +@app.command("init") +def bundle_init( + dest: Path = typer.Option( + Path("."), + "--dest", + help="Directory to scaffold the bundle layout in (default: current dir).", + ), + no_tls: bool = typer.Option( + False, "--no-tls", help="Skip TLS placeholder directories." + ), + env_file: Path = typer.Option( + DEFAULT_ENV_FILE, + "--env-file", + help="Env file to write under --dest (or absolute path).", + ), + force: bool = typer.Option( + False, + "--force", + "-f", + help="Overwrite an existing env file without prompting.", + ), + role: str = typer.Option( + NodeRole.ROOT.value, + "--role", + help="Target NODE_ROLE for the scaffolded .env (root|worker).", + ), +) -> None: + """Scaffold an empty bundle layout in --dest.""" + node_role = parse_node_role(role) + logging.info(f"Initializing server bundle layout in '{dest}'...") + _scaffold_server_assets(dest, include_tls=not no_tls) + resolved_env = env_file if env_file.is_absolute() else dest / env_file + resolved_env.parent.mkdir(parents=True, exist_ok=True) + stack_module.init( + env_file=resolved_env, force=force, role=node_role.value, deploy=True + ) + # Paths in the next-steps block are intentionally relative to dest so + # they remain accurate after the user runs the cd line. + env_hint = env_file if not env_file.is_absolute() else resolved_env + cd_hint = "" if dest == Path(".") else f" cd {dest}\n" + env_arg = "" if env_file == DEFAULT_ENV_FILE else f" --env-file {env_hint}" + if no_tls: + tls_hint = "" + elif node_role == NodeRole.WORKER: + # Worker nodes don't host Redis, so only the CA is needed there. + tls_hint = ( + f" drop server TLS certs into {_TLS_SERVER_SUBDIR}/ " + f"and the Redis CA into {_TLS_REDIS_SUBDIR}/redis-ca.pem\n" + ) + else: + tls_hint = ( + f" drop TLS certs into {_TLS_SERVER_SUBDIR}/ and {_TLS_REDIS_SUBDIR}/\n" + ) + logging.success(f"Bundle layout ready at '{dest}'.") + logging.log( + f"Next steps:\n{cd_hint}" + f" edit {env_hint} and {_WORKER_CONFIG_FILE}\n" + f"{tls_hint}" + f" flowmesh stack pull{env_arg}\n" + f" flowmesh stack up{env_arg}" + ) diff --git a/cli/stack/src/flowmesh_cli_stack/env_schema.py b/cli/stack/src/flowmesh_cli_stack/env_schema.py index 65ba33df..9c7edb3c 100644 --- a/cli/stack/src/flowmesh_cli_stack/env_schema.py +++ b/cli/stack/src/flowmesh_cli_stack/env_schema.py @@ -1,5 +1,6 @@ """Stack env schema.""" +from flowmesh.models.nodes import NodeRole from flowmesh_stack.env_schema import ( EnvSchema, EnvSection, @@ -46,9 +47,9 @@ ), EnvVar( "NODE_ROLE", - "root", + NodeRole.ROOT.value, var_type=EnvVarType.ENUM, - choices={"root", "worker"}, + choices=NodeRole, ), EnvVar("NODE_NAMESPACE", "flowmesh"), EnvVar("NODE_CLUSTER", "dev"), @@ -622,3 +623,24 @@ ), ], ) + + +# Schema-default overrides applied when rendering a worker-role .env. +# Unused vars are blanked out to avoid confusion and misconfiguration. +WORKER_ROLE_OVERRIDES = { + "NODE_ROLE": NodeRole.WORKER.value, + "REDIS_TLS_CERT_FILE": "", + "REDIS_TLS_KEY_FILE": "", +} + + +def role_overrides(role: NodeRole) -> dict[str, str]: + """Return the schema-default overrides for a given role's rendered .env.""" + return WORKER_ROLE_OVERRIDES.copy() if role == NodeRole.WORKER else {} + + +def deploy_overrides(deploy: bool, version: str | None = None) -> dict[str, str]: + """Return the schema-default overrides for a deploy-shaped rendered .env.""" + if not (deploy and version): + return {} + return {"FLOWMESH_VERSION": version} diff --git a/cli/stack/src/flowmesh_cli_stack/stack.py b/cli/stack/src/flowmesh_cli_stack/stack.py index 864a0fdd..c8a075b4 100644 --- a/cli/stack/src/flowmesh_cli_stack/stack.py +++ b/cli/stack/src/flowmesh_cli_stack/stack.py @@ -7,6 +7,7 @@ from pathlib import Path import typer +from flowmesh.models.nodes import NodeRole from flowmesh_cli.core import logging from flowmesh_cli.core.assets import AssetNotFoundError, asset_path from flowmesh_cli.core.typer import get_typer @@ -30,12 +31,14 @@ get_push_platforms, ) -from .env_schema import STACK_ENV_SCHEMA +from .env_schema import STACK_ENV_SCHEMA, deploy_overrides, role_overrides from .utils import ( DEFAULT_ENV_FILE, STACK_PATH_KEYS, apply_stack_resource_env, ensure_deploy_paths, + parse_node_role, + resolve_package_version, stack_bake_file, stack_compose_file, stack_env_example, @@ -78,18 +81,16 @@ def _compose( raise typer.Exit(code=result.returncode) -def _node_role(env_file: Path) -> str: +def _node_role(env_file: Path) -> NodeRole: """Return the configured NODE_ROLE (root | worker), defaulting to root if unset.""" raw = parse_env_file(env_file).get("NODE_ROLE", "").strip() - if not raw: - return "root" - role = raw.lower() - if role not in ("root", "worker"): + try: + return NodeRole(raw.lower()) if raw else NodeRole.ROOT + except ValueError: logging.error( f"NODE_ROLE={raw!r} is not a recognized role; expected 'root' or 'worker'." ) raise typer.Exit(code=1) - return role def _resolve_build_targets(batch_targets: list[str]) -> list[str]: @@ -449,7 +450,7 @@ def pull( ) -> None: """Pull Docker images for stack services from the registry.""" args = ["pull"] + (services or []) - profile = "root" if _node_role(env_file) == "root" else None + profile = "root" if _node_role(env_file) == NodeRole.ROOT else None _compose( args, env_file=env_file, env=image_env_overrides(image_tag), profile=profile ) @@ -486,7 +487,7 @@ def up( services are skipped — the worker is expected to connect to the root node's Redis via REDIS_CONTROL_URL / REDIS_TELEMETRY_URL. """ - profile = "root" if _node_role(env_file) == "root" else None + profile = "root" if _node_role(env_file) == NodeRole.ROOT else None _compose( ["up", "-d", "--wait"], env_file=env_file, @@ -546,7 +547,7 @@ def restart( env=image_env_overrides(image_tag), profile="root", ) - profile = "root" if _node_role(env_file) == "root" else None + profile = "root" if _node_role(env_file) == NodeRole.ROOT else None _compose( ["up", "-d", "--wait"], env_file=env_file, @@ -674,18 +675,45 @@ def init( force: bool = typer.Option( False, "--force", "-f", help="Force initialization; overwrite existing files" ), + role: str = typer.Option( + NodeRole.ROOT.value, + "--role", + help="Target NODE_ROLE for the generated env file (root|worker).", + ), + deploy: bool = typer.Option( + False, + "--deploy", + help=( + "Pin FLOWMESH_VERSION to the installed flowmesh-cli-stack package version" + "(falls back to 'latest' if package metadata is missing)." + ), + ), ) -> None: - """Create or overwrite the stack env file from the example template.""" - example = stack_env_example() - if not example.exists(): - logging.error(f"Env example not found: {example}") - raise typer.Exit(code=1) + """Create or overwrite the stack env file rendered from the schema.""" + node_role = parse_node_role(role) if env_file.exists() and not force: if not typer.confirm(f"{env_file} exists. Overwrite?", default=False): logging.info("Keeping existing env file.") return - env_file.write_text(example.read_text()) - logging.success(f"Wrote {env_file} from {example.name}.") + deploy_version: str | None = None + if deploy: + package_version = resolve_package_version() + if package_version is None: + logging.warning( + "Unable to resolve flowmesh-cli-stack version; " + "falling back to FLOWMESH_VERSION=latest. " + "Edit .env if you need a specific version." + ) + deploy_version = "latest" + else: + # GHCR images for releases are pushed at v. + deploy_version = f"v{package_version}" + overrides = { + **role_overrides(node_role), + **deploy_overrides(deploy, deploy_version), + } + env_file.write_text(render_env_example(STACK_ENV_SCHEMA, overrides=overrides)) + logging.success(f"Wrote {env_file} (NODE_ROLE={node_role.value}).") @app.command("purge") diff --git a/cli/stack/src/flowmesh_cli_stack/utils.py b/cli/stack/src/flowmesh_cli_stack/utils.py index d2de1ded..5acb487a 100644 --- a/cli/stack/src/flowmesh_cli_stack/utils.py +++ b/cli/stack/src/flowmesh_cli_stack/utils.py @@ -1,9 +1,13 @@ import os import re from collections.abc import Mapping +from importlib.metadata import PackageNotFoundError, version from pathlib import Path +import typer from flowmesh import FlowMesh +from flowmesh.models.nodes import NodeRole +from flowmesh_cli.core import logging from flowmesh_cli.core.assets import asset_path from flowmesh_stack.env import load_env from flowmesh_stack.node_client import NodeClient @@ -114,3 +118,20 @@ def ensure_deploy_paths(base_dir: Path) -> None: base_dir=base_dir, ) ) + + +def parse_node_role(raw: str) -> NodeRole: + """Parse a CLI-supplied role string into a NodeRole, exiting on invalid input.""" + try: + return NodeRole(raw.strip().lower()) + except ValueError: + logging.error(f"Invalid role {raw!r}; expected one of {', '.join(NodeRole)}.") + raise typer.Exit(code=1) from None + + +def resolve_package_version(name: str = "flowmesh-cli-stack") -> str | None: + """Return the installed flowmesh-cli-stack version, or None if it can't be read.""" + try: + return version(name) + except PackageNotFoundError: + return None diff --git a/docs/CLI.md b/docs/CLI.md index 09e6aee7..c1cae0c7 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -20,7 +20,7 @@ flowmesh result {fetch, download} flowmesh trace {fetch, analyze} flowmesh system {metrics} flowmesh stack {build, push, pull, pullall, up, down, restart, ps, logs} -flowmesh stack bundle export +flowmesh stack bundle {export, init} flowmesh stack worker {up, start, stop, down, list, pull} ``` @@ -123,13 +123,40 @@ prompt when the active `buildx` builder needs to switch. To hand off a deployment bundle with bootstrap/config assets: ```bash -flowmesh stack bundle export +flowmesh stack bundle export # root node (default) +flowmesh stack bundle export worker # worker node flowmesh stack bundle export --include-wheels ``` By default, the bundle's `install.sh` installs the published `flowmesh[cli]` package for the current release. Use `--include-wheels` when you need the archive to carry locally-built CLI/SDK wheels instead. +The `role` positional (`root` | `worker`) feeds into the bundled +`install.sh` so the chained `flowmesh stack init` writes a role-shaped +`.env`. A worker bundle still requires the operator to repoint +`REDIS_CONTROL_URL` / `REDIS_TELEMETRY_URL` at the root node before +`stack up`. + +Alternatively, use `stack bundle init` to prepare a directory for +deployment. It creates empty `secrets/tls/{server,redis}/` and +`configs/worker_config.yaml`, and writes `.env` from the schema. The +normal flow is: + +```bash +pip install flowmesh[cli] +flowmesh stack bundle init # root node (default) +flowmesh stack bundle init --role worker # worker node +# edit .env, configs/worker_config.yaml, drop TLS certs into secrets/tls/{server,redis}/ +flowmesh stack pull +flowmesh stack up +``` + +Existing files are preserved. Use `--dest ` to scaffold elsewhere +and `--force` to overwrite `.env` without prompting. `stack init` +accepts the same `--role` flag for direct (non-bundle) bootstrap, plus +`--deploy` to pin `FLOWMESH_VERSION` to the installed +`flowmesh-cli-stack` package version. Falls back to +`FLOWMESH_VERSION=latest` if the package metadata can't be read. ## SSH tasks diff --git a/hook/pyproject.toml b/hook/pyproject.toml index 8cfa440e..047b522d 100644 --- a/hook/pyproject.toml +++ b/hook/pyproject.toml @@ -11,7 +11,7 @@ requires-python = ">=3.12" license = "Apache-2.0" license-files = ["LICENSE"] dependencies = [ - "lumid-hooks @ git+https://github.com/mlsys-io/lumid.hooks.git@fdb9ddb9bf4203b18d073e5bab84f1173f32561a", + "lumid-hooks>=0.1.0", ] [tool.setuptools] diff --git a/pyproject.toml b/pyproject.toml index 672b1d37..b4b2423d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ runtime-server = [ "flowmesh-hook", "grpcio>=1.76.0", "httpx>=0.28.1", - "lumid-hooks @ git+https://github.com/mlsys-io/lumid.hooks.git@fdb9ddb9bf4203b18d073e5bab84f1173f32561a", + "lumid-hooks>=0.1.0", "protobuf>=5.29.6", "pydantic>=2.12.3", "python-multipart>=0.0.26", @@ -119,7 +119,7 @@ runtime-analytics = [ "pandas>=2.3.3", "psycopg[binary]>=3.2.12", "sqlalchemy>=2.0.44", - "lumid-data-sdk @ git+https://github.com/mlsys-io/lumid.data.git@5e309c856486b5f5cc22073488fabcc76595e78c#subdirectory=sdk", + "lumid-data-sdk>=0.1.0", ] runtime-worker-cpu = [ { include-group = "runtime-worker-core" }, diff --git a/sdk/src/flowmesh/models/__init__.py b/sdk/src/flowmesh/models/__init__.py index c174927a..6c4f47e5 100644 --- a/sdk/src/flowmesh/models/__init__.py +++ b/sdk/src/flowmesh/models/__init__.py @@ -15,6 +15,7 @@ from .nodes import ( Node, NodeRegisterResponse, + NodeRole, NodeWorkerInfo, WorkerRegisterResponse, ) @@ -73,6 +74,7 @@ "PathResponse", "Node", "NodeRegisterResponse", + "NodeRole", "NodeWorkerInfo", "ProfileSummary", "ResultEnvelope", diff --git a/sdk/src/flowmesh/models/nodes.py b/sdk/src/flowmesh/models/nodes.py index 3f0b79af..185654ed 100644 --- a/sdk/src/flowmesh/models/nodes.py +++ b/sdk/src/flowmesh/models/nodes.py @@ -1,5 +1,6 @@ """Node-related models.""" +from enum import StrEnum from typing import Any from pydantic import BaseModel, Field, field_validator @@ -7,6 +8,11 @@ from .workers import WorkerHardware +class NodeRole(StrEnum): + ROOT = "root" + WORKER = "worker" + + class Node(BaseModel): id: str namespace: str diff --git a/sdk/stack/src/flowmesh_stack/env_schema.py b/sdk/stack/src/flowmesh_stack/env_schema.py index b0ec9802..25fd970e 100644 --- a/sdk/stack/src/flowmesh_stack/env_schema.py +++ b/sdk/stack/src/flowmesh_stack/env_schema.py @@ -1,7 +1,7 @@ """Environment schema definitions and pure validation helpers.""" import enum -from collections.abc import Callable +from collections.abc import Callable, Iterable, Mapping from dataclasses import dataclass, field from logging import _nameToLevel as LOG_LEVELS from pathlib import Path @@ -32,7 +32,7 @@ class EnvVar: var_type: EnvVarType = EnvVarType.STRING required: bool = False use_default: bool = False - choices: set[str] | None = None + choices: Iterable[str] | None = None min_value: float | None = None max_value: float | None = None min_length: int | None = None @@ -68,8 +68,16 @@ def schema_keys(schema: EnvSchema) -> set[str]: return keys -def render_env_example(schema: EnvSchema) -> str: - """Render an example .env file based on the schema.""" +def render_env_example( + schema: EnvSchema, overrides: Mapping[str, str] | None = None +) -> str: + """Render an example .env file based on the schema. + + ``overrides`` swaps in a different default value for the listed keys to produce a + worker-shaped env without rebuilding the schema). Keys not present in ``overrides`` + use their schema-declared default. + """ + overrides = overrides or {} lines: list[str] = [] lines.extend(schema.header) for section in schema.sections: @@ -84,7 +92,8 @@ def render_env_example(schema: EnvSchema) -> str: lines.append(f"# {desc_line}") else: lines.append(f"# {description}") - lines.append(f"{var.key}={var.default}") + value = overrides.get(var.key, var.default) + lines.append(f"{var.key}={value}") lines.append("") return "\n".join(lines) diff --git a/src/server/requirements.txt b/src/server/requirements.txt index 8ea40184..9c4f33cf 100644 --- a/src/server/requirements.txt +++ b/src/server/requirements.txt @@ -6,7 +6,7 @@ docker==7.1.0 fastapi==0.120.3 grpcio==1.76.0 httpx==0.28.1 -lumid-hooks @ git+https://github.com/mlsys-io/lumid.hooks.git@fdb9ddb9bf4203b18d073e5bab84f1173f32561a +lumid-hooks==0.1.0 protobuf==5.29.6 pydantic==2.12.3 python-multipart==0.0.27 diff --git a/src/worker/requirements/requirements.txt b/src/worker/requirements/requirements.txt index 87a435fb..5f7e881e 100644 --- a/src/worker/requirements/requirements.txt +++ b/src/worker/requirements/requirements.txt @@ -24,7 +24,7 @@ httpx==0.28.1 hydra-core==1.3.2 ipython==9.5.0 jinja2==3.1.6 -lumid-data-sdk @ git+https://github.com/mlsys-io/lumid.data.git@5e309c856486b5f5cc22073488fabcc76595e78c#subdirectory=sdk +lumid-data-sdk==0.1.0 matplotlib==3.10.6 mcp==1.27.0 nvidia-ml-py==13.580.82 diff --git a/tests/cli/test_bundle.py b/tests/cli/test_bundle.py new file mode 100644 index 00000000..4f3acb7a --- /dev/null +++ b/tests/cli/test_bundle.py @@ -0,0 +1,392 @@ +from importlib.metadata import version +from pathlib import Path + +from flowmesh.models.nodes import NodeRole +from flowmesh_cli_stack import bundle as bundle_module +from flowmesh_cli_stack.bundle import ( + _TLS_REDIS_SUBDIR, + _TLS_SERVER_SUBDIR, + _WORKER_CONFIG_FILE, + _copy_server_assets, + _scaffold_server_assets, + bundle_init, +) +from flowmesh_cli_stack.env_schema import STACK_ENV_SCHEMA, role_overrides +from flowmesh_stack.env_schema import render_env_example, validate_env_values + + +def test_scaffold_creates_full_layout(tmp_path: Path) -> None: + _scaffold_server_assets(tmp_path, include_tls=True) + assert (tmp_path / _TLS_SERVER_SUBDIR).is_dir() + assert (tmp_path / _TLS_REDIS_SUBDIR).is_dir() + worker_config = tmp_path / _WORKER_CONFIG_FILE + assert worker_config.is_file() + assert worker_config.read_bytes() == b"" + + +def test_scaffold_skips_tls_when_disabled(tmp_path: Path) -> None: + _scaffold_server_assets(tmp_path, include_tls=False) + assert not (tmp_path / _TLS_SERVER_SUBDIR).exists() + assert not (tmp_path / _TLS_REDIS_SUBDIR).exists() + assert (tmp_path / _WORKER_CONFIG_FILE).is_file() + + +def test_scaffold_preserves_existing_worker_config(tmp_path: Path) -> None: + worker_config = tmp_path / _WORKER_CONFIG_FILE + worker_config.parent.mkdir(parents=True) + worker_config.write_text("user_data: true\n") + _scaffold_server_assets(tmp_path, include_tls=False) + assert worker_config.read_text() == "user_data: true\n" + + +def test_scaffold_preserves_existing_tls_dirs(tmp_path: Path) -> None: + pre_existing = tmp_path / _TLS_SERVER_SUBDIR / "server.pem" + pre_existing.parent.mkdir(parents=True) + pre_existing.write_text("CERT") + _scaffold_server_assets(tmp_path, include_tls=True) + assert pre_existing.read_text() == "CERT" + + +def test_bundle_init_writes_env_via_stack_init(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=False, + env_file=Path(".env"), + force=False, + role=NodeRole.ROOT.value, + ) + assert (tmp_path / _WORKER_CONFIG_FILE).is_file() + assert (tmp_path / _TLS_SERVER_SUBDIR).is_dir() + env_text = (tmp_path / ".env").read_text() + assert "FLOWMESH_VERSION" in env_text + + +def test_bundle_init_env_defaults_point_at_scaffolded_paths( + tmp_path: Path, monkeypatch +) -> None: + # The whole point of init is that `stack up` works out of the box + # against the scaffolded layout. Pin the env-vs-layout contract. + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=False, + env_file=Path(".env"), + force=False, + role=NodeRole.ROOT.value, + ) + env_text = (tmp_path / ".env").read_text() + assert f"SERVER_TLS_DIR=./{_TLS_SERVER_SUBDIR}" in env_text + assert f"REDIS_TLS_DIR=./{_TLS_REDIS_SUBDIR}" in env_text + assert f"SERVER_WORKER_CONFIG=./{_WORKER_CONFIG_FILE}" in env_text + + +def test_bundle_init_force_overwrites_env(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + env = tmp_path / ".env" + env.write_text("stale=1\n") + bundle_init( + dest=Path("."), + no_tls=True, + env_file=Path(".env"), + force=True, + role=NodeRole.ROOT.value, + ) + assert "stale=1" not in env.read_text() + + +def test_bundle_init_env_file_in_missing_parent(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=True, + env_file=Path("config/.env"), + force=False, + role=NodeRole.ROOT.value, + ) + assert (tmp_path / "config" / ".env").is_file() + + +def test_bundle_init_dest_subdirectory(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + target = tmp_path / "deploy" + bundle_init( + dest=target, + no_tls=False, + env_file=Path(".env"), + force=False, + role=NodeRole.ROOT.value, + ) + assert (target / _WORKER_CONFIG_FILE).is_file() + assert (target / ".env").is_file() + assert not (tmp_path / ".env").exists() + + +def test_copy_server_assets_stages_worker_config_under_configs( + tmp_path: Path, monkeypatch +) -> None: + # `_copy_server_assets` runs against a fresh temp staging dir; the + # worker_config destination is nested under configs/ which doesn't + # pre-exist, so the copy has to create the parent itself. + repo = tmp_path / "repo" + (repo / "configs").mkdir(parents=True) + (repo / "configs" / "worker_config.yaml").write_text("scheduler: round_robin\n") + monkeypatch.chdir(repo) + staging = tmp_path / "stage" + staging.mkdir() + _copy_server_assets(staging, include_tls=False) + staged = staging / _WORKER_CONFIG_FILE + assert staged.is_file() + assert staged.read_text() == "scheduler: round_robin\n" + + +def _seed_redis_tls_sources(repo: Path) -> None: + tls = repo / _TLS_REDIS_SUBDIR + tls.mkdir(parents=True) + (tls / "redis-ca.pem").write_text("CA") + (tls / "redis-server.pem").write_text("CERT") + (tls / "redis-server.key").write_text("KEY") + + +def test_copy_server_assets_root_stages_redis_cert_and_key( + tmp_path: Path, monkeypatch +) -> None: + repo = tmp_path / "repo" + _seed_redis_tls_sources(repo) + monkeypatch.chdir(repo) + staging = tmp_path / "stage" + staging.mkdir() + _copy_server_assets(staging, include_tls=True, role=NodeRole.ROOT) + redis_dir = staging / _TLS_REDIS_SUBDIR + assert (redis_dir / "redis-ca.pem").read_text() == "CA" + assert (redis_dir / "redis-server.pem").read_text() == "CERT" + assert (redis_dir / "redis-server.key").read_text() == "KEY" + + +def test_copy_server_assets_worker_skips_redis_cert_and_key( + tmp_path: Path, monkeypatch +) -> None: + repo = tmp_path / "repo" + _seed_redis_tls_sources(repo) + monkeypatch.chdir(repo) + staging = tmp_path / "stage" + staging.mkdir() + _copy_server_assets(staging, include_tls=True, role=NodeRole.WORKER) + redis_dir = staging / _TLS_REDIS_SUBDIR + assert (redis_dir / "redis-ca.pem").read_text() == "CA" + assert not (redis_dir / "redis-server.pem").exists() + assert not (redis_dir / "redis-server.key").exists() + + +def test_bundle_init_next_steps_include_custom_env_file( + tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=False, + env_file=Path("config/.env"), + force=False, + role=NodeRole.ROOT.value, + ) + out = capsys.readouterr().out + assert "flowmesh stack pull --env-file config/.env" in out + assert "flowmesh stack up --env-file config/.env" in out + + +def test_bundle_init_next_steps_omit_env_flag_for_default( + tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=False, + env_file=Path(".env"), + force=False, + role=NodeRole.ROOT.value, + ) + out = capsys.readouterr().out + assert "flowmesh stack pull\n" in out + assert "flowmesh stack up" in out + assert "--env-file" not in out + + +def test_bundle_init_worker_role_writes_worker_env(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=False, + env_file=Path(".env"), + force=False, + role=NodeRole.WORKER.value, + ) + env_text = (tmp_path / ".env").read_text() + assert "NODE_ROLE=worker" in env_text + assert "NODE_ROLE=root" not in env_text + # Cert/key keys are blanked so the rendered file doesn't suggest + # config the worker operator would have to maintain. + for key in ("REDIS_TLS_CERT_FILE", "REDIS_TLS_KEY_FILE"): + assert f"{key}=\n" in env_text, f"expected {key}= (blank) in worker env" + # bundle_init implies --deploy: the version pin should match the + # installed flowmesh-cli-stack release so compose pulls images at + # the same tag the bundle bootstrap picked. + assert f"FLOWMESH_VERSION=v{version('flowmesh-cli-stack')}" in env_text + + +def test_install_script_passes_role_and_deploy_to_stack_init(tmp_path: Path) -> None: + from flowmesh_cli_stack.bundle import _write_install_script + + _write_install_script( + tmp_path, + package_spec="flowmesh[cli]==0.1.0", + include_wheels=False, + role=NodeRole.WORKER, + ) + script = (tmp_path / "install.sh").read_text() + assert 'flowmesh stack init --env-file "$ENV_FILE" --role worker --deploy' in script + + +def test_install_script_anchors_to_script_directory(tmp_path: Path) -> None: + from flowmesh_cli_stack.bundle import _write_install_script + + _write_install_script( + tmp_path, + package_spec="flowmesh[cli]==0.1.0", + include_wheels=False, + role=NodeRole.ROOT, + ) + script = (tmp_path / "install.sh").read_text() + # Without this anchor, running `./flowmesh_server_bundle/install.sh` from + # the parent dir would write .venv / .env into the parent and (with + # --include-wheels) fail to find ./wheels. + assert 'cd "$(dirname "$0")"' in script + + +def test_install_script_omits_role_flag_for_root_but_keeps_deploy( + tmp_path: Path, +) -> None: + from flowmesh_cli_stack.bundle import _write_install_script + + _write_install_script( + tmp_path, + package_spec="flowmesh[cli]==0.1.0", + include_wheels=False, + role=NodeRole.ROOT, + ) + script = (tmp_path / "install.sh").read_text() + assert "--role" not in script + assert 'flowmesh stack init --env-file "$ENV_FILE" --deploy' in script + + +def test_bundle_init_no_tls_drops_cert_guidance( + tmp_path: Path, monkeypatch, capsys +) -> None: + monkeypatch.chdir(tmp_path) + bundle_init( + dest=Path("."), + no_tls=True, + env_file=Path(".env"), + force=False, + role=NodeRole.ROOT.value, + ) + out = capsys.readouterr().out + assert "drop TLS certs" not in out + + +def _parse_env_body(body: str) -> dict[str, str]: + out: dict[str, str] = {} + for line in body.splitlines(): + if not line or line.startswith("#"): + continue + key, _, value = line.partition("=") + out[key.strip()] = value.strip() + return out + + +def test_root_role_render_passes_schema_validation() -> None: + body = render_env_example(STACK_ENV_SCHEMA, overrides=role_overrides(NodeRole.ROOT)) + errors, _ = validate_env_values(STACK_ENV_SCHEMA, _parse_env_body(body)) + assert errors == [] + + +def test_worker_role_render_passes_schema_validation() -> None: + # Pin the contract that a scaffolded worker .env is considered valid + # by the schema's own validators — i.e. the blanked overrides don't + # trip required/min_value/conditional checks. + body = render_env_example( + STACK_ENV_SCHEMA, overrides=role_overrides(NodeRole.WORKER) + ) + errors, _ = validate_env_values(STACK_ENV_SCHEMA, _parse_env_body(body)) + assert errors == [] + + +def test_stack_init_deploy_writes_resolved_version(tmp_path: Path, monkeypatch) -> None: + from flowmesh_cli_stack import stack as stack_module + + monkeypatch.chdir(tmp_path) + env_path = tmp_path / ".env" + stack_module.init( + env_file=env_path, + force=False, + role=NodeRole.ROOT.value, + deploy=True, + ) + env_text = env_path.read_text() + assert f"FLOWMESH_VERSION=v{version('flowmesh-cli-stack')}" in env_text + assert "FLOWMESH_VERSION=dev" not in env_text + + +def test_stack_init_without_deploy_keeps_dev_version( + tmp_path: Path, monkeypatch +) -> None: + from flowmesh_cli_stack import stack as stack_module + + monkeypatch.chdir(tmp_path) + env_path = tmp_path / ".env" + stack_module.init( + env_file=env_path, + force=False, + role=NodeRole.ROOT.value, + deploy=False, + ) + env_text = env_path.read_text() + # The dev placeholder is intentional for local iteration; --deploy + # is opt-in for deploy-shaped scaffolds. + assert "FLOWMESH_VERSION=dev" in env_text + + +def test_stack_init_deploy_falls_back_to_latest_on_missing_metadata( + tmp_path: Path, monkeypatch, capsys +) -> None: + from importlib.metadata import PackageNotFoundError + + from flowmesh_cli_stack import stack as stack_module + from flowmesh_cli_stack import utils as utils_module + + monkeypatch.chdir(tmp_path) + + def _missing(name: str) -> str: + raise PackageNotFoundError(name) + + monkeypatch.setattr(utils_module, "version", _missing) + env_path = tmp_path / ".env" + stack_module.init( + env_file=env_path, + force=False, + role=NodeRole.ROOT.value, + deploy=True, + ) + env_text = env_path.read_text() + assert "FLOWMESH_VERSION=latest" in env_text + # The fallback should surface to the operator so they can pin a + # specific tag if needed. + assert "falling back to FLOWMESH_VERSION=latest" in capsys.readouterr().out + + +def test_module_constants_match_env_defaults() -> None: + # The scaffolded layout has to match the paths the shipped .env.example + # and compose.yml defaults reference, otherwise `stack up` would point + # somewhere other than where `bundle init` / `bundle export` wrote. + assert bundle_module._TLS_SERVER_SUBDIR == "secrets/tls/server" + assert bundle_module._TLS_REDIS_SUBDIR == "secrets/tls/redis" + assert bundle_module._WORKER_CONFIG_FILE == "configs/worker_config.yaml" diff --git a/tests/sdk/test_env_schema.py b/tests/sdk/test_env_schema.py new file mode 100644 index 00000000..58a20991 --- /dev/null +++ b/tests/sdk/test_env_schema.py @@ -0,0 +1,42 @@ +from flowmesh_stack.env_schema import ( + EnvSchema, + EnvSection, + EnvVar, + render_env_example, +) + + +def _toy_schema() -> EnvSchema: + return EnvSchema( + name="toy", + header=["# toy header"], + sections=[ + EnvSection( + title="Role", + vars=[ + EnvVar("NODE_ROLE", "root"), + EnvVar("OTHER", "value"), + ], + ), + ], + ) + + +def test_render_env_example_uses_schema_default_without_overrides() -> None: + body = render_env_example(_toy_schema()) + assert "NODE_ROLE=root" in body + assert "OTHER=value" in body + + +def test_render_env_example_applies_overrides() -> None: + body = render_env_example(_toy_schema(), overrides={"NODE_ROLE": "worker"}) + assert "NODE_ROLE=worker" in body + assert "NODE_ROLE=root" not in body + # Non-overridden keys still use the schema default. + assert "OTHER=value" in body + + +def test_render_env_example_ignores_overrides_for_unknown_keys() -> None: + body = render_env_example(_toy_schema(), overrides={"NOT_A_KEY": "x"}) + assert "NOT_A_KEY" not in body + assert "NODE_ROLE=root" in body diff --git a/tests/sdk/test_schema_compat.py b/tests/sdk/test_schema_compat.py index f0349534..c600b0a8 100644 --- a/tests/sdk/test_schema_compat.py +++ b/tests/sdk/test_schema_compat.py @@ -21,6 +21,7 @@ NetworkInfo, Node, NodeRegisterResponse, + NodeRole, NodeWorkerInfo, OkResponse, ResultEnvelope, @@ -42,6 +43,7 @@ ) # Server-side imports (stubs installed by conftest.py) +from server.config import NodeRole as SrvNodeRole from server.registries.node import Node as SrvNode from server.registries.worker import Worker as SrvWorker from server.registries.worker import WorkerInfo as SrvWorkerInfo @@ -150,6 +152,7 @@ def test_worker_register_response_fields() -> None: (SrvTaskType, TaskType), (SrvLogLevel, LogLevel), (SrvLogStream, LogStream), + (SrvNodeRole, NodeRole), ] diff --git a/uv.lock b/uv.lock index d96559d5..fb9f707f 100644 --- a/uv.lock +++ b/uv.lock @@ -2022,8 +2022,8 @@ ci = [ { name = "ipython", specifier = ">=9.5.0" }, { name = "isort", specifier = ">=7.0.0" }, { name = "jinja2", specifier = ">=3.1.6" }, - { name = "lumid-data-sdk", git = "https://github.com/mlsys-io/lumid.data.git?subdirectory=sdk&rev=5e309c856486b5f5cc22073488fabcc76595e78c" }, - { name = "lumid-hooks", git = "https://github.com/mlsys-io/lumid.hooks.git?rev=fdb9ddb9bf4203b18d073e5bab84f1173f32561a" }, + { name = "lumid-data-sdk", specifier = ">=0.1.0" }, + { name = "lumid-hooks", specifier = ">=0.1.0" }, { name = "matplotlib", specifier = ">=3.10.6" }, { name = "mcp", specifier = ">=1.23.0" }, { name = "mypy", specifier = ">=1.19.1" }, @@ -2144,7 +2144,7 @@ runtime-agent = [ ] runtime-analytics = [ { name = "boto3", specifier = ">=1.41.5" }, - { name = "lumid-data-sdk", git = "https://github.com/mlsys-io/lumid.data.git?subdirectory=sdk&rev=5e309c856486b5f5cc22073488fabcc76595e78c" }, + { name = "lumid-data-sdk", specifier = ">=0.1.0" }, { name = "pandas", specifier = ">=2.3.3" }, { name = "psycopg", extras = ["binary"], specifier = ">=3.2.12" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, @@ -2185,7 +2185,7 @@ runtime-server = [ { name = "flowmesh-hook", editable = "hook" }, { name = "grpcio", specifier = ">=1.76.0" }, { name = "httpx", specifier = ">=0.28.1" }, - { name = "lumid-hooks", git = "https://github.com/mlsys-io/lumid.hooks.git?rev=fdb9ddb9bf4203b18d073e5bab84f1173f32561a" }, + { name = "lumid-hooks", specifier = ">=0.1.0" }, { name = "protobuf", specifier = ">=5.29.6" }, { name = "pydantic", specifier = ">=2.12.3" }, { name = "python-multipart", specifier = ">=0.0.26" }, @@ -2237,7 +2237,7 @@ runtime-worker-cpu = [ { name = "hydra-core", specifier = ">=1.3.2" }, { name = "ipython", specifier = ">=9.5.0" }, { name = "jinja2", specifier = ">=3.1.6" }, - { name = "lumid-data-sdk", git = "https://github.com/mlsys-io/lumid.data.git?subdirectory=sdk&rev=5e309c856486b5f5cc22073488fabcc76595e78c" }, + { name = "lumid-data-sdk", specifier = ">=0.1.0" }, { name = "matplotlib", specifier = ">=3.10.6" }, { name = "mcp", specifier = ">=1.23.0" }, { name = "nvidia-ml-py", specifier = ">=13.580.82" }, @@ -2338,7 +2338,7 @@ dependencies = [ ] [package.metadata] -requires-dist = [{ name = "lumid-hooks", git = "https://github.com/mlsys-io/lumid.hooks.git?rev=fdb9ddb9bf4203b18d073e5bab84f1173f32561a" }] +requires-dist = [{ name = "lumid-hooks", specifier = ">=0.1.0" }] [[package]] name = "flowmesh-sdk" @@ -3554,20 +3554,28 @@ wheels = [ [[package]] name = "lumid-data-sdk" version = "0.1.0" -source = { git = "https://github.com/mlsys-io/lumid.data.git?subdirectory=sdk&rev=5e309c856486b5f5cc22073488fabcc76595e78c#5e309c856486b5f5cc22073488fabcc76595e78c" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "httpx" }, { name = "httpx-sse" }, { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/44/f1/14ecb5d1a2f729c44d3aa405a8d6e673e4e6be3020b32366b9c3c93f9539/lumid_data_sdk-0.1.0.tar.gz", hash = "sha256:aded95d578055c7f116b8075b7dd5fb95c598c703fdec819c7e16783e33c821b", size = 11475, upload-time = "2026-05-12T09:09:33.9Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/26/6bb059c078fc5681b00dad6a03dbbf344d330c6191cb9c4e9d31ac8ac077/lumid_data_sdk-0.1.0-py3-none-any.whl", hash = "sha256:9218d00b94d407b1090e92dca38d8bb25fb5fbdf1c66d240116a18ee95c7da84", size = 11897, upload-time = "2026-05-12T09:09:32.629Z" }, +] [[package]] name = "lumid-hooks" version = "0.1.0" -source = { git = "https://github.com/mlsys-io/lumid.hooks.git?rev=fdb9ddb9bf4203b18d073e5bab84f1173f32561a#fdb9ddb9bf4203b18d073e5bab84f1173f32561a" } +source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, ] +sdist = { url = "https://files.pythonhosted.org/packages/1f/b4/d660b386d13051669eae4c9056154c638302a1eea39fccb569075ed07243/lumid_hooks-0.1.0.tar.gz", hash = "sha256:0ae85cef586645391f21ea3d912de1bf2be77b6b6dd0690f3841bb238fa0315a", size = 11269, upload-time = "2026-05-12T09:07:16.782Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/85/70/84678eb357151f84fd78e6ce8270a42180a2605a3a3168492201feed76bf/lumid_hooks-0.1.0-py3-none-any.whl", hash = "sha256:8cfd8f3ed36001711b70290a5043a213322deaced1046d4583a4739f11cb00a6", size = 12796, upload-time = "2026-05-12T09:07:15.629Z" }, +] [[package]] name = "lxml"