Skip to content
Merged
Show file tree
Hide file tree
Changes from 13 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
251e265
feat: Introduce new input field types to HumanInput node
QuantumGhost Apr 19, 2026
8246f2a
test: add serialization tests for FormInput Type
QuantumGhost Apr 19, 2026
4b5e7f8
refactor!: Rename `FormInput` and `FormInputDefault`
QuantumGhost Apr 19, 2026
b6897a8
refactor!: Rename `PlaceholderType` to `ValueSourceType`
QuantumGhost Apr 19, 2026
54a5c45
chore!: Drop `TEXT_INPUT` form input type
QuantumGhost Apr 19, 2026
95fd712
refactor: remove unused get_all_by_node method
QuantumGhost Apr 20, 2026
03ed965
chore: check protocol compatibility statically for ReadOnlyVariablePool
QuantumGhost Apr 20, 2026
d000e45
test: add tests for HumanInputNode.resolve_default_values
QuantumGhost Apr 20, 2026
cfb255a
test: add tests for HumanInputNodeData variable mapping extraction
QuantumGhost Apr 20, 2026
7372a71
feat: introduce new input field types for HumanInputNode
QuantumGhost Apr 20, 2026
54cbe8b
chore: ignore PLC1901 in tests
QuantumGhost Apr 20, 2026
ba9186b
chore: ignore coverage related files
QuantumGhost Apr 20, 2026
f445e74
chore: add editorconfig configuration
QuantumGhost Apr 20, 2026
6dd2793
docs: clarify fields in `VariablePool`
QuantumGhost Apr 21, 2026
f88e7f6
refactor: promote `output_variable_name` to base class
QuantumGhost Apr 21, 2026
547db9d
feat: introduce `submitted_data` to HumanInputFormFilledEvent
QuantumGhost Apr 21, 2026
dd75bf5
docs: adjust documentation for VariablePool
QuantumGhost Apr 21, 2026
9967c0f
refactor: rename FormInput to FormInputConfig
QuantumGhost Apr 21, 2026
548b6fa
WIP
QuantumGhost Apr 23, 2026
f70c911
chore(human-input): use string constant for FormInputType enum values
QuantumGhost Apr 27, 2026
027abb8
chore(human-input): rename form_data to submitted_data in methods and…
QuantumGhost Apr 27, 2026
d2f7cf5
chore(human-input): mark methods in protocol as abstract
QuantumGhost Apr 27, 2026
d1da838
fixup! chore(human-input): rename form_data to submitted_data in meth…
QuantumGhost Apr 27, 2026
80c820d
fixup! chore(human-input): rename form_data to submitted_data in meth…
QuantumGhost Apr 27, 2026
e122712
fixup! chore(human-input): rename form_data to submitted_data in meth…
QuantumGhost Apr 27, 2026
91941bf
refactor(human-input): unify special output creation
QuantumGhost Apr 27, 2026
b99ebd0
Merge remote-tracking branch 'upstream' into QuantumGhost/hitl-form-dsl
QuantumGhost May 12, 2026
70b417c
docs: add a short docstring to FileReferenceFactoryProtocol.
QuantumGhost May 12, 2026
e10abc1
test(human-input): move tests for human input definition to test_enti…
QuantumGhost May 12, 2026
3d82420
chore: suppress SLF001 for tests
QuantumGhost May 12, 2026
5539d04
feat(human-input): remove restore_submitted_data from HumanInputNodeR…
QuantumGhost May 12, 2026
b8f72b5
chore(test): remove SLF001 ignore in code
QuantumGhost May 12, 2026
a6d4c1a
refactor(human-input): ensure all form data are converted to runtime …
QuantumGhost May 12, 2026
2287be4
refactor(human-input): move _InvalidSubmittedDataError to _exc
QuantumGhost May 12, 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
18 changes: 18 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
root = true

[*]
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
charset = utf-8

[*.py]
indent_style = space
indent_size = 4

