Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 18 additions & 1 deletion python/dify_plugin/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,27 @@
def main():
parser = argparse.ArgumentParser(description="Dify Plugin SDK Documentation Generator")
parser.add_argument("command", choices=["generate-docs"], help="Command to run")
parser.add_argument(
"--format",
choices=["markdown", "json-schema"],
default="markdown",
help="Output format (default: markdown)",
)
parser.add_argument(
"--output",
"-o",
default=None,
help="Output file path (default: docs.md for markdown, schema.json for json-schema)",
)
args = parser.parse_args()

if args.command == "generate-docs":
generate_docs()
if args.output is None:
output_file = "schema.json" if args.format == "json-schema" else "docs.md"
else:
output_file = args.output

generate_docs(output_file=output_file, output_format=args.format)


if __name__ == "__main__":
Expand Down
9 changes: 7 additions & 2 deletions python/dify_plugin/commands/generate_docs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
from dify_plugin.core.documentation.generator import SchemaDocumentationGenerator


def generate_docs():
SchemaDocumentationGenerator().generate_docs("docs.md")
def generate_docs(output_file: str = "docs.md", output_format: str = "markdown"):
generator = SchemaDocumentationGenerator()

if output_format == "json-schema":
generator.generate_json_schema(output_file)
else:
generator.generate_docs(output_file)
213 changes: 213 additions & 0 deletions python/dify_plugin/core/documentation/generator.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import json
from collections import defaultdict
from enum import Enum
from typing import Any, Union
Expand Down Expand Up @@ -414,3 +415,215 @@ def _format_type_name(self, field_type: Any) -> str:
return f"Union[{', '.join(types)}]"

return str(field_type)

def generate_json_schema(self, output_file: str):
"""Generate JSON Schema format documentation."""
schemas = list_schema_docs()

# Build type to schema mapping
for schema in schemas:
self._type_to_schema[schema.cls] = schema
self._types.add(schema.cls)

json_schemas = {}

for schema in schemas:
cls = schema.cls
# Use the resolved name from schema
name = schema.name or cls.__name__

if issubclass(cls, BaseModel):
json_schema = self._convert_basemodel_to_json_schema(cls, schema)
json_schemas[name] = json_schema
elif issubclass(cls, Enum):
json_schema = self._convert_enum_to_json_schema(cls, schema)
json_schemas[name] = json_schema

output = {"$schema": "http://json-schema.org/draft-07/schema#", "definitions": json_schemas}

with open(output_file, "w", encoding="utf-8") as f:
json.dump(output, f, indent=2, ensure_ascii=False)

def _convert_basemodel_to_json_schema(self, cls: type[BaseModel], schema_doc) -> dict:
"""Convert a Pydantic BaseModel to JSON Schema."""
properties = {}
required = []

ignore_fields = set(getattr(schema_doc, "ignore_fields", []) or [])
outside_reference_fields = getattr(schema_doc, "outside_reference_fields", {}) or {}

for field_name, field_info in cls.model_fields.items():
if field_name in ignore_fields:
continue

field_type = field_info.annotation
if field_type is None:
continue

description = field_info.description or ""

# Handle dynamic fields
if (
hasattr(schema_doc, "dynamic_fields")
and schema_doc.dynamic_fields
and field_name in schema_doc.dynamic_fields
):
description = schema_doc.dynamic_fields[field_name]

# Handle outside reference fields
if field_name in outside_reference_fields:
referenced_type = outside_reference_fields[field_name]
referenced_schema = self._type_to_schema.get(referenced_type)
schema_name = referenced_schema.name if referenced_schema else referenced_type.__name__

if self._is_container_type(field_type):
field_schema = {
"type": "array",
"items": {"type": "string"},
"description": f"{description} (Paths to yaml files that will be loaded as {schema_name})",
}
else:
field_schema = {
"type": "string",
"description": f"{description} (Path to yaml file that will be loaded as {schema_name})",
}
else:
field_schema = self._get_json_schema_type(field_type)
if description:
field_schema["description"] = description

# Handle default values
if field_info.default is not None and str(field_info.default) != "PydanticUndefined":
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The check str(field_info.default) != "PydanticUndefined" is brittle as it relies on the string representation of an internal Pydantic object, which could change in future versions. A more robust way to check if a field has a meaningful default value is to use field_info.is_required().

