Skip to content

feat: plug-and-play v0.5.0 — Proposals 1 + 2 (Pydantic config, builder, Protocol, testing plugin)#18

Merged
cipher813 merged 5 commits into
mainfrom
feat/plug-and-play-plan
May 13, 2026
Merged

feat: plug-and-play v0.5.0 — Proposals 1 + 2 (Pydantic config, builder, Protocol, testing plugin)#18
cipher813 merged 5 commits into
mainfrom
feat/plug-and-play-plan

Conversation

@cipher813
Copy link
Copy Markdown
Owner

@cipher813 cipher813 commented May 13, 2026

Summary

Lands Proposals 1 + 2 from private/plug-and-play-260513.md — typed, IDE-discoverable configuration AND the Protocol + testing-plugin surface that unblocks morning-signal's cutover.

Proposal 1 — Pydantic v2 config + builder

  • flow_doctor/core/config.py — all 11 @dataclass config models → Pydantic v2 BaseModel via a shared _ConfigModel base. Field names + defaults preserved → drop-in for existing test fixtures and 0.4.0 callers. Adds pydantic>=2.0 to runtime deps.
  • flow_doctor/notify/configs.pySlackNotifierConfig | EmailNotifierConfig | GitHubNotifierConfig | S3NotifierConfig as a Pydantic discriminated union (Field(discriminator="type")). EmailNotifierConfig.recipients accepts a CSV string or a list and normalizes via a field_validator.
  • flow_doctor/core/builder.pyFlowDoctorBuilder with fluent add_notifier / with_repo / with_dedup / with_store / with_diagnosis / with_github / with_auto_fix / with_remediation / with_handler / with_dependencies methods + build_config() and build(strict=True).
  • FlowDoctor.builder(flow_name) classmethod is the recommended new entry point; flow_doctor.init(config_path=...) still works unchanged.

Proposal 2 — Protocol + contextvars + async + testing plugin

  • flow_doctor/_protocol.py@runtime_checkable FlowDoctorProtocol declaring report() / guard() / monitor() / report_async() as the cross-version public contract. Consumers type-hint against the Protocol; mypy/isinstance verifies swap-ins.
  • flow_doctor/core/_context.py — per-task/-thread contextvars for flow_name + stage + extras, exposed via flow_doctor.context(). Inner scopes shadow outer; the active snapshot merges into every report's context at _build_context() time. Deep call-stacks no longer thread context=... explicitly.
  • FlowDoctor.report_async() — async coroutine running the existing sync pipeline via asyncio.to_thread(). Contextvars inherit across the thread boundary automatically.
  • flow_doctor/testing/_recording.pyRecordingFlowDoctor implementing FlowDoctorProtocol + ReportedIncident dataclass with .clear() / .last / .of_type(exc_name) ergonomic helpers.
  • flow_doctor/testing/_plugin.pyflow_doctor_recorder pytest fixture, auto-discovered via [project.entry-points.pytest11]. Downstreams pip install flow-doctor and the fixture is available in any test file with no imports.

Morning-signal cutover example (from the plan)

from flow_doctor import FlowDoctor
from flow_doctor.notify import EmailNotifierConfig

fd = (
    FlowDoctor.builder("morning-signal")
    .add_notifier(EmailNotifierConfig(
        sender="x@y.com",
        recipients=["x@y.com"],
        smtp_password=os.environ["GMAIL_APP_PASSWORD"],
    ))
    .with_dedup(cooldown_minutes=60)
    .build()
)

Downstream consumer test (no imports required):

def test_pipeline_reports_db_errors(flow_doctor_recorder):
    run_pipeline_that_should_fail(flow_doctor_recorder)
    assert len(flow_doctor_recorder.reports) == 1
    assert flow_doctor_recorder.last.exc_type == "DBError"

Back-compat

  • flow_doctor.init(config_path=...) — unchanged. Alpha-engine, the 0.4.0 consumer, doesn't move.
  • NotifyChannelConfig — kept as the internal lingua franca that _init_notifiers consumes; typed configs convert via .to_channel_config().
  • load_config() — accepts already-constructed Pydantic instances in addition to dicts (so the builder hands typed sub-configs straight down without re-serializing).

Not in this PR (planned follow-ups)

  • BaseSettings env-var injection (pydantic-settings dep) — deferred so the dep trade-off can be decided separately.
  • PEP 702 @deprecated markers on init() + NotifyChannelConfig — Proposal 3.
  • py.typed marker + OTel mapping doc — Proposal 3.
  • Cut v0.5.0 to PyPI + cut over morning-signal — once Proposal 3 lands.

Test plan

  • pytest tests/ → 335/335 pass (296 baseline + 39 new across builder, Protocol/context, testing-plugin).
  • New tests cover: discriminated-union deserialization (TypeAdapter, ValidationError), list→CSV recipients normalization, Protocol runtime satisfaction for FlowDoctor and arbitrary stubs, contextvars nested-scope shadowing + post-exit reset, contextvar inheritance into report_async's worker thread, RecordingFlowDoctor ergonomics, pytest11 entry-point wiring.
  • Manual cutover smoke test on morning-signal — defer until Proposal 3 ships.

🤖 Generated with Claude Code

cipher813 and others added 5 commits May 13, 2026 12:18
Replaces all 11 @DataClass declarations in flow_doctor/core/config.py
with pydantic.BaseModel via a shared _ConfigModel base. Field names and
defaults are preserved so existing test fixtures and consumers (alpha-engine
calls flow_doctor.init(config_path=...)) keep working unchanged.

The migration is the foundation for the FlowDoctor.builder() fluent API
and Pydantic BaseSettings-driven env-var injection landing in subsequent
commits on this branch (per private/plug-and-play-260513.md Proposal 1).

load_config() gains pass-through branches that accept already-constructed
Pydantic instances in addition to dicts, so the upcoming builder can hand
typed sub-configs straight to it without re-serializing through YAML.

Adds pydantic>=2.0 to [project.dependencies]; alpha-engine + morning-signal
already run Pydantic 2.x per the plan's compat audit.

296/296 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Introduces the SOTA-facing entry point for new consumers (plan Proposal 1):

- flow_doctor/notify/configs.py defines SlackNotifierConfig,
  EmailNotifierConfig, GitHubNotifierConfig, S3NotifierConfig as Pydantic
  v2 models, exposed as the discriminated union NotifierConfig via
  Field(discriminator="type"). EmailNotifierConfig.recipients accepts
  either a CSV string or a list and normalizes via a field_validator
  on the way down to the legacy NotifyChannelConfig.

- flow_doctor/core/builder.py adds FlowDoctorBuilder with fluent
  add_notifier / with_repo / with_dedup / with_store / with_diagnosis /
  with_github / with_auto_fix / with_remediation / with_handler /
  with_dependencies methods plus build_config() and build(strict=True).

- FlowDoctor.builder(flow_name) classmethod is the recommended entry
  point for new code; flow_doctor.init(config_path=...) still works
  unchanged for 0.4.0 callers.

The new typed configs and builder are re-exported from the flow_doctor
and flow_doctor.notify package roots.

18 new tests cover: per-channel config -> legacy NotifyChannelConfig
round-trip, list-to-CSV recipients normalization, fluent chaining,
unspecified-section defaults, the morning-signal cutover use case,
and the discriminated-union deserialization path (TypeAdapter +
ValidationError for unknown channel types).

Suite: 314/314 pass (296 baseline + 18 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
First slice of Proposal 2 from the plan:

- flow_doctor/_protocol.py defines @runtime_checkable FlowDoctorProtocol
  declaring report() / guard() / monitor() / report_async() as the
  cross-version public contract. Consumers can now type-hint against
  the Protocol and swap in test doubles (RecordingFlowDoctor lands in
  the next commit) with mypy / isinstance verification.

- flow_doctor/core/_context.py defines per-task/-thread contextvars for
  flow_name + stage + arbitrary extras, exposed via flow_doctor.context().
  Inner scopes shadow outer ones; the active snapshot is merged into
  every report's context payload at _build_context() time. Deep
  call-stacks no longer need to thread context=... explicitly.

- FlowDoctor.report_async() coroutine runs the existing sync pipeline
  via asyncio.to_thread() so async callers don't block the event loop.
  contextvars are inherited across the thread boundary automatically
  via contextvars.copy_context() (asyncio.to_thread does this for free).

Suite: 322/322 pass (8 new). Tests cover: runtime Protocol satisfaction
for FlowDoctor + arbitrary stubs, contextvars propagation onto reports,
inner-scope shadowing, post-scope reset (no leaks across tests), and
contextvar inheritance into report_async's worker thread.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the testing-module half of Proposal 2:

- flow_doctor/testing/_recording.py defines ReportedIncident dataclass
  (error / severity / context / logs / message + exc_type/exc_message
  derived from BaseException + ambient_context snapshotted from any
  flow_doctor.context() scope at report time) and RecordingFlowDoctor
  which implements FlowDoctorProtocol with report() / report_async() /
  guard() / monitor() recording calls in-memory. Ergonomic helpers:
  .clear(), .last, .of_type(exc_name).

- flow_doctor/testing/_plugin.py exposes a `flow_doctor_recorder` pytest
  fixture (fresh per test).

- pyproject.toml registers the plugin via
  [project.entry-points.pytest11], so downstreams pip install flow-doctor
  and get the fixture auto-discovered with no imports in their tests.

13 new tests cover: Protocol satisfaction at runtime, exception/string
metadata capture, ambient flow_doctor.context() propagation onto the
recorded ambient_context dict, .clear()/.of_type()/.last helpers,
guard() and monitor() decorator behaviour, async report_async() round-
trip, fresh-per-test fixture isolation, and pytest11 entry-point wiring
(the fixture arrives without an explicit import in the consumer test).

Suite: 335/335 pass (322 prior + 13 new).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cipher813 cipher813 changed the title feat: Pydantic v2 config + FlowDoctor.builder() (plug-and-play Proposal 1) feat: plug-and-play v0.5.0 — Proposals 1 + 2 (Pydantic config, builder, Protocol, testing plugin) May 13, 2026
@cipher813 cipher813 marked this pull request as ready for review May 13, 2026 19:31
@cipher813 cipher813 merged commit fb25116 into main May 13, 2026
1 check passed
@cipher813 cipher813 deleted the feat/plug-and-play-plan branch May 13, 2026 19:31
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