Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
dad6881
fix(packaging): ship UI/CLI deps unconditionally and make component r…
claude Aug 27, 2026
43634b3
fix(ui): make the backend fail closed and implement the endpoints the…
claude Aug 27, 2026
eb305fa
fix(assets): confine artifact file access to the artifacts directory
claude Aug 27, 2026
1d09f8f
fix(api): bound pagination inputs and stop leaking internals in error…
claude Aug 27, 2026
eee6f99
fix(assets): keep artifact ids addressable and locate model files cor…
claude Aug 27, 2026
6d9391d
perf(ui): cut initial bundle from 7.9 MB to 394 KB and fix redirected…
claude Aug 27, 2026
c097081
fix(ui): stop a single bad timestamp from taking down a page; add fro…
claude Aug 27, 2026
e8aef1f
fix(plugins): validate package names before handing them to pip
claude Aug 27, 2026
7df5138
style: rename InvalidPackageName to InvalidPackageNameError for ruff …
claude Aug 27, 2026
4aa120b
fix(api): read the configured database, and report storage failures h…
claude Aug 27, 2026
80d8e14
fix(runs): confine step-log file paths to the runs directory
claude Aug 27, 2026
af844d6
test: reset the cached metadata store between tests
claude Aug 27, 2026
5c6e431
fix(ws): require authentication on the log-streaming WebSockets
claude Aug 27, 2026
8b01f51
fix(explorer): bound the in-memory exploration session state
claude Aug 27, 2026
50cf90c
fix(runs): release heartbeat tracking state and bound the last query …
claude Aug 27, 2026
6bb9663
test: give each xdist worker its own cache directory
claude Aug 27, 2026
04cbfe2
fix(cli): report the real version instead of a hardcoded 0.1.0
claude Aug 27, 2026
d00f0d7
docs: document the production security requirements
claude Aug 27, 2026
6d63698
fix(api): look runs up in every store, and share one implementation
claude Aug 27, 2026
132a93e
fix(auth): keep /api/config reachable before login, and surface start…
claude Aug 27, 2026
01a88f2
fix(ci): regenerate the lock file, restore Python 3.10 support, and m…
claude Aug 27, 2026
da543e6
ci: install only the extras the tests use, and drop the stale asyncio…
claude Aug 27, 2026
174c325
fix(ci): repair the PR-title check, which could never pass
claude Aug 27, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,29 @@ FLOWYML_PORT=8080
FLOWYML_REMOTE_SERVER_URL=http://localhost:8080
FLOWYML_REMOTE_UI_URL=http://localhost:80

# Authentication (CHANGE IN PRODUCTION!)
# Authentication
#
# FLOWYML_ENV=production makes the server fail closed: it refuses to start
# unless FLOWYML_API_TOKEN and FLOWYML_ADMIN_PASSWORD are both set to real
# values. That is deliberate - POST /api/execution/execute imports and runs
# arbitrary Python modules, so an unauthenticated instance is remote code
# execution, and an empty FLOWYML_API_TOKEN used to disable authentication
# silently.
#
# Generate a token with: python -c "import secrets; print(secrets.token_urlsafe(32))"
FLOWYML_AUTH_SECRET=change-this-secret-in-production
FLOWYML_API_TOKEN=
FLOWYML_ADMIN_USER=admin
FLOWYML_ADMIN_PASSWORD=

# Set to 1 only when authentication is enforced by a proxy in front of FlowyML.
# This disables the built-in checks; do not set it otherwise.
FLOWYML_ALLOW_INSECURE=

# Comma-separated browser origins allowed to call the API. Defaults to the
# usual localhost dev ports in development. A wildcard is never used: combined
# with credentials it would let any website read this API.
FLOWYML_CORS_ORIGINS=

