diff --git a/nodes.py b/nodes.py index a4002d290..7f04ef434 100644 --- a/nodes.py +++ b/nodes.py @@ -2172,14 +2172,45 @@ LOADED_MODULE_DIRS = {} NODE_STARTUP_ERRORS: dict[str, dict] = {} -def _read_pyproject_metadata(module_path: str) -> dict | None: - """Best-effort extraction of node-pack identity from pyproject.toml. +_EMPTY_LEAF_VALUES = (None, "", [], {}) - Returns a dict with the Comfy Registry-style identity (pack_id, - display_name, publisher_id, version, repository) when the module - directory contains a pyproject.toml. Returns None when no toml is - present or parsing fails for any reason — startup-error tracking - must never itself raise. + +def _prune_empty(value): + """Recursively drop empty strings / lists / dicts / None from a nested structure. + + 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): return None @@ -2192,15 +2223,8 @@ def _read_pyproject_metadata(module_path: str) -> dict | None: cfg = config_parser.extract_node_configuration(module_path) if cfg is None: return None - meta = { - "pack_id": cfg.project.name 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} + pruned = _prune_empty(cfg.model_dump()) + return pruned or None except Exception: return None @@ -2224,6 +2248,48 @@ def record_node_startup_error( 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: """ Returns the module name based on the given module path. diff --git a/server.py b/server.py index fc3b503fc..4f22efb68 100644 --- a/server.py +++ b/server.py @@ -780,12 +780,26 @@ class PromptServer(): ``module_path`` is stripped because the absolute on-disk path is 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]] = {} - for entry in nodes.NODE_STARTUP_ERRORS.values(): - source = entry.get("source", "custom_nodes") - public_entry = {k: v for k, v in entry.items() if k != "module_path"} - grouped.setdefault(source, {})[entry["module_name"]] = public_entry + grouped = nodes.filter_node_startup_errors( + source=request.query.get("source"), + module_name=request.query.get("module_name"), + pack_id=request.query.get("pack_id"), + ) return web.json_response(grouped) @routes.get("/api/jobs") diff --git a/tests-unit/node_startup_errors_test.py b/tests-unit/node_startup_errors_test.py index 5395550c3..cf66f8e3b 100644 --- a/tests-unit/node_startup_errors_test.py +++ b/tests-unit/node_startup_errors_test.py @@ -119,11 +119,43 @@ async def test_load_custom_node_attaches_pyproject_metadata(tmp_path): entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:MyCoolPack"] assert "pyproject" in entry, entry py = entry["pyproject"] - assert py["pack_id"] == "comfyui-mycoolpack" - assert py["display_name"] == "My Cool Pack" - assert py["publisher_id"] == "example" - assert py["version"] == "1.2.3" - assert py["repository"] == "https://github.com/example/comfyui-mycoolpack" + + # Shape must mirror PyProjectConfig 1:1 so consumers can parse it back + # through the same pydantic model used by comfy_config.config_parser. + project = py["project"] + 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 @@ -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 entry = nodes.NODE_STARTUP_ERRORS["future_source:future_pack"] 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"}