Instruction file imported from microsoft/PyRIT (
.github/instructions/style-guide.instructions.md). Copyright stays with the author.
PyRIT Coding Style Guidelines
Follow these coding standards to ensure consistent, readable, and maintainable code across the PyRIT project.
Async Code
No Blocking I/O in Async Paths
- MANDATORY: Inside
async def(and any sync helper reached from one), never call blocking I/O ortime.sleep. They stall the event loop and serialize all concurrent work. - File I/O →
aiofiles(already a project dep). HTTP →httpx.AsyncClientorpyrit.common.net_utility.make_request_and_raise_if_error_async. Sleeps →asyncio.sleep. Subprocess →asyncio.create_subprocess_*. Blocking-only libs (wave,av,cv2,PIL.Image.openfrom disk,zipfile,yaml,jsonreads from file) → wrap the blocking section in a sync helper and call viaawait asyncio.to_thread(_helper, ...). - I/O that runs once at construction time (
__init__) is sync by design; prefer doing one-shot reads there rather than on every async call.
# WRONG — blocks the event loop on every send
async def _send_async(self):
with open(self.file_path, "rb") as fp:
return fp.read()
# CORRECT — async file read
async def _send_async(self):
async with aiofiles.open(self.file_path, "rb") as fp:
return await fp.read()
# WRONG — sync-only library called directly
async def _read_audio_async(self, path):
with wave.open(path, "rb") as wav:
return wav.readframes(wav.getnframes())
# CORRECT — wrap blocking lib in to_thread
def _read_wav_sync(path):
with wave.open(path, "rb") as wav:
return wav.readframes(wav.getnframes())
async def _read_audio_async(self, path):
return await asyncio.to_thread(_read_wav_sync, path)
Function and Method Naming
Async Functions
- MANDATORY: All async functions and methods MUST end with
_asyncsuffix - This applies to ALL async functions without exception
- Enforced by the
check-async-suffixpre-commit hook (build_scripts/check_async_suffix.py)
# CORRECT
async def send_prompt_async(self, prompt: str) -> Message: ...
# INCORRECT
async def send_prompt(self, prompt: str) -> Message: # Missing _async suffix
...
Exemptions are limited and explicit:
- Async dunders (
__aenter__,__aexit__,__aiter__,__anext__) are exempt automatically. - A small set of framework-mandated names (
lifespan,dispatch,__call__) is exempt automatically; see_FRAMEWORK_EXEMPT_NAMESinbuild_scripts/check_async_suffix.py. - For one-off exemptions (e.g. an external SDK protocol method) add a
# pyrit-async-suffix-exempttrailing comment on theasync defline.
Private Methods
- Private methods MUST start with underscore
- This clearly indicates internal implementation details
# CORRECT
def _validate_input(self, data: dict) -> None: ...
# INCORRECT
def validate_input(self, data: dict) -> None: # Should be private
...
Type Annotations
Mandatory Type Hints
- EVERY function parameter MUST have explicit type declaration
- EVERY function MUST declare its return type
- Use
Nonefor functions that don't return a value
Modern Type Syntax
- Use built-in generics and union syntax:
list[str]notList[str]dict[str, Any]notDict[str, Any]str | NonenotOptional[str]int | floatnotUnion[int, float]
- Still import
Any,Literal,TypeVar,Protocol,castetc. fromtypingas needed - This rule applies to docstrings and comments too. Argument type references inside docstrings (e.g.
Args:blocks) and any comment mentioning a type should use the modern form so the docs stay consistent with the signatures.
# CORRECT
def process_data(self, *, data: list[str], threshold: float = 0.5) -> dict[str, Any]: ...
def get_name(self) -> str | None: ...
# INCORRECT
def process_data(self, data, threshold=0.5): # Missing all type annotations
...
Function Signatures
Keyword-Only Arguments
- Functions with more than 1 parameter MUST use
*after self/cls to enforce keyword-only arguments - This prevents positional argument errors and improves API clarity
# CORRECT
def __init__(self, *, target: PromptTarget, scorer: Scorer | None = None, max_retries: int = 3) -> None: ...
# INCORRECT
def __init__(self, target: PromptTarget, scorer: Scorer | None = None, max_retries: int = 3): ...
Forwarded Constructor Parameters
- When a registry-built class accepts
**kwargsin__init__and passes them tosuper().__init__(**kwargs), decorate the constructor with@forward_init_parameters. - Do not apply the decorator to open-ended keyword bags that are consumed locally or are not forwarded. The registry uses the marker specifically to include parent constructor parameters in the class's build contract.
Single Parameter Functions
- Functions with only one parameter don't need keyword-only enforcement
# CORRECT
def process(self, data: str) -> str: ...
Imports
Placement and Organization
Top of file, grouped: stdlib → third-party → local.
Deferred Imports for Performance
Imports may be placed inside functions/methods when they pull in expensive
third-party packages (transformers, azure.storage.blob, alembic, openai,
scipy, pandas, av). Two cases:
- CLI entry points — defer heavy imports to after arg parsing so
--helpis instant. - Internal modules — when a method is the only consumer of a heavy package.
def main() -> int:
parsed_args = parse_args()
from pyrit.cli import frontend_core # deferred: heavy
...
async def _create_container_client_async(self):
from azure.storage.blob.aio import ContainerClient # deferred: heavy
...
Guard tests in tests/unit/cli/test_import_guards.py enforce that key import
paths stay fast.
Lazy __init__.py Exports (PEP 562)
Public API packages use __getattr__-based lazy loading so package imports do
not load every implementation module. Use pyrit.common.lazy_imports and follow
the standard contract:
- Add each public export to
_LAZY_EXPORTS, which is the runtime source of truth. - Use a module string when the public and source attribute names match. Use the tuple form only for aliases or module-valued exports.
- Set
__all__ = list(_LAZY_EXPORTS). - Put every public export under
if TYPE_CHECKING:for editor and static-analysis support. - Implement
__getattr__withresolve_lazy_exportand__dir__withget_lazy_dir. - Do not add eager implementation imports to package initializers.
The package contract tests in tests/unit/common/test_lazy_package_imports.py
enforce these rules. Internal heavy submodules that are not public exports
should remain omitted from __init__.py; consumers import them from the
specific module.
Import Paths
Import from the package root when the symbol is exported from __init__.py:
from pyrit.prompt_target import PromptTarget # CORRECT
from pyrit.prompt_target.common.prompt_target import PromptTarget # WRONG
Heavy submodules not re-exported from __init__.py are imported directly:
from pyrit.common.net_utility import get_httpx_client
Within the same package, import from the specific file to avoid circular imports.
Typing Backports (typing_extensions)
Import typing features from typing when they exist on every supported Python
version. For newer features, import from typing_extensions. It is already a
transitive dependency (pulled in by pydantic), so this avoids per-version
branching and # type: ignore noise.
# CORRECT — works on every supported Python version
from typing import Self
from typing_extensions import override
# INCORRECT — `override` is 3.12+ and breaks on Python 3.11
from typing import Self, override
Documentation Standards
Docstring Format
- Use Google-style docstrings
- Include type information in parameter descriptions
- Document return types and values
- Include "Raises" section when applicable
- Use triple quotes even for single-line docstrings
- Do not include example calls for how it's used
def calculate_score(
self, *, response: str, objective: str, threshold: float = 0.8, max_attempts: int | None = None
) -> Score:
"""
Calculate the score for a response against an objective.
This method evaluates how well the response achieves the stated objective
using the configured scoring mechanism.
Args:
response (str): The response text to evaluate.
objective (str): The objective to evaluate against.
threshold (float): The minimum score threshold. Defaults to 0.8.
max_attempts (int | None): Maximum number of scoring attempts. Defaults to None.
Returns:
Score: The calculated score object containing value and metadata.
Raises:
ValueError: If response or objective is empty.
ScoringException: If the scoring process fails.
"""
Code references in docstrings
The PyRIT docs build uses MyST (Markdown-flavoured), not reStructuredText.
Do not use reST cross-reference roles in docstrings or module comments —
they render as raw text under MyST. A pre-commit hook
(check_no_rest_roles) blocks new ones from landing.
Use plain double-backticks for symbol references. The API page generator
(build_scripts/gen_api_md.py) automatically rewrites known PyRIT symbol
names into MyST cross-reference links at build time, so you get clickable
navigation in the rendered docs without any extra markup.
# WRONG — reST roles render as literal `:class:\`SeedPrompt\`` under MyST,
# and the pre-commit guard will reject them
"""Returns a :class:`SeedPrompt` instance."""
"""Delegate to :func:`download_files_async` (deprecated alias)."""
"""See :meth:`PromptTarget.apply_capabilities` for details."""
# CORRECT — plain double-backticks; gen_api_md.py auto-links these
"""Returns a ``SeedPrompt`` instance."""
"""Delegate to ``download_files_async`` (deprecated alias)."""
"""See ``PromptTarget.apply_capabilities`` for details."""
The auto-linker resolves:
- bare class/function names (
SeedPrompt) Class.methodreferences (PromptTarget.apply_capabilities)- fully-qualified paths (
pyrit.models.SeedPrompt) - bare method names when the docstring is on the owning class
(
send_prompt_asyncinsidePromptTarget)
Ambiguous short names (e.g. two unrelated classes both called Scorer)
are left as plain code-spans; spell out the FQN when you need a stable
cross-reference. Unknown names also stay as plain code-spans, so
docstrings remain safe to write without consulting the symbol index.
If you need an explicit MyST link in markdown documentation, use the
standard syntax [`Name`](#api-pyrit_module-Name) — but inside
Python docstrings this should be rare; plain backticks are the default.
Class-Level Constants
- Define constants as class attributes, not module-level
- Use UPPER_CASE naming for constants
# CORRECT
class TreeOfAttacksAttack(AttackStrategy):
DEFAULT_TREE_WIDTH: int = 3
DEFAULT_TREE_DEPTH: int = 5
MIN_CONFIDENCE_THRESHOLD: float = 0.7
# INCORRECT
DEFAULT_TREE_WIDTH = 3 # Should be inside class
DEFAULT_TREE_DEPTH = 5
MIN_CONFIDENCE_THRESHOLD = 0.7
Code Organization
Function Length
- Keep functions under 20 lines where possible
- Extract complex logic into well-named helper methods
- Each function should have a single, clear responsibility
# CORRECT
async def execute_attack_async(self, *, context: AttackContext) -> AttackResult:
"""Execute the attack with the given context."""
self._validate_context(context)
prompt = await self._prepare_prompt_async(context)
response = await self._send_prompt_async(prompt, context)
result = self._evaluate_response(response, context)
return result
def _validate_context(self, context: AttackContext) -> None:
"""Validate the attack context."""
if not context.objective:
raise ValueError("Context must have an objective")
# INCORRECT - Too long and doing too many things
async def execute_attack_async(self, *, context: AttackContext) -> AttackResult:
# 50+ lines of mixed validation, preparation, sending, and evaluation logic
...
Method Ordering
- Class-level constants and class variables
__init__method- Public methods (API)
- Protected methods (subclass API)
- Private methods (internal implementation)
- Static methods and class methods at the end
Error Handling
Specific Exceptions
- Raise specific exceptions with clear messages
- Create custom exceptions when appropriate
- Always include helpful context in error messages
# CORRECT
if not self._model:
raise ValueError("Model not initialized. Call initialize_model() before executing attack.")
# INCORRECT
if not self._model:
raise Exception("Error") # Too generic, unhelpful message
Early Returns
- Use early returns to reduce nesting
- Handle edge cases at the beginning of functions
# CORRECT
def process_items(self, *, items: list[str]) -> list[str]:
if not items:
return []
if len(items) == 1:
return [self._process_single(items[0])]
# Main logic for multiple items
return [self._process_single(item) for item in items]
# INCORRECT - Excessive nesting
def process_items(self, *, items: list[str]) -> list[str]:
if items:
if len(items) == 1:
return [self._process_single(items[0])]
else:
return [self._process_single(item) for item in items]
else:
return []
Deprecations
When deprecating a public class, function, method, parameter, or module path, use pyrit.common.deprecation.print_deprecation_message — never warnings.warn directly. It wraps warnings.warn(..., DeprecationWarning, stacklevel=3) with a consistent format so filtering still works.
Set removed_in to current version + 2 minor versions (e.g. 0.14.x → removed_in="0.16.0"). This gives one full release cycle of warning before removal.
from pyrit.common.deprecation import print_deprecation_message
def old_method(self, *, foo: str) -> None:
print_deprecation_message(
old_item="MyClass.old_method",
new_item="MyClass.new_method",
removed_in="0.16.0",
)
...
old_item / new_item accept a class/callable (qualified name is generated) or a string.
# INCORRECT - bypasses the helper, breaks consistent formatting and filtering
import warnings
warnings.warn("foo is deprecated, use bar", DeprecationWarning, stacklevel=2)
If you find yourself reaching for warnings.warn because the
"X is deprecated and will be removed in Y. Use Z instead." template doesn't fit your message, rework the message to fit the template (e.g. lean on the docstring or a log line for any extra nuance). Do not stuff extra prose into new_item to work around the template.
Deprecating an argument with a non-default value
To detect "user passed this argument" reliably, use a sentinel default (typically None) rather than the documented default, and warn whenever the value is not the sentinel — otherwise callers who explicitly pass the documented default get no warning even though they're using the deprecated path.
# CORRECT - warns on any explicit value, including the old documented default of 1
def run_async(self, *, max_concurrency: int | None = None) -> None:
if max_concurrency is not None:
print_deprecation_message(
old_item="run_async(max_concurrency=...)",
new_item="run_async(executor=AttackExecutor(max_concurrency=...))",
removed_in="0.16.0",
)
effective = max_concurrency if max_concurrency is not None else 1
...
Pythonic Patterns
List Comprehensions
- Use comprehensions for simple transformations
- Don't use comprehensions for complex logic or side effects
# CORRECT
filtered_scores = [s for s in scores if s.value > threshold]
# INCORRECT - Too complex for comprehension
results = [
self._complex_transform(item, index, context)
for index, item in enumerate(items)
if self._should_process(item, context) and not item.processed
]
Context Managers
- Use context managers for resource management
- Create custom context managers when appropriate
# CORRECT
async with self._get_client() as client:
response = await client.send(request)
# For custom resources
from contextlib import asynccontextmanager
@asynccontextmanager
async def temporary_config(self, **kwargs):
old_config = self._config.copy()
self._config.update(kwargs)
try:
yield
finally:
self._config = old_config
Property Decorators
- Use @property for simple computed attributes
- Use explicit getter/setter methods for complex logic
- Property docstrings must be noun phrases describing the value (e.g.
"""The display name."""), not verb phrases (e.g."""Return the display name."""). This is enforced by RuffD421(property-docstring-starts-with-verb).
# CORRECT
@property
def is_complete(self) -> bool:
"""Whether the attack is complete."""
return self._status == AttackStatus.COMPLETE
# INCORRECT - verb-phrase docstring, flagged by Ruff D421
@property
def is_complete(self) -> bool:
"""Check if the attack is complete."""
return self._status == AttackStatus.COMPLETE
# INCORRECT - Too complex for property
@property
def analysis_report(self) -> str:
# 20+ lines of complex report generation
...
Testing Considerations
Dependency Injection
- Design classes to accept dependencies through constructor
- Avoid hard-coded dependencies
- For default behaviors, use factory class methods
# CORRECT
class AttackExecutor:
def __init__(self, *, target: PromptTarget, scorer: Scorer, logger: logging.Logger | None = None) -> None:
self._target = target
self._scorer = scorer
self._logger = logger or logging.getLogger(__name__)
# INCORRECT
class AttackExecutor:
def __init__(self):
self._target = AzureOpenAI() # Hard-coded dependency
self._scorer = DefaultScorer() # Hard-coded dependency
Pure Functions
- Prefer pure functions where possible
- Separate I/O from business logic
# CORRECT
def calculate_score(response: str, objective: str) -> float:
"""Pure function for score calculation."""
# Logic without side effects
return score
async def evaluate_response_async(self, *, response: str) -> Score:
"""I/O function that uses the pure function."""
score_value = calculate_score(response, self._objective)
await self._save_score_async(score_value)
return Score(value=score_value)
Performance Considerations
Lazy Evaluation
- Use generators for large sequences
- Don't load entire datasets into memory unnecessarily
# CORRECT
def process_large_dataset(self, *, file_path: Path) -> Generator[Result, None, None]:
with open(file_path) as f:
for line in f:
yield self._process_line(line)
# INCORRECT
def process_large_dataset(self, *, file_path: Path) -> list[Result]:
with open(file_path) as f:
lines = f.readlines() # Loads entire file into memory
return [self._process_line(line) for line in lines]
Lazy Imports for Startup Performance
- When adding a new module that imports heavy third-party packages (e.g.,
transformers,scipy,PIL,datasets,av), consider whether it is re-exported from a package__init__.pythat is on the CLI startup path - If so, add it to the
_LAZY_IMPORTSdict in that__init__.pyinstead of as an eager top-level import (see the Import Placement section for the pattern) - This is especially important for
pyrit/common/__init__.py,pyrit/prompt_target/__init__.py,pyrit/converter/__init__.py, andpyrit/score/__init__.pywhich are all on the import path for CLI startup
Final Checklist
Before committing code, ensure:
- No blocking I/O on async paths (no sync
open,requests.*,time.sleep,wave.open, etc. insideasync defor sync helpers reached fromasync def) - All async functions have
_asyncsuffix - All functions have complete type annotations
- Functions with >1 parameter use keyword-only arguments
- Docstrings include parameter types
- Docstrings use plain double-backtick code spans for symbol references (no reST roles)
- Enums are used instead of Literals
- Functions are focused and under 20 lines
- Error messages are helpful and specific
- Code follows the import organization pattern
- New modules with heavy deps follow
__init__.pystartup guidance - No hard-coded dependencies
- Complex logic is extracted to helper methods
File Editing Rules
Never Use sed for File Edits
- MANDATORY: Never use
sed(or similar stream-editing CLI tools) to modify source files sedfrequently corrupts files, applies partial edits, or silently fails- Always use the editor's built-in replace/edit tools (e.g.,
replace_string_in_file,multi_replace_string_in_file) to make targeted, verifiable changes
Remember: Clean code is written for humans to read. Make your intent clear and your code self-documenting.