# Logging
LOG_LEVEL=INFO
Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/PRECOMMITS.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,14 @@ jobs:
- name: Check Pull Request Title
uses: Slashgear/action-check-pr-title@main
with:
regexp: '(break|build|ci|docs|feat|fix|perf|refactor|style|test|ops|hotfix)\\([a-z,A-Z,0-9,\\-,\\_,\\/,:]+\\)(:)\\s{1}([\\w\\s]+)' # Regex the title should match.
# Kept identical to the `validate-commit-msg` hook in
# .pre-commit-config.yaml so a title that passes locally passes here.
# The previous pattern was double-escaped for a YAML single-quoted
# scalar (which has no backslash escapes), so `[a-z,A-Z,0-9,\-,...]`
# reached JavaScript containing a `\`-to-`,` range and RegExp threw
# "Range out of order in character class" before reading any title -
# the check could never pass, whatever the PR was called.
regexp: '^(break|build|ci|docs|feat|fix|perf|refactor|style|test|ops|hotfix|release|maint|init|enh|revert|reformat)(\([\w.,\-()/]+\))?(!)?:\s.+$'
- name: Getting changed files list
id: files
uses: jitterbit/get-changed-files@v1
Expand Down
62 changes: 59 additions & 3 deletions .github/workflows/UTESTS.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ on:
- "**.py"
- "pyproject.toml"
- "tests/**"
- "flowyml/ui/frontend/**"
pull_request:
branches:
- dev
Expand All @@ -21,6 +22,7 @@ on:
- "**.py"
- "pyproject.toml"
- "tests/**"
- "flowyml/ui/frontend/**"
workflow_dispatch:
inputs:
PYTHON_VERSION:
Expand Down Expand Up @@ -62,7 +64,16 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install --no-interaction --with dev --all-extras
# Only the extras the tests actually exercise. An audit of every
# import in tests/ finds torch, tensorflow, keras and sklearn and
# nothing else; tensorflow and scikit-learn already come from the dev
# group, and keras ships with tensorflow, so `pytorch` is the only
# extra left. --all-extras additionally pulled gcp, aws, azure, kfp,
# sagemaker, mlflow, wandb and the genai stack - roughly 30 packages
# and a set of CUDA wheels that no test imports. The extras' own
# correctness is covered by tests/test_packaging_metadata.py and by
# the --all-extras install the release workflow still performs.
poetry install --no-interaction --with dev --extras pytorch
- name: Run smoke tests
run: |
# Run a quick subset of tests for fast feedback
Expand Down Expand Up @@ -107,7 +118,16 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install --no-interaction --with dev --all-extras
# Only the extras the tests actually exercise. An audit of every
# import in tests/ finds torch, tensorflow, keras and sklearn and
# nothing else; tensorflow and scikit-learn already come from the dev
# group, and keras ships with tensorflow, so `pytorch` is the only
# extra left. --all-extras additionally pulled gcp, aws, azure, kfp,
# sagemaker, mlflow, wandb and the genai stack - roughly 30 packages
# and a set of CUDA wheels that no test imports. The extras' own
# correctness is covered by tests/test_packaging_metadata.py and by
# the --all-extras install the release workflow still performs.
poetry install --no-interaction --with dev --extras pytorch

- name: Run tests - ${{ matrix.test-group }}
run: |
Expand All @@ -131,6 +151,33 @@ jobs:
htmlcov/
retention-days: 7

# Frontend unit tests and production build. Nothing previously exercised the
# UI in CI, so a change that broke the build or a shared helper only surfaced
# at release time, when the wheel is packaged with a prebuilt bundle.
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- name: Set up Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
cache-dependency-path: flowyml/ui/frontend/package-lock.json

- name: Install dependencies
working-directory: flowyml/ui/frontend
run: npm ci

- name: Run unit tests
working-directory: flowyml/ui/frontend
run: npm test

- name: Build production bundle
working-directory: flowyml/ui/frontend
run: npm run build

# Coverage job (only on main Python version)
coverage:
runs-on: ubuntu-latest
Expand All @@ -153,7 +200,16 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install poetry
poetry install --no-interaction --with dev --all-extras
# Only the extras the tests actually exercise. An audit of every
# import in tests/ finds torch, tensorflow, keras and sklearn and
# nothing else; tensorflow and scikit-learn already come from the dev
# group, and keras ships with tensorflow, so `pytorch` is the only
# extra left. --all-extras additionally pulled gcp, aws, azure, kfp,
# sagemaker, mlflow, wandb and the genai stack - roughly 30 packages
# and a set of CUDA wheels that no test imports. The extras' own
# correctness is covered by tests/test_packaging_metadata.py and by
# the --all-extras install the release workflow still performs.
poetry install --no-interaction --with dev --extras pytorch

