Skip to content

[FIX] Propagate all *_cls/*_class plugin metadata fields in PluginManager#2201

Open
jaseemjaskp wants to merge 1 commit into
mainfrom
fix/plugin-manager-headers-cache-class
Open

[FIX] Propagate all *_cls/*_class plugin metadata fields in PluginManager#2201
jaseemjaskp wants to merge 1 commit into
mainfrom
fix/plugin-manager-headers-cache-class

Conversation

@jaseemjaskp

Copy link
Copy Markdown
Contributor

What

  • PluginManager.load_plugins() (in unstract/core) only copied a hardcoded 3-field allowlist (entrypoint_cls, exception_cls, service_class) from a plugin's metadata dict into the registered plugin dict.
  • Generalized this to copy any metadata key ending in _cls or _class, so new plugin fields don't require this shared loader to be updated every time.

Why

  • The verticals_usage plugin (enterprise/cloud-only, feeds API deployment usage into the API Hub subscription dashboard) defines a headers_cache_class metadata field that the loader's hardcoded allowlist didn't know about. It was silently dropped, so every plugin["headers_cache_class"] lookup in backend/plugins/workflow_manager/workflow_v2/api_hub_usage_utils.py raised KeyError — swallowed by broad except Exception blocks.
  • Net effect: API Hub request headers were never cached in Redis, store_usage() (the only path that writes to the verticals.subscription_usage table) was never reached, and the API Hub portal's subscription dashboard stopped showing usage — with no visible errors anywhere.
  • Traced the regression to PR refactor: Add dynamic plugin loading for enterprise components #1736 ("refactor: Add dynamic plugin loading for enterprise components"), which switched api_hub_usage_utils.py from a build-time file-replacement model to a runtime get_plugin() dict lookup, without the shared PluginManager (added in UN-2930 [MISC] Add plugin infrastructure to unstract-core #1618) being updated to support the headers_cache_class field.

How

  • Replaced the three explicit if "x" in metadata: plugin_data["x"] = metadata["x"] checks in unstract/core/src/unstract/core/plugins/plugin_manager.py with a loop that copies any metadata key matching *_cls/*_class.
  • Added unstract/core/tests/test_plugin_manager.py covering: unknown _class/_cls fields (e.g. headers_cache_class) now propagate, existing entrypoint_cls/exception_cls still work, non-class metadata isn't duplicated at the top level, and disabled plugins are still skipped.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. Verified via grep across backend/ that no other plugin's metadata relies on a field outside the previous three-field allowlist (only service_class is used elsewhere — notifications, hubspot, subscription_usage, file_converter, payload_modifier, etc.), so existing plugin behavior is unchanged; this purely adds support for previously-dropped fields.

Database Migrations

  • None

Env Config

  • None

Relevant Docs

  • None

Related Issues or PRs

Dependencies Versions

  • None

Notes on Testing

  • Added unstract/core/tests/test_plugin_manager.py (4 new tests, all passing).
  • Ran full unstract/core test suite (uv run pytest unstract/core/tests/) — no new regressions; 2 pre-existing unrelated failures were already broken before this change.
  • Ran ruff check / ruff format --check on changed files — clean.

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

…oded 3

PluginManager.load_plugins() only copied entrypoint_cls, exception_cls,
and service_class from a plugin's metadata into the registered plugin
dict. The verticals_usage plugin (which feeds API deployment usage into
the API Hub subscription dashboard) also defines headers_cache_class,
which was silently dropped, causing every plugin["headers_cache_class"]
lookup in api_hub_usage_utils.py to raise KeyError - swallowed by broad
except blocks - so API hub headers were never cached and usage never
reached the verticals.subscription_usage table.
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary by CodeRabbit

  • Improvements

    • Plugin loading now recognizes a broader range of class-related metadata fields automatically.
    • Existing entrypoint and exception class metadata remains supported.
    • Non-class metadata continues to be stored without unnecessary duplication.
    • Disabled plugins are excluded from loading as expected.
  • Tests

    • Added coverage for class metadata handling, legacy fields, non-class metadata, and disabled plugins.

Walkthrough

Changes

Plugin metadata loading

Layer / File(s) Summary
Class-field metadata promotion
unstract/core/src/unstract/core/plugins/plugin_manager.py
PluginManager.load_plugins now promotes metadata keys ending in _cls or _class while retaining existing plugin data.
Plugin loading validation
unstract/core/tests/test_plugin_manager.py
Tests cover new and legacy class fields, nested non-class metadata, temporary plugin loading, and disabled plugins.

Estimated code review effort: 2 (Simple) | ~10 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly matches the main change: generalizing PluginManager metadata propagation for _cls/_class fields.
Description check ✅ Passed The PR description follows the template and fills the required sections with concrete implementation, impact, testing, and related info.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/plugin-manager-headers-cache-class

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

The PR generalizes plugin class metadata propagation and adds focused regression coverage.

  • Replaces the hardcoded class-field allowlist with propagation for metadata keys ending in _cls or _class.
  • Preserves existing plugin registration fields and avoids duplicating unrelated metadata.
  • Adds tests for new and existing class fields, non-class metadata, and disabled plugins.

Confidence Score: 5/5

The PR appears safe to merge with no actionable defects identified.

The generalized suffix-based propagation preserves the previous supported fields, protects existing top-level registry entries, and is covered by focused tests for both positive and negative behavior.

Important Files Changed

Filename Overview
unstract/core/src/unstract/core/plugins/plugin_manager.py Generalizes top-level propagation of class-related plugin metadata while preserving reserved plugin fields.
unstract/core/tests/test_plugin_manager.py Adds isolated regression tests covering class-field propagation and existing plugin-loading behavior.

Reviews (1): Last reviewed commit: "[FIX] Propagate all *_cls/*_class plugin..." | Re-trigger Greptile

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@unstract/core/src/unstract/core/plugins/plugin_manager.py`:
- Around line 218-224: Update the metadata iteration in the plugin loading logic
to validate that each key is a string before applying the "_cls"/"_class" suffix
check. Skip non-string keys while preserving the existing condition that only
adds missing plugin_data fields.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f3a67017-5865-495c-8dc9-587afd840ffe

📥 Commits

Reviewing files that changed from the base of the PR and between e2485a8 and 72862d5.

📒 Files selected for processing (2)
  • unstract/core/src/unstract/core/plugins/plugin_manager.py
  • unstract/core/tests/test_plugin_manager.py

Comment on lines +218 to +224
# Add optional class/entrypoint fields if present (any key ending in
# _cls or _class, e.g. entrypoint_cls, exception_cls, service_class,
# headers_cache_class) so new plugin metadata fields don't need this
# loader updated every time.
for key, value in metadata.items():
if key.endswith(("_cls", "_class")) and key not in plugin_data:
plugin_data[key] = value

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf 'Files matching plugin_manager.py:\n'
fd -a 'plugin_manager.py' . || true

printf '\nRelevant file outline:\n'
ast-grep outline unstract/core/src/unstract/core/plugins/plugin_manager.py --view compact || true

printf '\nRelevant lines 180-250:\n'
sed -n '180,250p' unstract/core/src/unstract/core/plugins/plugin_manager.py | nl -ba -v180

printf '\nSearch plugin_manager load_plugins context and metadata source:\n'
rg -n "def load_plugins|metadata\\(|metadata_items|\\.items\\(\\)|except KeyError|key\\.endswith|_cls|_class" unstract/core/src/unstract/core/plugins/plugin_manager.py

Repository: Zipstack/unstract

Length of output: 622


🏁 Script executed:

#!/bin/bash
set -u

FILE="unstract/core/src/unstract/core/plugins/plugin_manager.py"
TESTFILE="unstract/core/tests/test_plugin_manager.py"

printf 'Line count for plugin_manager.py:\n'
wc -l "$FILE"

printf '\nRelevant lines 180-250 from %s:\n' "$FILE"
sed -n '180,250p' "$FILE" | awk '{printf "%5d  %s\n", NR+179, $0}'

printf '\nSearch loader/relevant symbols:\n'
rg -n "def load_plugins|\\bmetadata\\b|metadata_items|\\.items\\(\\)|except KeyError|key\\.endswith|_cls|_class|isinstance\\(.*key.*str" "$FILE"

printf '\nRelevant plugin_manager tests, if any:\n'
if [ -f "$TESTFILE" ]; then
  rg -n "metadata|endswith|non-string|AttributeError|load_plugins|load_plugin" "$TESTFILE" || true
else
  echo "test file not found"
fi

printf '\nStandalone Python behavior for non-string dict key with endswith in try/except KeyError:\n'
python3 - <<'PY'
def probe(metadata, suffixes):
    plugin_data = {}
    try:
        for key, value in metadata.items():
            if key.endswith(suffixes) and key not in plugin_data:
                plugin_data[key] = value
        return "ok", plugin_data
    except KeyError:
        return "keyerror", {}

print(probe({123: "value"}, ("_cls", "_class")))
PY

Repository: Zipstack/unstract

Length of output: 5810


🏁 Script executed:

#!/bin/bash
set -u

TESTFILE="unstract/core/tests/test_plugin_manager.py"

printf 'Tests around non-class metadata fields:\n'
sed -n '38,95p' "$TESTFILE" | awk '{printf "%5d  %s\n", NR+37, $0}'

printf '\nRelevant plugin manager implementation before load_plugins:\n'
sed -n '60,175p' unstract/core/src/unstract/core/plugins/plugin_manager.py | awk '{printf "%5d  %s\n", NR+59, $0}'

printf '\nPython behavior for metadata keys that satisfy get() but fail only at endswith:\n'
python3 - <<'PY'
def full_probe(metadata):
    try:
        if metadata.get("disable", False):
            return "skipped"
        if not metadata.get("is_active", True):
            return "inactive"
        plugin_name = metadata["name"]
        plugin_data = {
            "version": metadata.get("version", "unknown"),
            "name": "ignored",  # model the existing plugin_data with static name? No.
        }
        for key, value in metadata.items():
            if key.endswith(("_cls", "_class")) and key not in plugin_data:
                plugin_data[key] = value
        return "loaded", plugin_name, plugin_data
    except KeyError as e:
        return "keyerror", str(e)
    except Exception as e:
        return type(e).__name__, str(e)

print(full_probe({"name": "ok", "_cls": "cls", "nonstring": None}))
print(full_probe({"name": "ok", 123: "value"}))
PY

Repository: Zipstack/unstract

Length of output: 8080


Guard metadata keys before calling endswith.

Metadata keys are read through metadata.get(...) before being passed to key.endswith(...), so a non-string key can reach this call and raise AttributeError. Handle that exception too, or validate/skip invalid keys before suffix matching so broken metadata doesn’t break the entire plugin load pass.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@unstract/core/src/unstract/core/plugins/plugin_manager.py` around lines 218 -
224, Update the metadata iteration in the plugin loading logic to validate that
each key is a string before applying the "_cls"/"_class" suffix check. Skip
non-string keys while preserving the existing condition that only adds missing
plugin_data fields.

@github-actions

Copy link
Copy Markdown
Contributor

Unstract test results

Per-group results

Status Group Tier Passed Failed Errors Skipped Duration (s)
e2e-api-deployment e2e 3 0 0 0 20.4
e2e-coowners e2e 1 0 0 0 1.4
e2e-etl e2e 1 0 0 0 8.3
e2e-login e2e 2 0 0 0 1.2
e2e-prompt-studio e2e 1 0 0 0 4.6
e2e-smoke e2e 2 0 0 0 1.4
e2e-workflow e2e 1 0 0 0 16.7
integration-backend integration 161 0 0 27 40.6
integration-connectors integration 1 0 0 7 7.9
integration-workers integration 0 0 0 141 98.7
unit-backend unit 277 0 0 1 39.0
unit-connectors unit 63 0 0 0 10.5
unit-core unit 31 0 0 0 1.4
unit-platform-service unit 15 0 0 0 2.9
unit-rig unit 86 0 0 0 4.6
unit-sdk1 unit 480 0 0 0 24.7
unit-workers unit 1312 0 0 0 96.5
TOTAL 2437 0 0 176 380.7

Critical paths

⚠️ Critical paths not yet covered

  • workflow-execution-fan-out — Multi-file workflow execution fans out to file-processing workers and rejoins. (declared coverage: no groups declared)
✅ Covered critical paths
  • auth-login — covered by e2e-login
  • adapter-register-llm — covered by integration-backend
  • workflow-author — covered by integration-backend
  • co-owner-manage — covered by integration-backend, e2e-coowners
  • workflow-create-execute — covered by e2e-workflow
  • api-deployment-provision — covered by integration-backend
  • api-deployment-auth — covered by integration-backend
  • api-deployment-run — covered by e2e-api-deployment
  • prompt-studio-author — covered by integration-backend
  • prompt-studio-fetch-response — covered by e2e-prompt-studio
  • connector-register-test — covered by integration-backend
  • pipeline-etl-execute — covered by e2e-etl
  • usage-aggregate-read — covered by integration-backend
  • usage-token-tracking — covered by e2e-api-deployment
  • callback-result-delivery — covered by e2e-api-deployment

@jaseemjaskp jaseemjaskp left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Automated PR review (PR Review Toolkit: code-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer, comment-analyzer, code-simplifier). The core fix is correct and well-scoped; these are refinements. The non-string-key AttributeError case is already covered by CodeRabbit's comment and is not repeated here.

# _cls or _class, e.g. entrypoint_cls, exception_cls, service_class,
# headers_cache_class) so new plugin metadata fields don't need this
# loader updated every time.
for key, value in metadata.items():

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Silent failure — Medium] This loop propagates class handles by name only — it copies whatever keys end in _cls/_class, with no notion of a required field. That fixes the headers_cache_class drop, but the mechanism that made it invisible is untouched: if a plugin declares a misnamed field (header_cache_class, headers_cache_clss), the correct key is simply absent and verticals_usage_plugin["headers_cache_class"]() in backend/plugins/workflow_manager/workflow_v2/api_hub_usage_utils.py:38,99 raises KeyError again — swallowed by the same broad except Exception (lines 51/74/101) this PR was written to defeat.

Consider having consumers read with .get() and log an explicit failure, or validating a plugin's required class fields at load time, so a typo errors loudly at startup instead of failing silently per-request. (Narrowing those except Exception blocks to distinguish a permanent misconfiguration from a transient runtime error is the more complete fix, but is outside this PR's changed lines.)

plugin_data["exception_cls"] = metadata["exception_cls"]
if "service_class" in metadata:
plugin_data["service_class"] = metadata["service_class"]
# Add optional class/entrypoint fields if present (any key ending in

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Maintainability — Low] The _cls/_class suffix is now load-bearing behavior but is documented only in this comment. Consider promoting it to a module-level constant (e.g. _CLASS_HANDLE_SUFFIXES = ("_cls", "_class")) so the contract is greppable and testable. Also consider dropping the headers_cache_class example from the comment — it names a single enterprise plugin's field, so it is the part most likely to rot; the commit message already records the motivating case.

# headers_cache_class) so new plugin metadata fields don't need this
# loader updated every time.
for key, value in metadata.items():
if key.endswith(("_cls", "_class")) and key not in plugin_data:

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Nit — Low] key not in plugin_data can never be false here: at this point plugin_data holds only version/module/metadata (none end in _cls/_class) and metadata keys are already unique, so the guard is effectively dead. Fine to keep as future-proofing against a reserved key that someday ends in _class, but if so, add a one-line note saying it's purely defensive — otherwise it reads as load-bearing when it isn't. Flagged independently by the code-review, simplifier, and test passes.

plugin = _load(plugins_pkg_dir).get_plugin("sample_plugin")

assert plugin["service_class"] is str
assert plugin["headers_cache_class"] is dict

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Test gap — High value] The real behavioral widening in this change is that the loop propagates a *_cls/*_class key regardless of its value, whereas the old code copied 3 known class fields. No test pins that contract. A future "only propagate real classes" isinstance/callable check would pass all 4 tests here while silently reverting the fix for a field whose value is None/lazy at load time.

Suggest adding a test with metadata {"name": "sample_plugin", "headers_cache_class": None} asserting plugin["headers_cache_class"] is None — this locks "propagate by name, not by value."


manager = _load(plugins_pkg_dir)

assert not manager.has_plugin("sample_plugin")

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

[Test gap — Medium] The skip condition at plugin_manager.py:202 is metadata.get("disable", False) or not metadata.get("is_active", True). This test exercises only the disable=True operand; the is_active=False branch is separate and could regress independently (e.g. someone flips the default or drops the operand). Suggest mirroring this test with metadata {"name": "sample_plugin", "is_active": False} and asserting not manager.has_plugin("sample_plugin").

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant