mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-06-16 04:49:35 +08:00
Match PyProjectConfig shape for pyproject; add pack_id/module_name/source query filters
Two reviewer-requested improvements to GET /node_startup_errors:
1. Emit the pyproject metadata in the same {project: {...}, tool_comfy: {...}}
shape that comfy_config.config_parser.extract_node_configuration already
returns, instead of inventing a flat {pack_id, display_name, ...} bag.
API consumers can now parse the pyproject block straight through the
shared PyProjectConfig pydantic model. Empty / default-valued leaves
are pruned by a small recursive _prune_empty helper so the payload
stays compact, but nesting and field names match the source-of-truth.
2. Add optional source, module_name, and pack_id query parameters
(combined with AND) so a frontend / Manager can ask ?pack_id=foo
instead of grep'ing through the whole grouped response. pack_id
resolves against pyproject.project.name; entries without a parsed
pyproject are naturally excluded from a pack_id query.
The grouping + filtering + module_path stripping moves into
odes.filter_node_startup_errors so the route handler is a one-liner and
the helper is unit-testable without spinning up an aiohttp app.
Tests: 5 new unit tests covering each filter branch, AND-combination, and
empty-result behaviour, plus an updated pyproject-metadata assertion that
checks the nested PyProjectConfig shape, plus a focused test for the
_prune_empty helper.
This commit is contained in:
parent
7259e664ef
commit
4eef53041e
98
nodes.py
98
nodes.py
@ -2172,14 +2172,45 @@ LOADED_MODULE_DIRS = {}
|
|||||||
NODE_STARTUP_ERRORS: dict[str, dict] = {}
|
NODE_STARTUP_ERRORS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
def _read_pyproject_metadata(module_path: str) -> dict | None:
|
_EMPTY_LEAF_VALUES = (None, "", [], {})
|
||||||
"""Best-effort extraction of node-pack identity from pyproject.toml.
|
|
||||||
|
|
||||||
Returns a dict with the Comfy Registry-style identity (pack_id,
|
|
||||||
display_name, publisher_id, version, repository) when the module
|
def _prune_empty(value):
|
||||||
directory contains a pyproject.toml. Returns None when no toml is
|
"""Recursively drop empty strings / lists / dicts / None from a nested structure.
|
||||||
present or parsing fails for any reason — startup-error tracking
|
|
||||||
must never itself raise.
|
Used to keep the on-wire pyproject payload tight without altering the
|
||||||
|
nesting that callers see (so consumers can still parse it back through
|
||||||
|
``PyProjectConfig`` if they want a typed object).
|
||||||
|
"""
|
||||||
|
if isinstance(value, dict):
|
||||||
|
out = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
cleaned = _prune_empty(v)
|
||||||
|
if cleaned not in _EMPTY_LEAF_VALUES:
|
||||||
|
out[k] = cleaned
|
||||||
|
return out
|
||||||
|
if isinstance(value, list):
|
||||||
|
return [
|
||||||
|
cleaned
|
||||||
|
for cleaned in (_prune_empty(v) for v in value)
|
||||||
|
if cleaned not in _EMPTY_LEAF_VALUES
|
||||||
|
]
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pyproject_metadata(module_path: str) -> dict | None:
|
||||||
|
"""Best-effort extraction of pyproject.toml for a node module.
|
||||||
|
|
||||||
|
Returns a dict mirroring the ``PyProjectConfig`` shape produced by
|
||||||
|
``comfy_config.config_parser.extract_node_configuration`` (i.e. with
|
||||||
|
``project`` and ``tool_comfy`` nesting and the same field names) when the
|
||||||
|
module directory contains a pyproject.toml. Empty / default-valued leaves
|
||||||
|
are pruned so the API payload stays compact, but the nesting is kept
|
||||||
|
intact so API consumers can parse the result back through
|
||||||
|
``PyProjectConfig`` directly.
|
||||||
|
|
||||||
|
Returns None when no toml is present or parsing fails for any reason —
|
||||||
|
startup-error tracking must never itself raise.
|
||||||
"""
|
"""
|
||||||
if not module_path or not os.path.isdir(module_path):
|
if not module_path or not os.path.isdir(module_path):
|
||||||
return None
|
return None
|
||||||
@ -2192,15 +2223,8 @@ def _read_pyproject_metadata(module_path: str) -> dict | None:
|
|||||||
cfg = config_parser.extract_node_configuration(module_path)
|
cfg = config_parser.extract_node_configuration(module_path)
|
||||||
if cfg is None:
|
if cfg is None:
|
||||||
return None
|
return None
|
||||||
meta = {
|
pruned = _prune_empty(cfg.model_dump())
|
||||||
"pack_id": cfg.project.name or None,
|
return pruned or None
|
||||||
"display_name": cfg.tool_comfy.display_name or None,
|
|
||||||
"publisher_id": cfg.tool_comfy.publisher_id or None,
|
|
||||||
"version": cfg.project.version or None,
|
|
||||||
"repository": cfg.project.urls.repository or None,
|
|
||||||
}
|
|
||||||
# Drop empty fields so the API payload stays compact.
|
|
||||||
return {k: v for k, v in meta.items() if v}
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
@ -2224,6 +2248,48 @@ def record_node_startup_error(
|
|||||||
NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = entry
|
NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = entry
|
||||||
|
|
||||||
|
|
||||||
|
def filter_node_startup_errors(
|
||||||
|
*,
|
||||||
|
source: str | None = None,
|
||||||
|
module_name: str | None = None,
|
||||||
|
pack_id: str | None = None,
|
||||||
|
) -> dict[str, dict[str, dict]]:
|
||||||
|
"""Return `NODE_STARTUP_ERRORS` reshaped for the public HTTP endpoint.
|
||||||
|
|
||||||
|
Entries are grouped by their ``source`` bucket (the same string as the
|
||||||
|
internal ``module_parent`` used at load time). The on-disk
|
||||||
|
``module_path`` is stripped from each entry — it's an internal detail
|
||||||
|
useful only for server-side logging and would leak absolute filesystem
|
||||||
|
layout otherwise.
|
||||||
|
|
||||||
|
Optional filters narrow the response and combine with AND:
|
||||||
|
|
||||||
|
* ``source`` — only entries from this source bucket.
|
||||||
|
* ``module_name`` — only entries whose module name matches exactly.
|
||||||
|
* ``pack_id`` — only entries whose ``pyproject.project.name``
|
||||||
|
matches exactly. Entries without a parsed
|
||||||
|
pyproject.toml can never match this filter.
|
||||||
|
|
||||||
|
A non-matching filter returns an empty dict, not an error — absence of
|
||||||
|
a failure is a valid answer for this query.
|
||||||
|
"""
|
||||||
|
grouped: dict[str, dict[str, dict]] = {}
|
||||||
|
for entry in NODE_STARTUP_ERRORS.values():
|
||||||
|
entry_source = entry.get("source", "custom_nodes")
|
||||||
|
if source is not None and entry_source != source:
|
||||||
|
continue
|
||||||
|
if module_name is not None and entry.get("module_name") != module_name:
|
||||||
|
continue
|
||||||
|
if pack_id is not None:
|
||||||
|
pyproject = entry.get("pyproject") or {}
|
||||||
|
project = pyproject.get("project") or {}
|
||||||
|
if project.get("name") != pack_id:
|
||||||
|
continue
|
||||||
|
public_entry = {k: v for k, v in entry.items() if k != "module_path"}
|
||||||
|
grouped.setdefault(entry_source, {})[entry["module_name"]] = public_entry
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
|
||||||
def get_module_name(module_path: str) -> str:
|
def get_module_name(module_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
Returns the module name based on the given module path.
|
Returns the module name based on the given module path.
|
||||||
|
|||||||
24
server.py
24
server.py
@ -780,12 +780,26 @@ class PromptServer():
|
|||||||
|
|
||||||
``module_path`` is stripped because the absolute on-disk path is
|
``module_path`` is stripped because the absolute on-disk path is
|
||||||
internal detail that the frontend has no use for.
|
internal detail that the frontend has no use for.
|
||||||
|
|
||||||
|
Optional query parameters narrow the response:
|
||||||
|
|
||||||
|
* ``source`` — only entries from this source bucket.
|
||||||
|
* ``module_name`` — only entries whose module name matches exactly.
|
||||||
|
(Folder name for directory-style packs, file
|
||||||
|
stem for single-file modules.)
|
||||||
|
* ``pack_id`` — only entries whose ``pyproject.project.name``
|
||||||
|
matches exactly. Entries without a parsed
|
||||||
|
pyproject.toml are skipped under this filter.
|
||||||
|
|
||||||
|
Filters are combined with AND. Filtering an empty / non-matching
|
||||||
|
result still returns ``{}`` with HTTP 200 rather than 404 — absence
|
||||||
|
of an error is a valid answer for this endpoint.
|
||||||
"""
|
"""
|
||||||
grouped: dict[str, dict[str, dict]] = {}
|
grouped = nodes.filter_node_startup_errors(
|
||||||
for entry in nodes.NODE_STARTUP_ERRORS.values():
|
source=request.query.get("source"),
|
||||||
source = entry.get("source", "custom_nodes")
|
module_name=request.query.get("module_name"),
|
||||||
public_entry = {k: v for k, v in entry.items() if k != "module_path"}
|
pack_id=request.query.get("pack_id"),
|
||||||
grouped.setdefault(source, {})[entry["module_name"]] = public_entry
|
)
|
||||||
return web.json_response(grouped)
|
return web.json_response(grouped)
|
||||||
|
|
||||||
@routes.get("/api/jobs")
|
@routes.get("/api/jobs")
|
||||||
|
|||||||
@ -119,11 +119,43 @@ async def test_load_custom_node_attaches_pyproject_metadata(tmp_path):
|
|||||||
entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:MyCoolPack"]
|
entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:MyCoolPack"]
|
||||||
assert "pyproject" in entry, entry
|
assert "pyproject" in entry, entry
|
||||||
py = entry["pyproject"]
|
py = entry["pyproject"]
|
||||||
assert py["pack_id"] == "comfyui-mycoolpack"
|
|
||||||
assert py["display_name"] == "My Cool Pack"
|
# Shape must mirror PyProjectConfig 1:1 so consumers can parse it back
|
||||||
assert py["publisher_id"] == "example"
|
# through the same pydantic model used by comfy_config.config_parser.
|
||||||
assert py["version"] == "1.2.3"
|
project = py["project"]
|
||||||
assert py["repository"] == "https://github.com/example/comfyui-mycoolpack"
|
assert project["name"] == "comfyui-mycoolpack"
|
||||||
|
assert project["version"] == "1.2.3"
|
||||||
|
assert project["urls"]["repository"] == "https://github.com/example/comfyui-mycoolpack"
|
||||||
|
|
||||||
|
tool_comfy = py["tool_comfy"]
|
||||||
|
assert tool_comfy["publisher_id"] == "example"
|
||||||
|
assert tool_comfy["display_name"] == "My Cool Pack"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prune_empty_drops_empty_leaves_only():
|
||||||
|
src = {
|
||||||
|
"keep_str": "x",
|
||||||
|
"drop_empty_str": "",
|
||||||
|
"drop_none": None,
|
||||||
|
"drop_empty_list": [],
|
||||||
|
"drop_empty_dict": {},
|
||||||
|
"keep_zero": 0,
|
||||||
|
"keep_false": False,
|
||||||
|
"nested": {
|
||||||
|
"drop_me": "",
|
||||||
|
"keep_me": "y",
|
||||||
|
"deeper": {"only_empties": ""},
|
||||||
|
},
|
||||||
|
"list_of_dicts": [{"a": ""}, {"a": "z"}],
|
||||||
|
}
|
||||||
|
result = nodes._prune_empty(src)
|
||||||
|
assert result == {
|
||||||
|
"keep_str": "x",
|
||||||
|
"keep_zero": 0,
|
||||||
|
"keep_false": False,
|
||||||
|
"nested": {"keep_me": "y"},
|
||||||
|
"list_of_dicts": [{"a": "z"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@ -144,3 +176,78 @@ async def test_load_custom_node_arbitrary_module_parent_passes_through(tmp_path)
|
|||||||
assert await nodes.load_custom_node(module_path, module_parent="future_source") is False
|
assert await nodes.load_custom_node(module_path, module_parent="future_source") is False
|
||||||
entry = nodes.NODE_STARTUP_ERRORS["future_source:future_pack"]
|
entry = nodes.NODE_STARTUP_ERRORS["future_source:future_pack"]
|
||||||
assert entry["source"] == "future_source"
|
assert entry["source"] == "future_source"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tests for the public reshape/filter helper (nodes.filter_node_startup_errors).
|
||||||
|
# The HTTP route is a thin wrapper around this helper, so unit-testing it
|
||||||
|
# directly avoids spinning up an aiohttp app while still covering every
|
||||||
|
# query-param branch.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _seed(*, source, module_name, pack_id=None, module_path="/abs/path"):
|
||||||
|
"""Insert a synthetic entry directly into NODE_STARTUP_ERRORS."""
|
||||||
|
entry = {
|
||||||
|
"source": source,
|
||||||
|
"module_name": module_name,
|
||||||
|
"module_path": module_path,
|
||||||
|
"error": "boom",
|
||||||
|
"traceback": "tb",
|
||||||
|
"phase": "import",
|
||||||
|
}
|
||||||
|
if pack_id is not None:
|
||||||
|
entry["pyproject"] = {"project": {"name": pack_id}}
|
||||||
|
nodes.NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_node_startup_errors_strips_module_path_and_groups_by_source():
|
||||||
|
_seed(source="custom_nodes", module_name="A", module_path="/x/A")
|
||||||
|
_seed(source="comfy_extras", module_name="B", module_path="/x/B")
|
||||||
|
grouped = nodes.filter_node_startup_errors()
|
||||||
|
assert set(grouped) == {"custom_nodes", "comfy_extras"}
|
||||||
|
assert "module_path" not in grouped["custom_nodes"]["A"]
|
||||||
|
assert "module_path" not in grouped["comfy_extras"]["B"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_node_startup_errors_source_filter():
|
||||||
|
_seed(source="custom_nodes", module_name="A")
|
||||||
|
_seed(source="comfy_extras", module_name="B")
|
||||||
|
grouped = nodes.filter_node_startup_errors(source="comfy_extras")
|
||||||
|
assert set(grouped) == {"comfy_extras"}
|
||||||
|
assert set(grouped["comfy_extras"]) == {"B"}
|
||||||
|
# Non-matching source filter returns an empty dict, not an error.
|
||||||
|
assert nodes.filter_node_startup_errors(source="nope") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_node_startup_errors_module_name_filter():
|
||||||
|
_seed(source="custom_nodes", module_name="A")
|
||||||
|
_seed(source="comfy_extras", module_name="A") # same name, different source
|
||||||
|
_seed(source="custom_nodes", module_name="C")
|
||||||
|
grouped = nodes.filter_node_startup_errors(module_name="A")
|
||||||
|
# Both A entries (from different sources) survive the filter and stay in
|
||||||
|
# their respective source buckets.
|
||||||
|
assert set(grouped) == {"custom_nodes", "comfy_extras"}
|
||||||
|
assert set(grouped["custom_nodes"]) == {"A"}
|
||||||
|
assert set(grouped["comfy_extras"]) == {"A"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_node_startup_errors_pack_id_filter_matches_only_pyproject_entries():
|
||||||
|
_seed(source="custom_nodes", module_name="A", pack_id="comfyui-foo")
|
||||||
|
_seed(source="custom_nodes", module_name="B", pack_id="comfyui-bar")
|
||||||
|
_seed(source="comfy_extras", module_name="C") # no pyproject at all
|
||||||
|
grouped = nodes.filter_node_startup_errors(pack_id="comfyui-foo")
|
||||||
|
assert set(grouped) == {"custom_nodes"}
|
||||||
|
assert set(grouped["custom_nodes"]) == {"A"}
|
||||||
|
# An entry without a parsed pyproject can never match a pack_id filter.
|
||||||
|
assert nodes.filter_node_startup_errors(pack_id="anything-else") == {}
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_node_startup_errors_filters_combine_with_and():
|
||||||
|
_seed(source="custom_nodes", module_name="A", pack_id="comfyui-foo")
|
||||||
|
_seed(source="comfy_extras", module_name="A", pack_id="comfyui-foo")
|
||||||
|
grouped = nodes.filter_node_startup_errors(
|
||||||
|
source="comfy_extras", pack_id="comfyui-foo"
|
||||||
|
)
|
||||||
|
assert set(grouped) == {"comfy_extras"}
|
||||||
|
assert set(grouped["comfy_extras"]) == {"A"}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user