- name: Run tests with coverage
run: |
Expand Down
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ repos:
hooks:
- id: check-yaml
args: ["--allow-multiple-documents", "--unsafe"]
# The built frontend bundle is generated output, and rewriting it is not
# merely noisy: minified JS contains multi-line template literals where a
# trailing space is part of the string, so stripping it changes what the
# program does. Never let the whitespace fixers near it.
- id: end-of-file-fixer
exclude: ^flowyml/ui/frontend/dist/
- id: trailing-whitespace
exclude: ^flowyml/ui/frontend/dist/
- id: check-docstring-first
- id: check-toml
- id: check-added-large-files
Expand All @@ -31,10 +37,13 @@ repos:
- id: check-merge-conflict
- id: debug-statements
exclude: ^flowyml/utils/debug\.py$
# package-lock.json is written by npm in its own format; reformatting it
# here just guarantees npm rewrites it back on the next install.
- id: pretty-format-json
args:
- "--autofix"
- "--indent=2"
exclude: ^(flowyml/ui/frontend/(dist/|package-lock\.json)|.*\.ipynb)$
- repo: local
hooks:
- id: validate-commit-msg
Expand Down
13 changes: 13 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,21 @@ services:
FLOWYML_REMOTE_UI_URL: ${FLOWYML_REMOTE_UI_URL:-http://localhost:8080}

# Authentication
#
# With FLOWYML_ENV=production the server refuses to start unless both
# FLOWYML_API_TOKEN and FLOWYML_ADMIN_PASSWORD are set. Without them
# every endpoint - including POST /api/execution/execute, which imports
# and runs arbitrary Python modules - would be reachable unauthenticated.
# Set FLOWYML_ALLOW_INSECURE=1 only if a proxy in front of FlowyML
# already enforces authentication.
FLOWYML_AUTH_SECRET: ${FLOWYML_AUTH_SECRET:-change-this-secret-in-production}
FLOWYML_API_TOKEN: ${FLOWYML_API_TOKEN:-}
FLOWYML_ADMIN_USER: ${FLOWYML_ADMIN_USER:-admin}
FLOWYML_ADMIN_PASSWORD: ${FLOWYML_ADMIN_PASSWORD:-}
FLOWYML_ALLOW_INSECURE: ${FLOWYML_ALLOW_INSECURE:-}

# Browser origins allowed to call the API (comma-separated).
FLOWYML_CORS_ORIGINS: ${FLOWYML_CORS_ORIGINS:-}

# Environment
FLOWYML_ENV: ${FLOWYML_ENV:-development}
Expand Down
10 changes: 3 additions & 7 deletions flowyml/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import rich_click as click
from flowyml import __version__
from flowyml.cli.rich_utils import recho
from pathlib import Path
from flowyml.utils.config import get_config
Expand Down Expand Up @@ -77,7 +78,7 @@


@click.group()
@click.version_option(version="0.1.0", prog_name="flowyml")
@click.version_option(version=__version__, prog_name="flowyml")
def cli() -> None:
"""[bold cyan]🌊 FlowyML[/] — Next-Generation ML Pipeline Framework

Expand Down Expand Up @@ -1031,12 +1032,7 @@ def system_info() -> None:
print_banner(console=console, subtitle="Next-Generation ML Pipeline Framework")

# System info
try:
import flowyml

version = getattr(flowyml, "__version__", "0.1.0")
except Exception:
version = "0.1.0"
version = __version__

cfg = get_config()
ui_running = is_ui_running("localhost", cfg.ui_port)
Expand Down
6 changes: 5 additions & 1 deletion flowyml/stacks/enterprise/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,11 @@ def get(self, key: str) -> StackDefinition | None:
path.unlink(missing_ok=True)
return None

def set(self, key: str, stack: StackDefinition) -> None:
# A003 flags `set` as shadowing the builtin. It does not: the name is only
# ever reached as `cache.set(...)`, and `get`/`set` is the conventional
# cache interface this class implements. Later ruff releases removed the
# rule for exactly this reason.
def set(self, key: str, stack: StackDefinition) -> None: # noqa: A003
"""Store a stack definition in the cache.

Args:
Expand Down
65 changes: 63 additions & 2 deletions flowyml/stacks/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
from dataclasses import dataclass, field
from typing import Any, Protocol, runtime_checkable

from loguru import logger

from flowyml.stacks.components import (
StackComponent,
Orchestrator,
Expand Down Expand Up @@ -457,9 +459,22 @@ def load_plugins_from_config(self, config_path: str) -> None:
print(f"Failed to load plugin {config.name}: {e}")

def install_plugin(self, package_name: str) -> bool:
"""Install a plugin package via pip."""
"""Install a plugin package via pip.

The name is validated first: pip would read an argument such as
``--index-url=http://...`` as an option rather than a package, letting
a caller redirect the install at an arbitrary index.
"""
from flowyml.utils.packages import InvalidPackageNameError, validate_requirement

try:
subprocess.check_call([sys.executable, "-m", "pip", "install", package_name])
requirement = validate_requirement(package_name)
except InvalidPackageNameError as exc:
logger.warning(f"Refusing to install plugin: {exc}")
return False

try:
subprocess.check_call([sys.executable, "-m", "pip", "install", requirement])
# Refresh discovery
importlib.invalidate_caches()
self._discover_installed_plugins()
Expand Down Expand Up @@ -586,6 +601,30 @@ def _class_to_snake_case(name: str) -> str:
# Global registry instance
_global_component_registry: ComponentRegistry | None = None

# Durable record of every component registered through ``register_component``.
#
# ``@register_component`` fires exactly once per process, when the defining
# module is first imported. Python caches modules in ``sys.modules``, so a
# later ``import`` of that module is a no-op and the decorator never runs
# again. That makes registration by import side effect fragile: if the global
# registry is ever rebuilt (tests reset it, an embedding host re-initializes
# the process, a plugin reload), every component whose module was already
# imported would silently disappear and ``get_component(...)`` would start
# returning ``None`` for the rest of the process.
#
# Recording each ``(class, name)`` pair here and replaying it into every newly
# constructed registry makes registration durable and idempotent, without
# depending on import ordering. Registrations are keyed by the resolved name
# so a later re-registration of the same name wins, matching the behaviour of
# calling ``ComponentRegistry.register`` directly.
_recorded_components: dict[str, type[StackComponent]] = {}


def _record_component(component_class: type[StackComponent], name: str | None) -> None:
"""Remember a decorator-registered component so registry rebuilds keep it."""
resolved = name or ComponentRegistry._class_to_snake_case(component_class.__name__)
_recorded_components[resolved] = component_class


def get_component_registry() -> ComponentRegistry:
"""Get the global component registry."""
Expand All @@ -596,17 +635,39 @@ def get_component_registry() -> ComponentRegistry:
# to this same instance instead of recursively constructing another.
_global_component_registry = ComponentRegistry()
_global_component_registry._register_builtin_components()
# Replay decorator registrations from modules already imported in this
# process; their decorators will not fire again (see ``_recorded_components``).
for recorded_name, recorded_cls in _recorded_components.items():
try:
_global_component_registry.register(recorded_cls, recorded_name)
except Exception: # pragma: no cover - a bad plugin must not break startup
logger.warning(
f"Failed to restore recorded component '{recorded_name}'",
)
return _global_component_registry


def reset_component_registry() -> None:
"""Drop the global registry so the next access rebuilds it.

The rebuilt registry still contains every built-in flavor and every
component previously registered via ``register_component``, because those
are replayed from ``_recorded_components``.
"""
global _global_component_registry
_global_component_registry = None


def register_component(component_class=None, name: str | None = None):
"""Decorator to register a custom component."""

def wrapper(cls):
_record_component(cls, name)
get_component_registry().register(cls, name)
return cls

if component_class is not None:
_record_component(component_class, name)
get_component_registry().register(component_class, name)
return component_class

Expand Down
Loading
Loading