[*.toml]
indent_style = space
indent_size = 4

[*.md]
trim_trailing_whitespace = false
15 changes: 15 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,18 @@ examples/*/.env

# Ruff stuff:
.ruff_cache/

# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ ignore-one-line-docstrings = true
'tests/**/*.py' = [
'S101', # Assert statements used for pytest.
'PLR2004', # Magic value used in test cases.
'PLC1901', # be more precise about assertion in tests.
]

[tool.ty.environment]
Expand Down
4 changes: 2 additions & 2 deletions src/graphon/entities/pause_reason.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ class HumanInputRequired(BaseModel):
)
form_id: str
form_content: str
inputs: list[FormInput] = Field(default_factory=list)
actions: list[UserAction] = Field(default_factory=list)
inputs: list[FormInput] = Field(default_factory=list[FormInput])
actions: list[UserAction] = Field(default_factory=list[UserAction])
node_id: str
node_title: str

Expand Down
18 changes: 18 additions & 0 deletions src/graphon/nodes/human_input/_exc.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from graphon.file.enums import FileTransferMethod


class InvalidConfigError(Exception):
pass


class InvalidTransferMethodError(InvalidConfigError):
transfer_method: FileTransferMethod

def __init__(self, transfer_method: FileTransferMethod) -> None:
self.transfer_method = transfer_method
super().__init__(f"invalid file transfer method: {transfer_method}")


class ExtensionsNotSetErrorValueError(InvalidConfigError):
def __init__(self) -> None:
super().__init__("allowed_file_extensions not set")
176 changes: 155 additions & 21 deletions src/graphon/nodes/human_input/entities.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,41 @@
outside `graphon`.
"""

import abc
import re
from collections.abc import Mapping, Sequence
from datetime import datetime, timedelta
from typing import Any, Self
from typing import Annotated, Any, Literal, Self

from pydantic import BaseModel, Field, field_validator, model_validator
from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator

from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.file.enums import FileTransferMethod, FileType
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
from graphon.runtime.graph_runtime_state_protocol import ReadOnlyVariablePool
from graphon.variables.consts import SELECTORS_LENGTH
from graphon.variables.segments import Segment

from .enums import ButtonStyle, FormInputType, PlaceholderType, TimeoutUnit
from . import _exc as exc
from .enums import ButtonStyle, FormInputType, TimeoutUnit, ValueSourceType

_OUTPUT_VARIABLE_PATTERN = re.compile(
r"\{\{#\$output\.(?P<field_name>[a-zA-Z_][a-zA-Z0-9_]{0,29})#\}\}",
)


class FormInputDefault(BaseModel):
class StringSource(BaseModel):
"""Default configuration for form inputs."""

# NOTE: Ideally, a discriminated union would be used to model
# FormInputDefault. However, the UI requires preserving the previous
# value when switching between `VARIABLE` and `CONSTANT` types. This
# necessitates retaining all fields, making a discriminated union unsuitable.

type: PlaceholderType
# NOTE: This class is renamed from FormInputDefault.

type: ValueSourceType

# The selector of default variable, used when `type` is `VARIABLE`.
selector: Sequence[str] = Field(default_factory=tuple)
Expand All @@ -43,7 +50,7 @@ class FormInputDefault(BaseModel):

@model_validator(mode="after")
def _validate_selector(self) -> Self:
if self.type == PlaceholderType.CONSTANT:
if self.type == ValueSourceType.CONSTANT:
return self
if len(self.selector) < SELECTORS_LENGTH:
msg = (
Expand All @@ -54,12 +61,142 @@ def _validate_selector(self) -> Self:
return self


class FormInput(BaseModel):
class StringListSource(BaseModel):
type: ValueSourceType

# The selector of default variable, used when `type` is `VARIABLE`.
selector: Sequence[str] = Field(default_factory=tuple)

# The value of the default, used when `type` is `CONSTANT`.
value: list[str] = Field(default_factory=list)


class BaseInput(abc.ABC):
"""BaseInput is the base class for all input field definitions.
One input corresponds to one output variable during form submission.
"""

@abc.abstractmethod
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
"""`extract_variable_selectors` extracts variable selectors
used by this input field.
"""

@abc.abstractmethod
def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None:
"""`resolve_default_value` resolves the default value for form submission.

