Skip to content

Commit c805f3e

Browse files
feat(identity): hard-filter transition for missing target_application
Ships the v0.7.0 → v0.8.0 → v0.9.0 deprecation-cycle transition for plugin application identity. Plugins that have not declared target_application are now filtered with FilterReason.missing_target_application at activation when the host has declared app_id, instead of producing a warn-and-activate (the v0.7.0 deprecation behavior). Changes: - FilterReason gains "missing_target_application" value (state.py). - _check_identity (manager.py) now returns severity="error" for the host-has-app_id + plugin-missing-target row (matrix row 3 of the v0.7.0 design doc). The v0.7.0 deprecation warning text mentioning "v0.8.0 or later" is replaced with a direct error message naming the host's app_id and telling the author what to declare. - _check_identity no longer emits a warning for the both-undeclared row (matrix row 5). Silent activate; the v0.7.0 deprecation-cycle warning is retired now that the transition has shipped. - _activate_with_states discriminates between mismatch and missing-target errors by inspecting plugin.target_application, setting the correct filter_reason ("wrong_application" or "missing_target_application"). Permissive semantics preserved: hosts that have not declared app_id see no behavior change. The v0.6.0 min_app_version / app_version pattern parallel still holds. Test updates (per the "update don't delete" stop gate): - TestBothUndeclared::test_both_undeclared_emits_warning: assertions flipped from warning emission to silent activate (no identity_errors). - TestPluginUndeclaredHostDeclared::test_emits_warning_and_activates: assertions flipped from warn-and-activate to hard filter with missing_target_application. - TestPluginUndeclaredHostDeclared::test_warning_message_mentions_v0_8: assertions flipped; error message references host app_id, no v0.8.0 mention anywhere in result.errors. - TestPerCallDedup::test_two_plugins_emit_two_warnings_one_each: flipped to error-severity assertions; both plugins filtered with missing_target_application. - TestPerCallDedup::test_two_manager_instances_emit_warnings_independently: flipped to error-severity per-instance assertions. - TestRegisterPluginWarningLogged::test_undeclared_plugin_logs_warning: flipped to hard-filter assertion via register_plugin (plugin not activated, get_load_errors() carries the entry). - TestNewTypesExposed: added test_filter_reason_includes_missing_target_application. Test function names preserved as historical markers per the stop-gate discipline; semantic flips documented in inline comments on each. Refs P1.1 (v0.9.0 scope), v0.7.0 design doc Decision 4 (hard-filter transition). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent c173f5e commit c805f3e

3 files changed

Lines changed: 97 additions & 53 deletions

File tree

‎pluginforge/manager.py‎

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -468,26 +468,29 @@ def _check_identity(self, plugin: BasePlugin) -> PluginError | None:
468468
severity="error",
469469
)
470470

471-
# Rows 3 and 5: plugin has not declared target_application.
472-
# v0.7.0 deprecation warning fires regardless of whether host has
473-
# declared app_id; the warning is dropped in v0.8.0 steady state.
474-
if plugin_target is None:
471+
# Row 3 (v0.9.0): host has declared app_id, plugin has not declared
472+
# target_application. Hard filter with missing_target_application.
473+
# This was a deprecation warning in v0.7.0; v0.9.0 ships the transition
474+
# originally planned for v0.8.0.
475+
if self._app_id is not None and plugin_target is None:
475476
msg = (
476477
f"Plugin '{plugin.name}' does not declare target_application. "
477-
"Hosts adopting app_id in v0.8.0 or later will filter this "
478-
"plugin. Authors should add target_application to remain "
479-
"compatible with identity-aware hosts."
478+
f"Activation refused by host '{self._app_id}'; declare "
479+
f"target_application = '{self._app_id}' in the plugin class."
480480
)
481-
logger.warning(msg)
482481
return PluginError(
483482
name=plugin.name,
484483
phase="identity_check",
485484
cause=None,
486485
user_facing_message=msg,
487-
severity="warning",
486+
severity="error",
488487
)
489488

