Skip to content

FEAT: Enforce keyword-only __init__ on PyRIT lego brick base classes#1883

Open
romanlutz wants to merge 7 commits into
microsoft:mainfrom
romanlutz:romanlutz/audit-lego-brick-constructors
Open

FEAT: Enforce keyword-only __init__ on PyRIT lego brick base classes#1883
romanlutz wants to merge 7 commits into
microsoft:mainfrom
romanlutz:romanlutz/audit-lego-brick-constructors

Conversation

@romanlutz
Copy link
Copy Markdown
Contributor

Description

PyRIT's main extension points (PromptConverter, Scorer, PromptTarget, Scenario, AttackStrategy, SeedDatasetProvider) are "lego bricks" that users routinely swap in and out. Today there's no enforcement of any constructor shape, so sibling classes diverge: some are kwarg-only (per the style guide), others still accept positional args, and a few are sandwiched **kwargs-passthroughs. Swapping bricks in real code feels inconsistent and surprising.

This PR closes that gap with a small, shared __init_subclass__ check that every lego-brick base class invokes. Violators now fail at class-definition time with an actionable TypeError, so the contract is documented in instruction files and enforced by Python itself.

Approach

  • New shared helper pyrit/common/lego_brick_contract.py exposes enforce_keyword_only_init(cls, *, base_name). It inspects the subclass's directly-defined __init__ (skipping inherited ones) and flags any parameter after self whose kind is POSITIONAL_ONLY or POSITIONAL_OR_KEYWORD. *args (VAR_POSITIONAL) is allowed because it already forces the rest to be keyword-only.
  • Six base classes wire the helper into their own __init_subclass__ via a local import (avoids the pyrit.common circular-import trap): PromptConverter, Scorer, PromptTarget, Scenario, AttackStrategy, SeedDatasetProvider.
  • A warn-first grandfathering mechanism (_lego_brick_legacy_init = True) downgrades the TypeError to a one-shot DeprecationWarning(removed_in="0.16.0") for the 15 existing public-API violators. New code MUST follow the contract; the opt-out attribute is intended to be removed in 0.16.0.
  • One known-tolerated exception: PromptTarget.__init__ itself is still positional (6 params). __init_subclass__ doesn't fire on the base, so the suite stays green; a TODO comment records the 0.16.0 reshape plan.
  • Instruction docs updated/added: new attacks.instructions.md, scorers.instructions.md, targets.instructions.md; extended converters.instructions.md, scenarios.instructions.md, datasets.instructions.md.

Grandfathered classes (slated for 0.16.0 cleanup)

Base Class Reason
Scorer PlagiarismScorer Public positional API (reference_text, metric, ...)
PromptConverter AddImageVideoConverter, AnsiAttackConverter, AsciiArtConverter, AskToDecodeConverter, DiacriticConverter, InsertPunctuationConverter, PDFConverter, QRCodeConverter, RandomCapitalLettersConverter, SearchReplaceConverter, SmugglerConverter (and its 3 children) Public positional API
PromptTarget HTTPTarget, PromptShieldTarget, OpenAICompletionTarget, OpenAIImageTarget Public positional API

The two *args back-compat shim classes (AddImageTextConverter, AddTextImageConverter) needed no flag because *args after self already satisfies the helper.