Suggested change
if field_info.default is not None and str(field_info.default) != "PydanticUndefined":
if not field_info.is_required() and field_info.default is not None:

try:
# Try to serialize the default value to ensure it's JSON-serializable
json.dumps(field_info.default)
field_schema["default"] = field_info.default
except (TypeError, ValueError):
# If it's not JSON-serializable, convert to string
if isinstance(field_info.default, Enum):
field_schema["default"] = field_info.default.value
else:
field_schema["default"] = str(field_info.default)

# Handle metadata/constraints
if hasattr(field_info, "metadata"):
for metadata in field_info.metadata:
# Handle common constraints
if hasattr(metadata, "pattern"):
field_schema["pattern"] = metadata.pattern
if hasattr(metadata, "min_length"):
field_schema["minLength"] = metadata.min_length
if hasattr(metadata, "max_length"):
field_schema["maxLength"] = metadata.max_length
if hasattr(metadata, "ge"):
field_schema["minimum"] = metadata.ge
if hasattr(metadata, "le"):
field_schema["maximum"] = metadata.le

properties[field_name] = field_schema

# Check if field is required
if field_info.is_required():
required.append(field_name)

result = {
"type": "object",
"properties": properties,
}

if required:
result["required"] = required

description = self._schema_descriptions.get(cls, schema_doc.description)
if description:
result["description"] = description

return result

def _convert_enum_to_json_schema(self, cls: type[Enum], schema_doc) -> dict:
"""Convert an Enum to JSON Schema."""
enum_values = [member.value for member in cls]

# Determine the type of enum values
if enum_values:
first_value = enum_values[0]
if isinstance(first_value, str):
value_type = "string"
elif isinstance(first_value, int):
value_type = "integer"
elif isinstance(first_value, float):
value_type = "number"
elif isinstance(first_value, bool):
value_type = "boolean"
else:
value_type = "string"
else:
value_type = "string"
Comment on lines +548 to +561
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

The logic to determine the JSON schema type for an Enum only considers the type of the first enum value. This can lead to incorrect schema generation for enums with mixed value types (e.g., strings and integers).

A more robust approach is to inspect all enum values to determine the set of types present.

Suggested change
if enum_values:
first_value = enum_values[0]
if isinstance(first_value, str):
value_type = "string"
elif isinstance(first_value, int):
value_type = "integer"
elif isinstance(first_value, float):
value_type = "number"
elif isinstance(first_value, bool):
value_type = "boolean"
else:
value_type = "string"
else:
value_type = "string"
if enum_values:
json_types = set()
for value in enum_values:
if isinstance(value, str):
json_types.add("string")
elif isinstance(value, bool):
json_types.add("boolean")
elif isinstance(value, int):
json_types.add("integer")
elif isinstance(value, float):
json_types.add("number")
if not json_types:
value_type = "string" # Fallback for unsupported types
elif len(json_types) == 1:
value_type = json_types.pop()
else:
value_type = sorted(list(json_types))
else:
value_type = "string"


result = {"type": value_type, "enum": enum_values}

description = self._schema_descriptions.get(cls, schema_doc.description)
if description:
result["description"] = description

return result

def _get_json_schema_type(self, field_type: Any) -> dict:
"""Convert a Python type to JSON Schema type definition."""
if field_type is None:
return {"type": "null"}

# Handle primitive types
if field_type is str:
return {"type": "string"}
if field_type is int:
return {"type": "integer"}
if field_type is float:
return {"type": "number"}
if field_type is bool:
return {"type": "boolean"}

# Handle type references
if isinstance(field_type, type):
if issubclass(field_type, BaseModel) or issubclass(field_type, Enum):
schema = self._type_to_schema.get(field_type)
# Use the resolved name from schema
name = (schema.name if schema else None) or field_type.__name__
return {"$ref": f"#/definitions/{name}"}
elif field_type is dict:
return {"type": "object"}
elif field_type is list:
return {"type": "array"}

# Handle generic types
if hasattr(field_type, "__origin__") and hasattr(field_type, "__args__"):
origin = field_type.__origin__
if origin in (list, set):
item_schema = self._get_json_schema_type(field_type.__args__[0])
return {"type": "array", "items": item_schema}
elif origin is dict:
key_type = field_type.__args__[0]
value_type = field_type.__args__[1]
key_schema = self._get_json_schema_type(key_type)
value_schema = self._get_json_schema_type(value_type)