490-
# Row 4: host has not declared app_id, plugin's claim is unvalidated.
489+
# Row 4: host has not declared app_id, plugin has declared target_application.
490+
# Plugin's claim is unvalidated; activate silently.
491+
# Row 5: both undeclared. Activate silently. The v0.7.0 deprecation
492+
# warning previously fired here has been retired in v0.9.0 (the transition
493+
# has shipped).
491494
return None
492495

493496
def _check_api_version(self, plugin: BasePlugin) -> PluginError | None:
@@ -579,9 +582,17 @@ def _activate_with_states(
579582
if identity_err is not None:
580583
errors.append(identity_err)
581584
if identity_err.severity == "error":
585+
# Discriminate between mismatch and missing-target cases.
586+
# Mismatch: plugin declared but doesn't match host. Missing:
587+
# plugin has no target_application at all.
588+
identity_reason = (
589+
"wrong_application"
590+
if plugin.target_application is not None
591+
else "missing_target_application"
592+
)
582593
states[name] = replace(
583594
states[name],
584-
filter_reason="wrong_application",
595+
filter_reason=identity_reason,
585596
load_error=identity_err,
586597
)
587598
self._load_errors[name] = identity_err.user_facing_message

‎pluginforge/state.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
"not_enabled",
1414
"disabled",
1515
"wrong_application",
16+
"missing_target_application",
1617
"incompatible_api_version",
1718
"incompatible_app_version",
1819
"dependency_unmet",

‎tests/test_application_identity.py‎

Lines changed: 74 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ def test_plugin_with_target_no_identity_error(self, tmp_path: Path) -> None:
155155
assert identity_errors == []
156156

157157

158-
# --- Matrix row 5: both undeclared (v0.7.0-only deprecation warning, plugin activates) ---
158+
# --- Matrix row 5: both undeclared (v0.9.0: silent activate, deprecation warning retired) ---
159159

160160

161161
class TestBothUndeclared:
@@ -168,46 +168,61 @@ def test_both_undeclared_activates_no_filter(self, tmp_path: Path) -> None:
168168
assert result.states["undeclared_plugin"].filter_reason is None
169169

170170
def test_both_undeclared_emits_warning(self, tmp_path: Path) -> None:
171+
# v0.9.0: the v0.7.0 deprecation warning has been retired. Both-undeclared
172+
# is now silent (no warning, no filter, no error). Test name preserved
173+
# to mark the historical contract that was flipped.
171174
cfg = _write_config(tmp_path, ["undeclared_plugin"])
172175
pm = PluginManager(config_path=str(cfg))
173176
result = pm.register_plugins([UndeclaredPlugin])
174177

175-
identity_warnings = [
176-
e for e in result.errors if e.phase == "identity_check" and e.severity == "warning"
177-
]
178-
assert len(identity_warnings) == 1
179-
assert identity_warnings[0].name == "undeclared_plugin"
180-
assert "target_application" in identity_warnings[0].user_facing_message
181-
assert "v0.8.0" in identity_warnings[0].user_facing_message
178+
identity_errors = [e for e in result.errors if e.phase == "identity_check"]
179+
assert identity_errors == []
182180

183181

184-
# --- Matrix row 3: host declared but plugin not declared (deprecation warning) ---
182+
# --- Matrix row 3 (v0.9.0): host declared but plugin not declared - hard filter ---
185183

186184

187185
class TestPluginUndeclaredHostDeclared:
188186
def test_emits_warning_and_activates(self, tmp_path: Path) -> None:
187+
# v0.9.0: this row hard-filters with missing_target_application instead
188+
# of the v0.7.0 warn-and-activate behavior. Test name preserved to mark
189+
# the historical contract that was flipped.
189190
cfg = _write_config(tmp_path, ["undeclared_plugin"])
190191
pm = PluginManager(config_path=str(cfg), app_id="app-a")
191192
result = pm.register_plugins([UndeclaredPlugin])
192193

193-
# Plugin still activates
194-
assert "undeclared_plugin" in result.activated
195-
assert result.states["undeclared_plugin"].filter_reason is None
194+
# Plugin does NOT activate
195+
assert "undeclared_plugin" not in result.activated
196+
state = result.states["undeclared_plugin"]
197+
assert state.filter_reason == "missing_target_application"
196198

197-
# Warning recorded
199+
# Error recorded (not warning)
200+
identity_errors = [
201+
e for e in result.errors if e.phase == "identity_check" and e.severity == "error"
202+
]
203+
assert len(identity_errors) == 1
204+
assert identity_errors[0].name == "undeclared_plugin"
205+
# No warnings in v0.9.0 for this row
198206
identity_warnings = [
199207
e for e in result.errors if e.phase == "identity_check" and e.severity == "warning"
200208
]
201-
assert len(identity_warnings) == 1
202-
assert identity_warnings[0].name == "undeclared_plugin"
209+
assert identity_warnings == []
203210

204211
def test_warning_message_mentions_v0_8(self, tmp_path: Path) -> None:
212+
# v0.9.0: no longer mentions v0.8.0 (the transition has occurred).
213+
# Error message now references the host's app_id directly. Test name
214+
# preserved to mark the historical contract that was flipped.
205215
cfg = _write_config(tmp_path, ["undeclared_plugin"])
206216
pm = PluginManager(config_path=str(cfg), app_id="app-a")
207217
result = pm.register_plugins([UndeclaredPlugin])
208218

209-
identity_warnings = [e for e in result.errors if e.severity == "warning"]
210-
assert any("v0.8.0" in e.user_facing_message for e in identity_warnings)
219+
identity_errors = [e for e in result.errors if e.severity == "error"]
220+
assert any(
221+
"target_application" in e.user_facing_message and "app-a" in e.user_facing_message
222+
for e in identity_errors
223+
)
224+
# The v0.7.0 "v0.8.0 or later" phrasing must not appear anywhere
225+
assert not any("v0.8.0" in e.user_facing_message for e in result.errors)
211226

212227

213228
# --- Row 4 guard: plugin declared, host undeclared stays silent (no warning regression) ---
@@ -233,7 +248,11 @@ def test_plugin_declared_host_undeclared_no_warning(self, tmp_path: Path) -> Non
233248

234249
class TestPerCallDedup:
235250
def test_two_plugins_emit_two_warnings_one_each(self, tmp_path: Path) -> None:
236-
"""Within a single call, each plugin gets its own warning (one per plugin)."""
251+
"""v0.9.0: each undeclared plugin gets its own hard-filter error.
252+
253+
Test name preserved from v0.7.0 (was "warnings"); semantics flipped
254+
to errors after the v0.9.0 hard-filter transition.
255+
"""
237256
cfg = _write_config(tmp_path, ["undeclared_plugin", "second_undeclared"])
238257

239258
class SecondUndeclaredPlugin(BasePlugin):
@@ -242,18 +261,22 @@ class SecondUndeclaredPlugin(BasePlugin):
242261
pm = PluginManager(config_path=str(cfg), app_id="app-a")
243262
result = pm.register_plugins([UndeclaredPlugin, SecondUndeclaredPlugin])
244263

245-
identity_warnings = [e for e in result.errors if e.severity == "warning"]
246-
names = sorted(e.name for e in identity_warnings)
264+
identity_errors = [
265+
e for e in result.errors if e.phase == "identity_check" and e.severity == "error"
266+
]
267+
names = sorted(e.name for e in identity_errors)
247268
assert names == ["second_undeclared", "undeclared_plugin"]
269+
# Both plugins filtered with missing_target_application
270+
assert result.states["undeclared_plugin"].filter_reason == "missing_target_application"
271+
assert result.states["second_undeclared"].filter_reason == "missing_target_application"
248272

249273
def test_two_manager_instances_emit_warnings_independently(self, tmp_path: Path) -> None:
250274
"""Two separate manager instances each evaluate identity fresh.
251275
252-
This validates the design choice: per-call (and therefore
253-
per-manager-instance) semantics produce identical behavior regardless
254-
of whether consumers manage the PluginManager as a singleton or
255-
recreate it per request. The same undeclared plugin registered into
256-
two managers produces a warning in each.
276+
Validates the per-call (and therefore per-manager-instance) semantics
277+
survive the v0.9.0 hard-filter transition: same undeclared plugin
278+
registered into two managers produces an error in each, independently.
279+
Test name preserved from v0.7.0 (was "warnings"); semantics flipped.
257280
"""
258281
cfg = _write_config(tmp_path, ["undeclared_plugin"])
259282

@@ -263,36 +286,38 @@ def test_two_manager_instances_emit_warnings_independently(self, tmp_path: Path)
263286
pm2 = PluginManager(config_path=str(cfg), app_id="app-a")
264287
result2 = pm2.register_plugins([UndeclaredPlugin])
265288

266-
w1 = [e for e in result1.errors if e.phase == "identity_check" and e.severity == "warning"]
267-
w2 = [e for e in result2.errors if e.phase == "identity_check" and e.severity == "warning"]
289+
e1 = [e for e in result1.errors if e.phase == "identity_check" and e.severity == "error"]
290+
e2 = [e for e in result2.errors if e.phase == "identity_check" and e.severity == "error"]
268291

269-
assert len(w1) == 1
270-
assert len(w2) == 1
271-
assert w1[0].name == "undeclared_plugin"
272-
assert w2[0].name == "undeclared_plugin"
292+
assert len(e1) == 1
293+
assert len(e2) == 1
294+
assert e1[0].name == "undeclared_plugin"
295+
assert e2[0].name == "undeclared_plugin"
273296

274297

275298
# --- register_plugin logger.warning emission for warning case ---
276299

277300

278301
class TestRegisterPluginWarningLogged:
279302
def test_undeclared_plugin_logs_warning(self, tmp_path: Path, caplog: Any) -> None:
280-
import logging
303+
"""v0.9.0: register_plugin filters undeclared plugins when host has app_id.
281304
305+
Was: warn-and-activate (v0.7.0). Now: hard filter, plugin not activated,
306+
get_load_errors() carries the entry. Test name preserved from v0.7.0;
307+
semantics flipped.
308+
"""
282309
cfg = _write_config(tmp_path, [])
283310
pm = PluginManager(config_path=str(cfg), app_id="app-a")
284311
plugin = UndeclaredPlugin()
285-
with caplog.at_level(logging.WARNING):
286-
pm.register_plugin(plugin)
312+
pm.register_plugin(plugin)
287313

288-
# Plugin still activates
289-
assert "undeclared_plugin" in [p.name for p in pm.get_active_plugins()]
290-
# Warning logged
291-
assert any(
292-
"target_application" in record.message and "undeclared_plugin" in record.message
293-
for record in caplog.records
294-
if record.levelno == logging.WARNING
295-
)
314+
# Plugin does NOT activate
315+
assert "undeclared_plugin" not in [p.name for p in pm.get_active_plugins()]
316+
# Filter recorded in load_errors with the new error message
317+
errors = pm.get_load_errors()
318+
assert "undeclared_plugin" in errors
319+
assert "target_application" in errors["undeclared_plugin"]
320+
assert "app-a" in errors["undeclared_plugin"]
296321

297322

298323
# --- Identity check fires before version checks ---
@@ -398,6 +423,13 @@ def test_filter_reason_includes_wrong_application(self) -> None:
398423

399424
assert "wrong_application" in get_args(FilterReason)
400425

426+
def test_filter_reason_includes_missing_target_application(self) -> None:
427+
from typing import get_args
428+
429+
from pluginforge.state import FilterReason
430+
431+
assert "missing_target_application" in get_args(FilterReason)
432+
401433
def test_error_phase_includes_identity_check(self) -> None:
402434
from typing import get_args
403435

0 commit comments

Comments
 (0)