If the form input does not specify a default value, or the default value does
not depend on the runtime variable, this method should return `None`.
"""


class ParagraphInput(BaseModel, BaseInput):
"""Form input definition."""

type: FormInputType
# NOTE: This class is renamed from FormInput.
type: Literal[FormInputType.PARAGRAPH] = FormInputType.PARAGRAPH
output_variable_name: str
default: FormInputDefault | None = None
default: StringSource | None = None

def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
default = self.default
if default is None:
return []
if default.type == ValueSourceType.CONSTANT:
return []
return [default.selector]

def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None:
default = self.default
if default is None:
return None

if default.type == ValueSourceType.CONSTANT:
return None

return pool.get(default.selector)


class SelectInput(BaseModel, BaseInput):
type: Literal[FormInputType.SELECT] = FormInputType.SELECT
output_variable_name: str
option_source: StringListSource

def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]:
if self.option_source.type == ValueSourceType.CONSTANT:
return []
return [self.option_source.selector]

def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None:
_ = pool
return None


_ALLOWED_TRANSFER_METHOD = frozenset([
FileTransferMethod.LOCAL_FILE,
FileTransferMethod.REMOTE_URL,
])


class _FileInputCommon(BaseModel):
allowed_file_types: Sequence[FileType] = Field(default_factory=list[FileType])
allowed_file_extensions: Sequence[str] = Field(default_factory=list)
allowed_file_upload_methods: Sequence[FileTransferMethod] = Field(
default_factory=list[FileTransferMethod]
)

@field_validator("allowed_file_upload_methods", mode="after")
@classmethod
def _validate_upload_methods(
cls, transfer_methods: Sequence[FileTransferMethod]
) -> Sequence[FileTransferMethod]:
validated_values: list[FileTransferMethod] = []
for value in transfer_methods:
if value not in _ALLOWED_TRANSFER_METHOD:
raise exc.InvalidTransferMethodError(value)
validated_values.append(value)

return validated_values

@model_validator(mode="after")
def _validate_extensions(self) -> Self:
if self.allowed_file_types != FileType.CUSTOM:
return self
if not self.allowed_file_extensions:
raise exc.ExtensionsNotSetErrorValueError
return self


class FileInput(_FileInputCommon, BaseInput):
type: Literal[FormInputType.FILE] = FormInputType.FILE
output_variable_name: str

def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]:
return []

def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None:
_ = pool
return None


class FileListInput(_FileInputCommon, BaseInput):
type: Literal[FormInputType.FILE_LIST] = FormInputType.FILE_LIST
output_variable_name: str
number_limits: NonNegativeInt = 0

def extract_variable_selectors(self) -> Sequence[Sequence[NodeType]]:
return []

def resolve_default_value(self, pool: ReadOnlyVariablePool) -> Segment | None:
_ = pool
return None


type FormInput = Annotated[
ParagraphInput | SelectInput | FileInput | FileListInput,
Field(discriminator="type"),
]


_IDENTIFIER_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
Expand Down Expand Up @@ -94,8 +231,8 @@ class HumanInputNodeData(BaseNodeData):

type: NodeType = BuiltinNodeTypes.HUMAN_INPUT
form_content: str = ""
inputs: list[FormInput] = Field(default_factory=list)
user_actions: list[UserAction] = Field(default_factory=list)
inputs: list[FormInput] = Field(default_factory=list[FormInput])
user_actions: list[UserAction] = Field(default_factory=list[UserAction])
timeout: int = 36
timeout_unit: TimeoutUnit = TimeoutUnit.HOUR

Expand Down Expand Up @@ -161,14 +298,11 @@ def _add_variable_selectors(selectors: Sequence[Sequence[str]]) -> None:
])

for form_input in self.inputs:
default_value = form_input.default
if default_value is None:
continue
if default_value.type == PlaceholderType.CONSTANT:
continue
default_value_key = ".".join(default_value.selector)
qualified_variable_mapping_key = f"{node_id}.#{default_value_key}#"
variable_mappings[qualified_variable_mapping_key] = default_value.selector
selectors = form_input.extract_variable_selectors()
for selector in selectors:
value_key = ".".join(selector)
qualified_variable_mapping_key = f"{node_id}.#{value_key}#"
variable_mappings[qualified_variable_mapping_key] = selector

return variable_mappings

Expand All @@ -182,8 +316,8 @@ def find_action_text(self, action_id: str) -> str:

class FormDefinition(BaseModel):
form_content: str
inputs: list[FormInput] = Field(default_factory=list)
user_actions: list[UserAction] = Field(default_factory=list)
inputs: list[FormInput] = Field(default_factory=list[FormInput])
user_actions: list[UserAction] = Field(default_factory=list[UserAction])
rendered_content: str
expiration_time: datetime

Expand Down
29 changes: 25 additions & 4 deletions src/graphon/nodes/human_input/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,14 +42,35 @@ class TimeoutUnit(enum.StrEnum):


class FormInputType(enum.StrEnum):
"""Form input types."""
"""Form input types.