return {"type": "object", "propertyNames": key_schema, "additionalProperties": value_schema}
elif origin is tuple:
items = [self._get_json_schema_type(arg) for arg in field_type.__args__]
return {"type": "array", "items": items, "minItems": len(items), "maxItems": len(items)}
elif origin is Union:
args = field_type.__args__
if type(None) in args:
non_none_types = [arg for arg in args if arg is not type(None)]
if len(non_none_types) == 1:
schema = self._get_json_schema_type(non_none_types[0])
if "type" in schema and isinstance(schema["type"], str):
schema["type"] = [schema["type"], "null"]
else:
return {"anyOf": [schema, {"type": "null"}]}
return schema

schemas = [self._get_json_schema_type(arg) for arg in args]
return {"anyOf": schemas}

return {}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The function _get_json_schema_type returns an empty dictionary {} for any type it cannot handle. While this is a valid JSON Schema that allows any value, it can hide issues where a type is not being converted correctly. This might lead to a less strict and less useful schema than intended.

Consider raising an error for unhandled types to make the schema generation process fail-fast and ensure all types are explicitly handled.

Suggested change
return {}
raise NotImplementedError(f"Type {field_type} cannot be converted to JSON Schema.")

3 changes: 3 additions & 0 deletions python/dify_plugin/entities/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ class AgentStrategyIdentity(ToolIdentity):
description="The parameter of the agent strategy",
)
class AgentStrategyParameter(BaseModel):
@docs(
description="Agent strategy parameter type (aliases CommonParameterType values)",
)
class ToolParameterType(str, Enum):
STRING = CommonParameterType.STRING.value
NUMBER = CommonParameterType.NUMBER.value
Expand Down
3 changes: 3 additions & 0 deletions python/dify_plugin/entities/datasource_manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ class DatasourceParameter(BaseModel):
Overrides type
"""

@docs(
description="Datasource parameter type (aliases CommonParameterType values)",
)
class DatasourceParameterType(StrEnum):
STRING = CommonParameterType.STRING.value
NUMBER = CommonParameterType.NUMBER.value
Expand Down
3 changes: 3 additions & 0 deletions python/dify_plugin/entities/provider_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,9 @@ class ConfigOption(BaseModel):
description="A common config schema",
)
class ProviderConfig(BaseModel):
@docs(
description="The type options for provider config entries",
)
class Config(Enum):
SECRET_INPUT = CommonParameterType.SECRET_INPUT.value
TEXT_INPUT = CommonParameterType.TEXT_INPUT.value
Expand Down
9 changes: 9 additions & 0 deletions python/dify_plugin/entities/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ class ToolParameterOption(ParameterOption):
description="The auto generate of the parameter",
)
class ParameterAutoGenerate(BaseModel):
@docs(
description="The type of the auto generate",
)
class Type(StrEnum):
PROMPT_INSTRUCTION = "prompt_instruction"

Expand All @@ -70,6 +73,9 @@ class ParameterTemplate(BaseModel):
description="The type of the parameter",
)
class ToolParameter(BaseModel):
@docs(
description="Tool parameter type (aliases CommonParameterType values)",
)
class ToolParameterType(str, Enum):
STRING = CommonParameterType.STRING.value
NUMBER = CommonParameterType.NUMBER.value
Expand All @@ -88,6 +94,9 @@ class ToolParameterType(str, Enum):
ARRAY = CommonParameterType.ARRAY.value
DYNAMIC_SELECT = CommonParameterType.DYNAMIC_SELECT.value

@docs(
description="The form of the parameter",
)
class ToolParameterForm(Enum):
SCHEMA = "schema" # should be set while adding tool
FORM = "form" # should be set before invoking tool
Expand Down
6 changes: 6 additions & 0 deletions python/dify_plugin/entities/trigger.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,9 @@ class EventParameter(BaseModel):
The parameter of the event
"""

@docs(
description="The type options for event parameter",
)
class EventParameterType(StrEnum):
STRING = CommonParameterType.STRING.value
NUMBER = CommonParameterType.NUMBER.value
Expand Down Expand Up @@ -124,6 +127,9 @@ class EventParameterType(StrEnum):
description: I18nObject | None = None


@docs(
description="The labels for event",
)
class EventLabelEnum(Enum):
WEBHOOKS = "webhooks"

Expand Down