Tests and Documentation

  • New tests/unit/common/test_lego_brick_contract.py exercises the helper (compliant / * / *args / inherited / positional / opt-out / print_deprecation_message integration).
  • A small set of test-fixture subclasses across tests/unit/scenario/, tests/unit/score/, tests/unit/mocks.py, and tests/integration/mocks.py got the mechanical *, insert so they comply with the new check. One composite-scorer test had four positional callsites updated to kwargs.
  • Full tests/unit/ run after every commit; the final run is 8392 passed, 120 skipped, 469 warnings. The 15 grandfathered classes emit exactly one DeprecationWarning each at import time.
  • Documentation lives entirely in .github/instructions/*.md; no notebook rebuilds needed. No JupyText changes.

Review notes

  • One PR per base class would have been cleaner but the changes are stacked into one branch because they share pyrit/common/lego_brick_contract.py. The commit graph mirrors the original PR plan (one commit per base class plus a small doc cleanup commit), so reviewing commit-by-commit is the recommended path.
  • Migration cost was the deciding factor for the PR order (lowest-friction bases first), so the first commit lands the helper plus the bases that needed zero grandfathering, and the bases with violators come later.

romanlutz and others added 7 commits June 1, 2026 14:08
…nd SeedDatasetProvider

PyRIT's extension-point base classes (PromptConverter, Scorer, PromptTarget,
Scenario, AttackStrategy, SeedDatasetProvider) need a consistent
constructor contract so users can swap "Lego bricks" without surprise. The
style guide already mandates `def __init__(self, *, ...)` (keyword-only
parameters), but until now nothing enforced it.

This change is the infrastructure PR (PR 1 of 5 in the audit; see session
artifacts phase1_brick_inventory.md and phase2_contract_proposal.md):

* Add `pyrit/common/lego_brick_contract.enforce_keyword_only_init` —
  a small helper that bases invoke from their own `__init_subclass__`
  hook to raise `TypeError` for non-conforming subclasses at class
  definition time. Subclasses may set `_lego_brick_legacy_init = True`
  to downgrade the error to a `DeprecationWarning(removed_in="0.16.0")`
  via the existing `print_deprecation_message` machinery — letting
  publicly-positional APIs migrate over one release cycle.
* Wire `Scenario.__init_subclass__` (new) and extend
  `SeedDatasetProvider.__init_subclass__` (existing — already registers
  subclasses and emits the legacy `fetch_dataset` deprecation) to call
  the helper. Both families were already 100%-compliant per the phase 1
  inventory; this PR has no behavior change for production code.
* Document the runtime check + opt-out in
  `.github/instructions/scenarios.instructions.md` and
  `.github/instructions/datasets.instructions.md`.
* Fix three test fixtures whose `__init__` violated the contract
  (mechanical `*,` insertion; all call sites already used kwargs).

Subsequent PRs will extend the same helper to AttackStrategy, Scorer,
PromptConverter, and PromptTarget — each opting publicly-positional
violators into `_lego_brick_legacy_init = True` so the suite stays
green during the deprecation window.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an `__init_subclass__` hook on `AttackStrategy` that calls the
shared `enforce_keyword_only_init` helper (introduced in the previous
commit) so every attack subclass must use the keyword-only constructor
shape mandated by the style guide.

All 12 attack subclasses already conformed per the phase 1 inventory, so
this PR has no behavior change for production code — it just locks in the
existing pattern at the class-definition boundary so future contributions
can't drift.

Also adds `.github/instructions/attacks.instructions.md` documenting the
contract, the runtime check, and the `_lego_brick_legacy_init` opt-out
escape hatch (`removed_in="0.16.0"`). The new check is complementary
to the existing factory-time rejection of `**kwargs` in
`AttackTechniqueFactory` — the factory check catches scenarios-side
wiring mistakes, the subclass check catches the `__init__` shape at
class definition time.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Adds an `__init_subclass__` hook on `Scorer` that calls the shared
`enforce_keyword_only_init` helper so every scorer subclass must use
the keyword-only constructor shape mandated by the style guide.

Grandfathers the one production violator surfaced by the phase 1
inventory:

* `PlagiarismScorer` (`pyrit/score/float_scale/plagiarism_scorer.py`)
  accepts `reference_text` positionally as part of its public API.
  Opting into `_lego_brick_legacy_init = True` keeps existing callers
  working but emits a `DeprecationWarning(removed_in="0.16.0")` at
  module import time. The positional shape will become keyword-only in
  0.16.0 (`BREAKING CHANGE`).

Fixes seven test fixture classes whose `__init__` violated the
contract (`MockTextTrueFalseScorer`, `MockTextFloatScaleScorer`,
`MockTrueFalseScorer`, `MockFloatScaleScorer`,
`MockAudioTrueFalseScorer`, `MockScorer` in the composite scorer
suite, and two anonymous fixtures in `test_scorer.py`). Where the
positional callsites existed (`MockScorer(True, "...")` in the
composite suite), the calls are updated to use kwargs in the same
commit.

Also adds `.github/instructions/scorers.instructions.md` documenting
the contract, the runtime check, the `_lego_brick_legacy_init`
opt-out escape hatch, and the current grandfathered list.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wires `enforce_keyword_only_init` into `PromptConverter.__init_subclass__`
so every new converter must declare its `__init__` with `*` as the first
parameter after `self` (or use `*args` immediately after `self` to consume
positional inputs into a back-compat shim). Violators now fail at class
definition time with a `TypeError` that lists the offending parameters and
points at the opt-out attribute.

Twelve existing converters whose positional `__init__` is part of the public
API are grandfathered with `_lego_brick_legacy_init = True` so the suite
stays green; each grandfathered class emits a `DeprecationWarning` once at
import time with `removed_in=0.16.0`. The grandfathered list is documented
in `converters.instructions.md`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Wires `enforce_keyword_only_init` into `PromptTarget.__init_subclass__`
so every new target must declare its `__init__` with `*` as the first
parameter after `self` (or use `*args`/`**kwargs` immediately after
`self`). Violators now fail at class definition time with a `TypeError`
that lists the offending parameters and points at the opt-out attribute.

The four existing public-API violators (`HTTPTarget`, `PromptShieldTarget`,
`OpenAICompletionTarget`, `OpenAIImageTarget`) are grandfathered with
`_lego_brick_legacy_init = True` so the suite stays green; each emits a
`DeprecationWarning` once at import time with `removed_in=0.16.0`.

`PromptTarget.__init__` itself still accepts positional parameters. The
`__init_subclass__` hook only runs for subclasses, so the base class
non-compliance is tolerated during this warn-first phase; the base
signature will be reshaped to be keyword-only in 0.16.0 as a BREAKING
CHANGE. A TODO comment on the base `__init__` records the plan.

Creates `.github/instructions/targets.instructions.md` documenting the
contract, the opt-out attribute, and the grandfathered list. Fixes the
`MockPromptTarget` test fixtures in `tests/unit/mocks.py` and
`tests/integration/mocks.py` so they comply with the new check.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
PyRIT docstrings use plain double-backtick names, not Sphinx `:func:`
roles. Replace the seven `:func:...` references I introduced across
the lego-brick contract PRs with the same plain-backtick style used
elsewhere in the file.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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