TEXT_INPUT = enum.auto()
Name for this enumeration are intentionally keep the same as those for
`VariableEntityType`.
"""

# Both `TEXT_INPUT` and `PARAGRAPH` represent string input fields.
# The corresponding generated variable type is `SegmentType.STRING`.
PARAGRAPH = enum.auto()

# A single-select input field (e.g., a dropdown or radio buttons).
# The corresponding generated variable type is `SegmentType.STRING`.
SELECT = enum.auto()

# A file input field that accepts a single file.
# The corresponding generated variable type is `SegmentType.FILE`.
FILE = enum.auto()

# A file input field that accepts zero or more files.
# The corresponding generated variable type is `SegmentType.ARRAY_FILE`.
FILE_LIST = enum.auto()
Comment thread
QuantumGhost marked this conversation as resolved.
Outdated


class PlaceholderType(enum.StrEnum):
"""Default value types for form inputs."""
class ValueSourceType(enum.StrEnum):
"""ValueSourceType records whether the value comes from a static setting
in form definiton, or a variable while the workflow is running.
"""

# `VARIABLE` means that the value comes from a variable in workflow execution
VARIABLE = enum.auto()
# `CONSTANT` measn that the value comes from a static setting in form definition.
CONSTANT = enum.auto()
12 changes: 4 additions & 8 deletions src/graphon/nodes/human_input/human_input_node.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
from graphon.workflow_type_encoder import WorkflowRuntimeTypeConverter

from .entities import HumanInputNodeData
from .enums import HumanInputFormStatus, PlaceholderType
from .enums import HumanInputFormStatus

_SELECTED_BRANCH_KEY = "selected_branch"

Expand Down Expand Up @@ -163,17 +163,13 @@ def resolve_default_values(self) -> Mapping[str, Any]:
variable_pool = self.graph_runtime_state.variable_pool
resolved_defaults: dict[str, Any] = {}
for form_input in self._node_data.inputs:
if (default_value := form_input.default) is None:
continue
if default_value.type == PlaceholderType.CONSTANT:
continue
resolved_value = variable_pool.get(default_value.selector)
if resolved_value is None:
resolved_default = form_input.resolve_default_value(variable_pool)
if resolved_default is None:
# Treat missing variable-backed defaults as absent defaults.
continue
resolved_defaults[form_input.output_variable_name] = (
WorkflowRuntimeTypeConverter().value_to_json_encodable_recursive(
resolved_value.value,
resolved_default.value,
)
)

Expand Down
Loading
Loading