From 315b2a302bf5fcc88183ab97ba578e465f9b4f16 Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Mon, 6 Jul 2026 16:00:15 +0200 Subject: [PATCH 1/2] feat(phase-step): add provisioning-phase step gating library Add the mpt-extension-contrib-phase-step module so a pipeline step can declare the provisioning phase it runs for and be skipped otherwise, replacing the ad-hoc phase if-branches duplicated across extension steps. The library reuses the SDK skip mechanism instead of extending it: gating lives in a step pre() hook that raises SkipStepError, which BasePipeline already logs and continues past. It ships require_phase (the guard), PhaseGatedStep (a thin BaseStep base binding one or more expected phases per instance), and advance_phase (persist the new phase + refresh the context). Phase values and the parameter external id stay out of the library: the id comes from the PhaseStepSettings protocol on the extension settings. "Phase" is used throughout instead of "status" to avoid confusion with the MPT order status (context.order.status); it matches the AWS source the code is extracted from (PhasesEnum / get_phase / set_phase). Include unit tests (100% coverage), self-discoverable docs, a CODEOWNERS entry, and the workspace wiring for the new module. Co-Authored-By: Claude Opus 4.8 --- .github/CODEOWNERS | 1 + AGENTS.md | 1 + README.md | 1 + make/common.mk | 2 +- phase-step/.copier-answers.yml | 4 + phase-step/AGENTS.md | 21 ++ phase-step/LICENSE | 201 ++++++++++++++++++ phase-step/README.md | 108 ++++++++++ phase-step/docs/architecture.md | 87 ++++++++ phase-step/docs/contributing.md | 21 ++ phase-step/docs/releases.md | 8 + phase-step/docs/testing.md | 26 +++ phase-step/docs/usage.md | 160 ++++++++++++++ .../phase_step/__init__.py | 13 ++ .../mpt_extension_contrib/phase_step/py.typed | 0 .../mpt_extension_contrib/phase_step/steps.py | 130 +++++++++++ phase-step/pyproject.toml | 27 +++ phase-step/tests/conftest.py | 30 +++ phase-step/tests/test_steps.py | 180 ++++++++++++++++ pyproject.toml | 3 + uv.lock | 19 ++ 21 files changed, 1042 insertions(+), 1 deletion(-) create mode 100644 phase-step/.copier-answers.yml create mode 100644 phase-step/AGENTS.md create mode 100644 phase-step/LICENSE create mode 100644 phase-step/README.md create mode 100644 phase-step/docs/architecture.md create mode 100644 phase-step/docs/contributing.md create mode 100644 phase-step/docs/releases.md create mode 100644 phase-step/docs/testing.md create mode 100644 phase-step/docs/usage.md create mode 100644 phase-step/mpt_extension_contrib/phase_step/__init__.py create mode 100644 phase-step/mpt_extension_contrib/phase_step/py.typed create mode 100644 phase-step/mpt_extension_contrib/phase_step/steps.py create mode 100644 phase-step/pyproject.toml create mode 100644 phase-step/tests/conftest.py create mode 100644 phase-step/tests/test_steps.py diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 8fdf164..f7284a0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -7,3 +7,4 @@ /custom-notifications/ @softwareone-platform/sirius /order-status/ @softwareone-platform/sirius /reports/ @softwareone-platform/sirius +/phase-step/ @softwareone-platform/sirius diff --git a/AGENTS.md b/AGENTS.md index a5b2edb..f6aced6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -39,6 +39,7 @@ Python import: mpt_extension_contrib. - [`shared/`](shared): internal reusable code exposed as `mpt_extension_contrib.shared` (distribution `mpt-extension-contrib-shared`). - [`order-status/`](order-status): public package exposed as `mpt_extension_contrib.order_status` (distribution `mpt-extension-contrib-order-status`). +- [`phase-step/`](phase-step): public package exposed as `mpt_extension_contrib.phase_step` (distribution `mpt-extension-contrib-phase-step`). - [`custom-notifications/`](custom-notifications): public package exposed as `mpt_extension_contrib.custom_notifications` (distribution `mpt-extension-contrib-custom-notifications`). - [`reports/`](reports): public package exposed as `mpt_extension_contrib.reports` (distribution `mpt-extension-contrib-reports`). - [`due-date/`](due-date): public package exposed as `mpt_extension_contrib.due_date` (distribution `mpt-extension-contrib-due-date`). diff --git a/README.md b/README.md index 2ff7d0d..2b53cee 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ of them share the `mpt_extension_contrib` namespace. | --- | --- | --- | --- | | `shared/` | `mpt-extension-contrib-shared` | `mpt_extension_contrib.shared` | internal | | `order-status/` | `mpt-extension-contrib-order-status` | `mpt_extension_contrib.order_status` | public | +| `phase-step/` | `mpt-extension-contrib-phase-step` | `mpt_extension_contrib.phase_step` | public | | `custom-notifications/` | `mpt-extension-contrib-custom-notifications` | `mpt_extension_contrib.custom_notifications` | public | | `reports/` | `mpt-extension-contrib-reports` | `mpt_extension_contrib.reports` | public | | `due-date/` | `mpt-extension-contrib-due-date` | `mpt_extension_contrib.due_date` | public | diff --git a/make/common.mk b/make/common.mk index 40d7f62..c689cf3 100644 --- a/make/common.mk +++ b/make/common.mk @@ -2,7 +2,7 @@ DC = docker compose -f compose.yaml RUN = $(DC) run --rm app RUN_IT = $(DC) run --rm -it app -PACKAGES := shared order-status custom-notifications reports due-date +PACKAGES := shared order-status phase-step custom-notifications reports due-date TARGETS := $(if $(pkg),$(pkg),$(PACKAGES)) LINT_TARGETS := $(if $(pkg),$(pkg),$(PACKAGES) tests scripts) TYPE_TARGETS := $(if $(pkg),$(pkg),$(PACKAGES) scripts) diff --git a/phase-step/.copier-answers.yml b/phase-step/.copier-answers.yml new file mode 100644 index 0000000..0333126 --- /dev/null +++ b/phase-step/.copier-answers.yml @@ -0,0 +1,4 @@ +# Managed by Copier — do not edit by hand. Run `copier update` to refresh. +_src_path: /workspace/scripts/templates/module +module: phase-step + diff --git a/phase-step/AGENTS.md b/phase-step/AGENTS.md new file mode 100644 index 0000000..25fb8d0 --- /dev/null +++ b/phase-step/AGENTS.md @@ -0,0 +1,21 @@ +# AGENTS.md + +This module is the `phase-step` contrib package. + +Read in this order: + +1. [README.md](README.md) for the module purpose. +2. [docs/usage.md](docs/usage.md) for installing and using the module. +3. [docs/architecture.md](docs/architecture.md) for the public API boundary. +4. [docs/contributing.md](docs/contributing.md) before changing this module. +5. [docs/testing.md](docs/testing.md) before changing tests. +6. [docs/releases.md](docs/releases.md) before releasing this module. +7. [../AGENTS.md](../AGENTS.md) for repository-wide rules and validation commands. + +Operational guidance: + +- Keep the public API under `mpt_extension_contrib.phase_step`. +- Keep the library free of product-specific phase values and parameter ids; + they come from the extension via `PhaseStepSettings`. +- Add tests under [`tests/`](tests) for every behavior change. +- Run `make check-all pkg=phase-step` from the repository root while iterating. diff --git a/phase-step/LICENSE b/phase-step/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/phase-step/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/phase-step/README.md b/phase-step/README.md new file mode 100644 index 0000000..5881b2f --- /dev/null +++ b/phase-step/README.md @@ -0,0 +1,108 @@ +# mpt-extension-contrib-phase-step + +Shared **provisioning-phase gating** for SoftwareONE MPT extensions built on the +Extension SDK. A step declares the single provisioning phase it runs for; when +the order's phase parameter holds a different value the step is skipped and the +pipeline records the skip, instead of the step deciding to no-op inside its own +body. + +It replaces ad-hoc phase checks inside individual extension steps with one +reusable gating mechanism shared across pipelines. + +See [AGENTS.md](AGENTS.md) for the module documentation map. + +## Install + +```bash +pip install mpt-extension-contrib-phase-step +``` + +Requires the Extension SDK (`mpt-extension-sdk >= 6.3, < 7`), which is pulled in as a +dependency. + +## Public API + +`mpt_extension_contrib.phase_step` exposes a base step, two functions, and one +settings contract: + +| Object | Purpose | +| --- | --- | +| `PhaseGatedStep(expected_phases)` | Base step that runs only when the order phase is one of `expected_phases` (a phase or a list of phases); subclass and implement `process()`. | +| `require_phase(context, expected_phases)` | Guard for a hand-written `pre()`: skip the step unless the phase is one of `expected_phases`. | +| `advance_phase(context, phase)` | Persist a new phase on the order (and refresh the context) to move the flow forward; call it from `process()`. | +| `PhaseStepSettings` | `Protocol` describing the settings the API reads. | + +## Design in one line + +The SDK already skips a step when its `pre()` raises `SkipStepError` (the +pipeline logs the skip and continues). This library adds only the missing +convention — read the phase parameter, compare, raise — as a function and a +thin base step, and keeps phase values and the parameter id out of the library. +No decorators or wrappers: gating is expressed by subclassing and, when needed, +an explicit `await super().pre(context)`. + +## Usage + +Expose the phase parameter external id in the extension settings: + +```python +from dataclasses import dataclass +from typing import Self, override + +from mpt_extension_sdk.settings.extension import BaseExtensionSettings + +from mpt_extension_contrib.phase_step import PhaseStepSettings + + +@dataclass(frozen=True) +class ExtensionSettings(BaseExtensionSettings, PhaseStepSettings): + phase_parameter: str = "phase" + + @override + @classmethod + def load(cls) -> Self: + return cls() +``` + +Seed the phase before the first gated step: `PhaseGatedStep.pre()` runs before +`process()`, so a first step with an unset phase would skip before it could +initialize it. Set an initial value via a product default or an ungated +initializer step. + +Gate a step by subclassing `PhaseGatedStep` and binding the phase when the +pipeline is composed: + +```python +from typing import override + +from mpt_extension_sdk.pipeline import BasePipeline, BaseStep, OrderContext + +from mpt_extension_contrib.phase_step import PhaseGatedStep + + +class CreateSubscription(PhaseGatedStep): + @override + async def process(self, context: OrderContext) -> None: + # runs only while the phase parameter equals the bound value + ... + + +class PurchasePipeline(BasePipeline): + @property + def steps(self) -> list[BaseStep]: + return [ + CreateSubscription("createSubscription"), + # ... more phase-gated steps ... + ] +``` + +See [Usage](docs/usage.md) for a step that adds its own `pre()` guard and for +advancing the phase. + +## Documentation + +- [Usage](docs/usage.md) — install, configure, gate steps, advance the phase +- [Architecture](docs/architecture.md) +- [Contributing](docs/contributing.md) +- [Testing](docs/testing.md) +- [Releases](docs/releases.md) diff --git a/phase-step/docs/architecture.md b/phase-step/docs/architecture.md new file mode 100644 index 0000000..b982bb1 --- /dev/null +++ b/phase-step/docs/architecture.md @@ -0,0 +1,87 @@ +# Architecture + +`mpt-extension-contrib-phase-step` provides provisioning-phase gating for +Extension SDK pipeline steps: a step runs only while the order's phase +parameter equals the value the step declares. + +## Public API boundary + +The package root `mpt_extension_contrib.phase_step` exports only the +integration surface: + +```python +from mpt_extension_contrib.phase_step import ( + PhaseGatedStep, + require_phase, + advance_phase, + PhaseStepSettings, +) +``` + +- `PhaseGatedStep` is an `mpt_extension_sdk` pipeline step (`BaseStep`) whose + `pre()` skips the step unless the phase matches; subclasses implement + `process()`. The expected phase is a constructor argument — a single phase + or a non-empty list of phases. +- `require_phase(context, expected_phases)` is the guard + `PhaseGatedStep.pre()` calls; it is also available to a step that adds its own + `pre()`. It reads the phase with `ParameterBag.get_fulfillment_value(...)`. +- `advance_phase(context, phase)` is the write counterpart to gating: it sets + the phase on an updated `ParameterBag` (`with_fulfillment_value`) and persists + it through `mpt_api_service`. Steps call it from `process()`; the base class + never writes the phase itself. +- `PhaseStepSettings` is a `Protocol` declaring the required setting, + `phase_parameter: str`. Extensions expose this field on `ExtensionSettings`; + inheriting `PhaseStepSettings` makes the contract explicit and type-checked. + +The `steps` module is internal; import from the package root. + +## Design + +- **Reuse the SDK, do not extend it.** The SDK `BaseStep.run()` awaits `pre()` + first, and `BasePipeline` already catches `SkipStepError`, logs the skip + through `on_step_skipped`, and continues with the next step. The whole + skip-and-continue control flow lives in the SDK, so this library adds only the + phase-check convention, not a new base pipeline or error type. +- **No decorators, wrappers, or mixins.** Gating is expressed by subclassing + `PhaseGatedStep` (or calling `require_phase` in a `pre()`). A subclass that + needs extra `pre()` logic calls `await super().pre(context)` explicitly, so the + check order is visible in the source instead of resolved by MRO. This keeps the + facilitation code smaller than the logic it facilitates and preserves each + step's own `name` for logging and tracing. +- **Phase is bound per instance.** The expected phase is a constructor + argument (`CreateSubscription("createSubscription")`), so it lives in the + pipeline definition rather than baked into the class body. +- **Configuration has one source.** The phase parameter external id is read from + `context.ext_settings.phase_parameter`. No phase vocabulary or parameter id + is baked into the library — phase values differ per extension and stay in the + extension. +- **Order snapshots stay immutable.** `advance_phase` never mutates the current + snapshot in place: it builds a new `ParameterBag` with `with_fulfillment_value(...)`, + persists it through `context.mpt_api_service.orders.update(...)`, and replaces + the snapshot only through the sanctioned `context.refresh_order()` (a fresh + fetch), not by editing `context.order` directly. + +## Behaviour + +- `PhaseGatedStep(expected_phases)` — `pre()` reads the phase parameter and + raises `SkipStepError` unless it is one of `expected_phases`; otherwise + `process()` runs. `expected_phases` is a single phase or a non-empty list of + non-empty phases, validated at construction. +- `require_phase(context, expected_phases)` — raises `SkipStepError` when the + current phase (including unset) is not one of `expected_phases`; returns + `None` on a match. +- `advance_phase(context, phase)` — writes `phase` through + `context.mpt_api_service.orders.update(...)`, then refreshes `context.order` + (`context.refresh_order()`) so a later gated step in the same run reads the new + phase and gates correctly. The caller decides when to call it. + +## Out of scope + +- Creating or seeding the phase parameter — the extension owns it (see + [Usage](usage.md)). +- Deciding *when* to advance the phase — `advance_phase` performs the write, + but the step chooses the moment and the target phase in `process()`. +- Order status transitions such as fail/query/complete (the MPT order status, + distinct from the provisioning phase this library gates on) — those are + declared on `context.order_state` and applied by pipeline hooks, per the SDK + pipeline-steps guideline. diff --git a/phase-step/docs/contributing.md b/phase-step/docs/contributing.md new file mode 100644 index 0000000..f9dd9e0 --- /dev/null +++ b/phase-step/docs/contributing.md @@ -0,0 +1,21 @@ +# Contributing + +Keep the public API under `mpt_extension_contrib.phase_step` limited to +`PhaseGatedStep`, `require_phase`, `advance_phase`, and the +`PhaseStepSettings` protocol. Import them from the package root; the `steps` +submodule is implementation detail. Update +[architecture.md](architecture.md) when the public API changes. + +Keep the library free of product-specific phase values and parameter ids: +the expected phases are constructor/argument input, and the phase parameter +external id comes from `PhaseStepSettings` (`context.ext_settings`). Do not add +a phase enum, a default parameter name, or any extension business logic. + +Do not reimplement the pipeline control flow. Skipping relies on the SDK: raise +`SkipStepError` from a `pre()` hook and let `BasePipeline` log and continue. A +step that also needs its own `pre()` calls `await super().pre(context)` first; +prefer this explicit subclassing over decorators, wrappers, or mixins. + +Use package-scoped validation with `pkg=phase-step`. Follow the +repository-wide [contributing workflow](../../docs/contributing.md) for +dependency changes, validation commands, and pre-commit expectations. diff --git a/phase-step/docs/releases.md b/phase-step/docs/releases.md new file mode 100644 index 0000000..0e9fde6 --- /dev/null +++ b/phase-step/docs/releases.md @@ -0,0 +1,8 @@ +# Releases + +`mpt-extension-contrib-phase-step` is released independently with a +`phase-step-` tag. + +Release this package only when its own API or declared dependencies change. + +Follow the repository-wide [release workflow](../../docs/releases.md). diff --git a/phase-step/docs/testing.md b/phase-step/docs/testing.md new file mode 100644 index 0000000..9bb28a7 --- /dev/null +++ b/phase-step/docs/testing.md @@ -0,0 +1,26 @@ +# Testing + +Tests for `mpt-extension-contrib-phase-step` live in +[`../tests/test_steps.py`](../tests/test_steps.py) and cover `require_phase`, +`PhaseGatedStep`, and `advance_phase` against a real `OrderContext` (built by +the shared `order_context_factory` fixture, with an autospec'd +`mpt_api_service`): + +- a match with a single phase and with one of a list of phases runs the step; + a mismatch and an unset phase skip it; +- the constructor rejects an empty phase, an empty list, and an empty phase + inside a list; +- a subclass `pre()` runs its own guard only after the inherited phase check + passes; +- `advance_phase` persists the new phase through `mpt_api_service.orders.update` + with the parameter external id taken from the settings. + +Assert step execution with a mock, not an instance flag: patch `process` with +`mocker.patch.object(step, "process")` (auto-detected as an `AsyncMock`) and +check `assert_awaited_once()` / `assert_not_awaited()`. The phase parameter is +built with the local `phase_parameter_factory` fixture ([conftest.py](../tests/conftest.py)), +whose external id (`phase`) matches the test `ExtensionSettings`. + +Use package-scoped test commands with `pkg=phase-step`. Coverage must stay at +the repository threshold. See the repository-wide +[testing strategy](../../docs/testing.md). diff --git a/phase-step/docs/usage.md b/phase-step/docs/usage.md new file mode 100644 index 0000000..e0f2b9c --- /dev/null +++ b/phase-step/docs/usage.md @@ -0,0 +1,160 @@ +# Usage + +This guide covers installing the library, configuring the phase parameter, +gating steps on the provisioning phase, and advancing the phase. + +## 1. Install + +```bash +pip install mpt-extension-contrib-phase-step +``` + +Requires the Extension SDK (`mpt-extension-sdk >= 6.3, < 7`), pulled in as a +dependency. + +## 2. Provide the phase parameter + +The library does **not** create the phase parameter — it is a prerequisite the +extension owns. Define a single-choice fulfillment parameter on the product +whose value is the current provisioning phase (for example `phase`), and set an +initial value in the first pipeline step so gated steps have something to match. + +The phase is the extension's own fulfillment sub-state (e.g. `createSubscription`, +`checkOnboardStatus`), tracked in this parameter — it is **not** the MPT order +status (`Processing`, `Querying`, `Completed`, on `context.order.status`). Gating +is about *where in your fulfillment flow* the order is, not its platform status. + +The helpers read the parameter's external id from +`context.ext_settings.phase_parameter`. Inherit `PhaseStepSettings` on the +extension settings so the contract is type-checked: + +```python +from dataclasses import dataclass +from typing import Self, override + +from mpt_extension_sdk.settings.extension import BaseExtensionSettings + +from mpt_extension_contrib.phase_step import PhaseStepSettings + + +@dataclass(frozen=True) +class ExtensionSettings(BaseExtensionSettings, PhaseStepSettings): + phase_parameter: str = "phase" + + @override + @classmethod + def load(cls) -> Self: + return cls() +``` + +## 3. Gate your own step + +Subclass `PhaseGatedStep` and implement `process()`. The expected phase is +bound per instance when the pipeline is composed, so the same class can be +reused later with a different phase. + +```python +from typing import override + +from mpt_extension_sdk.pipeline import BasePipeline, BaseStep, OrderContext + +from mpt_extension_contrib.phase_step import PhaseGatedStep + + +class CreateSubscription(PhaseGatedStep): + @override + async def process(self, context: OrderContext) -> None: + # runs only while the phase parameter equals the bound value + ... + + +class PurchasePipeline(BasePipeline): + @property + def steps(self) -> list[BaseStep]: + return [ + CreateSubscription("createSubscription"), + # ... more phase-gated steps ... + ] +``` + +To run a step for any of several phases, pass a list instead of a single +value — `CreateSubscription(["createSubscription", "createExistingSubscription"])`; +the step runs while the phase equals any of them. + +When the phase does not match, `pre()` raises `SkipStepError`; the SDK pipeline +logs `Step CreateSubscription skipped - reason: …` and continues with the next +step. `process()` never runs and stays focused on the actual work. + +### A step that needs its own `pre()` + +Override `pre()` and call `super().pre(context)` first, so the phase check runs +before the step's own guard and the order is explicit in the method body: + +```python +from typing import override + +from mpt_extension_sdk.errors.step import SkipStepError +from mpt_extension_sdk.pipeline import OrderContext + +from mpt_extension_contrib.phase_step import PhaseGatedStep + + +class CreateSubscription(PhaseGatedStep): + @override + async def pre(self, context: OrderContext) -> None: + await super().pre(context) # skip unless the phase matches + # `_subscription_already_exists` is your extension's own check + if _subscription_already_exists(context.order): + raise SkipStepError("subscription already created") +``` + +## 4. Advance the phase + +Gating and advancing are separate responsibilities: gating is a read the base +step does in `pre()`, while advancing the phase is a write the step makes +explicitly in `process()`, at the point it decides to move forward. Use +`advance_phase`: + +```python +from typing import override + +from mpt_extension_sdk.pipeline import OrderContext + +from mpt_extension_contrib.phase_step import PhaseGatedStep, advance_phase + + +class CreateSubscription(PhaseGatedStep): + @override + async def process(self, context: OrderContext) -> None: + # ... do the work ... + await advance_phase(context, "completed") +``` + +`advance_phase(context, phase)` writes the phase fulfillment parameter (named +by the `phase_parameter` setting), persists it through `mpt_api_service`, and +then refreshes `context.order`. + +### It refreshes `context.order` after the write + +The refresh keeps the persisted and in-memory phase consistent: a later gated +step in the same pipeline run reads the new phase from `context.order` and gates +correctly, rather than seeing the stale value and being skipped. This is the +read-after-write case the SDK pipeline-steps guideline refreshes for — gated +steps read from `context.order`, so a step that advances the phase must leave the +snapshot updated for the steps that follow it in the same run. + +The refresh is one extra fetch. A caller that must avoid it — advancing as the +very last step, or batching the phase into a larger order update — can drop to +the lower level and skip the helper: + +```python +external_id = context.ext_settings.phase_parameter +parameters = context.order.parameters.with_fulfillment_value(external_id, "completed") +await context.mpt_api_service.orders.update( + context.order_id, {"parameters": parameters.to_dict()} +) # persists without refreshing; only when nothing downstream reads it +``` + +An "already done, move on" case is expressed the same way: advance the phase in +`pre()` and raise `SkipStepError` — the SDK handles the skip, so no special error +type is needed. diff --git a/phase-step/mpt_extension_contrib/phase_step/__init__.py b/phase-step/mpt_extension_contrib/phase_step/__init__.py new file mode 100644 index 0000000..111ebc5 --- /dev/null +++ b/phase-step/mpt_extension_contrib/phase_step/__init__.py @@ -0,0 +1,13 @@ +from mpt_extension_contrib.phase_step.steps import ( + PhaseGatedStep, + PhaseStepSettings, + advance_phase, + require_phase, +) + +__all__ = [ + "PhaseGatedStep", + "PhaseStepSettings", + "advance_phase", + "require_phase", +] diff --git a/phase-step/mpt_extension_contrib/phase_step/py.typed b/phase-step/mpt_extension_contrib/phase_step/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/phase-step/mpt_extension_contrib/phase_step/steps.py b/phase-step/mpt_extension_contrib/phase_step/steps.py new file mode 100644 index 0000000..1d9d9ff --- /dev/null +++ b/phase-step/mpt_extension_contrib/phase_step/steps.py @@ -0,0 +1,130 @@ +from typing import Annotated, Protocol, cast, override + +from mpt_extension_sdk.errors.step import SkipStepError +from mpt_extension_sdk.pipeline import BaseStep, OrderContext +from pydantic import Field, StringConstraints, validate_call + +ExpectedPhase = Annotated[str, StringConstraints(min_length=1)] +ExpectedPhases = ExpectedPhase | Annotated[list[ExpectedPhase], Field(min_length=1)] + + +class PhaseStepSettings(Protocol): + """Extension settings contract required by the phase gating helpers. + + An extension's ``ExtensionSettings`` satisfies this structurally by exposing + a ``phase_parameter`` string; it may also inherit this protocol to have + the contract checked explicitly. + """ + + phase_parameter: str + + +def _phase_parameter(context: OrderContext) -> str: + """Return the phase parameter external id from the extension settings.""" + return cast(PhaseStepSettings, context.ext_settings).phase_parameter + + +@validate_call +def _normalize_phases(expected_phases: ExpectedPhases) -> list[str]: + """Return the expected phases as a non-empty list of non-empty strings.""" + if isinstance(expected_phases, str): + return [expected_phases] + return list(expected_phases) + + +def _format_expected(phases: list[str]) -> str: + """Render the expected phases for a skip message.""" + if len(phases) == 1: + return f"'{phases[0]}'" + joined = ", ".join(f"'{phase}'" for phase in phases) + return f"one of {joined}" + + +def require_phase(context: OrderContext, expected_phases: ExpectedPhases) -> None: + """Skip the current step unless the order phase is one of the expected ones. + + Reads the provisioning phase from the fulfillment parameter named by the + extension settings field ``phase_parameter`` and checks it against + ``expected_phases``. Call it from a step ``pre()`` hook; the pipeline + handles the raised skip and continues with the next step. + + Args: + context: The order pipeline context. + expected_phases: A phase, or a non-empty list of phases, the step + runs for. + + Raises: + SkipStepError: When the current phase is not one of the expected ones. + """ + phases = _normalize_phases(expected_phases) + current_phase = context.order.parameters.get_fulfillment_value(_phase_parameter(context)) + if current_phase not in phases: + raise SkipStepError( + f"current phase is '{current_phase}', expected {_format_expected(phases)}" + ) + + +async def advance_phase(context: OrderContext, phase: str) -> None: + """Persist a new provisioning phase on the order. + + Convenience for the common "advance the phase" write: it sets the phase + fulfillment parameter (named by the extension settings field + ``phase_parameter``) on an updated ``ParameterBag``, persists it through + ``context.mpt_api_service.orders.update``, and then refreshes + ``context.order``. Call it from ``process()`` at the point the step decides + to move forward; gating (the read) stays in ``pre()``. + + It refreshes ``context.order`` (via ``context.refresh_order()``) after the + write so the persisted and in-memory phase stay consistent: a later gated + step in the same pipeline run reads the new phase from ``context.order`` and + gates correctly, instead of seeing the stale value and being skipped. This is + the read-after-write case the SDK pipeline-steps guideline refreshes for. The + refresh is one extra fetch; a caller that must avoid it (advancing as the last + step, or batching the phase into a larger order update) can drop to + ``context.order.parameters.with_fulfillment_value(...)`` plus its own + ``orders.update`` instead. + + Args: + context: The order pipeline context. + phase: The provisioning phase to store. + """ + parameter_bag = context.order.parameters.with_fulfillment_value( + _phase_parameter(context), phase + ) + await context.mpt_api_service.orders.update( + context.order_id, + {"parameters": parameter_bag.to_dict()}, + ) + await context.refresh_order() + + +class PhaseGatedStep(BaseStep): + """Base step that runs only for one of a set of provisioning phases. + + A gated drop-in replacement for the SDK ``BaseStep``: subclass it and + implement ``process()`` for a phase-gated step, the same way you subclass + ``BaseStep`` for a plain one. The expected phases are a constructor + argument, so they live in the pipeline definition rather than the class + body. ``pre()`` skips the step (via :func:`require_phase`) unless the order + phase matches. + + A subclass that needs its own ``pre()`` logic overrides it and calls + ``await super().pre(context)`` explicitly, making the check order visible + in the method body. + + The phase parameter external id comes from the extension settings field + ``phase_parameter`` (``context.ext_settings``). + """ + + def __init__(self, expected_phases: ExpectedPhases) -> None: + self._expected_phases = _normalize_phases(expected_phases) + + @property + def expected_phases(self) -> list[str]: + """The provisioning phases this step runs for.""" + return list(self._expected_phases) + + @override + async def pre(self, context: OrderContext) -> None: + """Skip the step unless the order phase is one of the expected ones.""" + require_phase(context, self._expected_phases) diff --git a/phase-step/pyproject.toml b/phase-step/pyproject.toml new file mode 100644 index 0000000..28efe55 --- /dev/null +++ b/phase-step/pyproject.toml @@ -0,0 +1,27 @@ +[project] +name = "mpt-extension-contrib-phase-step" +version = "0.0.0" +description = "Provisioning-phase gating for MPT Extension SDK pipeline steps." +readme = "README.md" +requires-python = ">=3.12" +dependencies = [ + "mpt-extension-sdk>=6.3,<7", + "pydantic>=2.13,<3", +] + +[dependency-groups] +dev = [] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[tool.hatch.build.targets.wheel] +packages = ["mpt_extension_contrib"] + +[tool.hatch.build.targets.sdist] +include = [ + "LICENSE", + "mpt_extension_contrib/phase_step", + "README.md", +] diff --git a/phase-step/tests/conftest.py b/phase-step/tests/conftest.py new file mode 100644 index 0000000..f061da2 --- /dev/null +++ b/phase-step/tests/conftest.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass +from typing import override + +import pytest +from mpt_extension_contrib.phase_step import PhaseStepSettings +from mpt_extension_sdk.models.parameter import ParameterValue +from mpt_extension_sdk.settings.extension import BaseExtensionSettings + + +@dataclass(frozen=True) +class PhaseStepExtensionSettings(BaseExtensionSettings, PhaseStepSettings): + phase_parameter: str = "phase" + + @override + @classmethod + def load(cls): + return cls() + + +@pytest.fixture +def extension_settings(): + return PhaseStepExtensionSettings() + + +@pytest.fixture +def phase_parameter_factory(): + def factory(stored_value=None): + return ParameterValue(external_id="phase", value=stored_value) + + return factory diff --git a/phase-step/tests/test_steps.py b/phase-step/tests/test_steps.py new file mode 100644 index 0000000..8a28e42 --- /dev/null +++ b/phase-step/tests/test_steps.py @@ -0,0 +1,180 @@ +from typing import override + +import pytest +from mpt_extension_contrib.phase_step import ( + PhaseGatedStep, + advance_phase, + require_phase, +) +from mpt_extension_sdk.errors.step import SkipStepError +from mpt_extension_sdk.pipeline import OrderContext +from pydantic import ValidationError + +EXPECTED_PHASE = "createSubscription" +OTHER_PHASE = "checkOnboardStatus" +SECOND_PHASE = "createExistingSubscription" + + +class SampleGatedStep(PhaseGatedStep): + """Concrete gated step whose ``process`` is patched with a mock in tests.""" + + @override + async def process(self, context: OrderContext) -> None: + """No-op; tests patch this to assert whether it was awaited.""" + + +class SampleGatedStepWithPre(SampleGatedStep): + """Gated step with its own ``pre`` guard, run after the phase check.""" + + def __init__(self, expected_phases, after_super_pre) -> None: + super().__init__(expected_phases) + self._after_super_pre = after_super_pre + + @override + async def pre(self, context: OrderContext) -> None: + await super().pre(context) + await self._after_super_pre(context) + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (EXPECTED_PHASE, EXPECTED_PHASE), + (SECOND_PHASE, [EXPECTED_PHASE, SECOND_PHASE]), + ], +) +def test_require_phase_passes_when_matching( + order_context_factory, phase_parameter_factory, stored, expected +): + context = order_context_factory(fulfillment=[phase_parameter_factory(stored)]) + + result = require_phase(context, expected) + + assert result is None + + +@pytest.mark.parametrize( + ("stored", "expected", "match"), + [ + (OTHER_PHASE, EXPECTED_PHASE, r"checkOnboardStatus.*createSubscription"), + (OTHER_PHASE, [EXPECTED_PHASE, SECOND_PHASE], r"one of"), + (None, EXPECTED_PHASE, None), + ], +) +def test_require_phase_skips_when_not_matching( + order_context_factory, phase_parameter_factory, stored, expected, match +): + fulfillment = [] if stored is None else [phase_parameter_factory(stored)] + context = order_context_factory(fulfillment=fulfillment) + + with pytest.raises(SkipStepError, match=match): + require_phase(context, expected) + + +@pytest.mark.parametrize("invalid", ["", [], ["createSubscription", ""]]) +def test_gated_step_rejects_invalid_phase(invalid): + with pytest.raises(ValidationError): + SampleGatedStep(invalid) + + +@pytest.mark.parametrize( + ("argument", "expected"), + [ + (EXPECTED_PHASE, [EXPECTED_PHASE]), + ([EXPECTED_PHASE, SECOND_PHASE], [EXPECTED_PHASE, SECOND_PHASE]), + ], +) +def test_gated_step_exposes_phases(argument, expected): + step = SampleGatedStep(argument) + + result = step.expected_phases + + assert result == expected + + +@pytest.mark.parametrize( + ("stored", "expected"), + [ + (EXPECTED_PHASE, EXPECTED_PHASE), + (SECOND_PHASE, [EXPECTED_PHASE, SECOND_PHASE]), + ], +) +async def test_gated_step_processes_when_matching( + order_context_factory, phase_parameter_factory, mocker, stored, expected +): + context = order_context_factory(fulfillment=[phase_parameter_factory(stored)]) + step = SampleGatedStep(expected) + process = mocker.patch.object(step, "process") + + await step.run(context) + + process.assert_awaited_once() + + +@pytest.mark.parametrize("stored", [OTHER_PHASE, None]) +async def test_gated_step_skips_when_not_matching( + order_context_factory, phase_parameter_factory, mocker, stored +): + fulfillment = [] if stored is None else [phase_parameter_factory(stored)] + context = order_context_factory(fulfillment=fulfillment) + step = SampleGatedStep(EXPECTED_PHASE) + process = mocker.patch.object(step, "process") + + with pytest.raises(SkipStepError): + await step.run(context) + + process.assert_not_awaited() + + +async def test_subclass_pre_runs_after_check( + order_context_factory, phase_parameter_factory, mocker +): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + after_super_pre = mocker.AsyncMock() + step = SampleGatedStepWithPre(EXPECTED_PHASE, after_super_pre) + + await step.run(context) + + after_super_pre.assert_awaited_once() + + +async def test_subclass_pre_skipped_on_mismatch( + order_context_factory, phase_parameter_factory, mocker +): + context = order_context_factory(fulfillment=[phase_parameter_factory(OTHER_PHASE)]) + after_super_pre = mocker.AsyncMock() + step = SampleGatedStepWithPre(EXPECTED_PHASE, after_super_pre) + + with pytest.raises(SkipStepError): + await step.run(context) + + after_super_pre.assert_not_awaited() + + +async def test_advance_phase_persists(order_context_factory, phase_parameter_factory): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + + await advance_phase(context, "completed") + + orders.update.assert_awaited_once() + + +async def test_advance_phase_writes_new_phase(order_context_factory, phase_parameter_factory): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + order_id = context.order_id + expected = context.order.parameters.with_fulfillment_value("phase", "completed").to_dict() + + await advance_phase(context, "completed") + + orders.update.assert_awaited_once_with(order_id, {"parameters": expected}) + + +async def test_advance_phase_refreshes_order(order_context_factory, phase_parameter_factory): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + + await advance_phase(context, "completed") + + orders.get_by_id.assert_awaited_once() diff --git a/pyproject.toml b/pyproject.toml index 19fe36a..783b4eb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,6 +2,7 @@ members = [ "shared", "order-status", + "phase-step", "custom-notifications", "reports", "due-date", @@ -138,6 +139,7 @@ testpaths = [ "scripts/tests", "shared/tests", "order-status/tests", + "phase-step/tests", "custom-notifications/tests", "reports/tests", "due-date/tests", @@ -213,6 +215,7 @@ local_partial_types = true mypy_path = [ "shared", "order-status", + "phase-step", "custom-notifications", "reports", "due-date", diff --git a/uv.lock b/uv.lock index 031d45d..785295a 100644 --- a/uv.lock +++ b/uv.lock @@ -13,6 +13,7 @@ members = [ "mpt-extension-contrib-custom-notifications", "mpt-extension-contrib-due-date", "mpt-extension-contrib-order-status", + "mpt-extension-contrib-phase-step", "mpt-extension-contrib-reports", "mpt-extension-contrib-shared", ] @@ -1562,6 +1563,24 @@ requires-dist = [{ name = "mpt-extension-sdk", specifier = ">=6.3,<7" }] [package.metadata.requires-dev] dev = [] +[[package]] +name = "mpt-extension-contrib-phase-step" +version = "0.0.0" +source = { editable = "phase-step" } +dependencies = [ + { name = "mpt-extension-sdk" }, + { name = "pydantic" }, +] + +[package.metadata] +requires-dist = [ + { name = "mpt-extension-sdk", specifier = ">=6.3,<7" }, + { name = "pydantic", specifier = ">=2.13,<3" }, +] + +[package.metadata.requires-dev] +dev = [] + [[package]] name = "mpt-extension-contrib-reports" version = "0.0.0" From e9ce0da72165e556b6049650b4f3834e0b5d477b Mon Sep 17 00:00:00 2001 From: Lukasz Lancucki Date: Wed, 15 Jul 2026 16:35:14 +0100 Subject: [PATCH 2/2] feat(phase-step): add phase_gate_step to gate an existing step class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `phase_gate_step(cls, expected_phases, next_phase=None)`, which returns a runtime subclass of an existing `BaseStep` class that runs the phase check in `pre()` before the wrapped step's own `pre()`. It covers the case where a step class cannot be changed to subclass `PhaseGatedStep` directly; prefer `PhaseGatedStep` when you own the step. When `next_phase` is given, the wrapped step advances the order to that phase (via `advance_phase`) after its `process()` completes successfully — the simple linear transition. For a next phase that depends on the step's own logic, subclass `PhaseGatedStep` and advance explicitly. It lives in its own module (`phase_gate.py`) and carries a scoped `# type: ignore[misc, valid-type]` on the subclass line because the base class is only known at runtime (mypy cannot type a dynamic base), plus a `# noqa: WPS431` for the in-function subclass. Co-Authored-By: Claude Opus 4.8 --- .../phase_step/__init__.py | 2 + .../phase_step/phase_gate.py | 58 ++++++++++++ phase-step/tests/test_phase_gate.py | 93 +++++++++++++++++++ 3 files changed, 153 insertions(+) create mode 100644 phase-step/mpt_extension_contrib/phase_step/phase_gate.py create mode 100644 phase-step/tests/test_phase_gate.py diff --git a/phase-step/mpt_extension_contrib/phase_step/__init__.py b/phase-step/mpt_extension_contrib/phase_step/__init__.py index 111ebc5..726852b 100644 --- a/phase-step/mpt_extension_contrib/phase_step/__init__.py +++ b/phase-step/mpt_extension_contrib/phase_step/__init__.py @@ -1,3 +1,4 @@ +from mpt_extension_contrib.phase_step.phase_gate import phase_gate_step from mpt_extension_contrib.phase_step.steps import ( PhaseGatedStep, PhaseStepSettings, @@ -9,5 +10,6 @@ "PhaseGatedStep", "PhaseStepSettings", "advance_phase", + "phase_gate_step", "require_phase", ] diff --git a/phase-step/mpt_extension_contrib/phase_step/phase_gate.py b/phase-step/mpt_extension_contrib/phase_step/phase_gate.py new file mode 100644 index 0000000..9dbf8c7 --- /dev/null +++ b/phase-step/mpt_extension_contrib/phase_step/phase_gate.py @@ -0,0 +1,58 @@ +from typing import override + +from mpt_extension_contrib.phase_step.steps import ( + ExpectedPhases, + _normalize_phases, # noqa: PLC2701 # same-package internal helper + advance_phase, + require_phase, +) +from mpt_extension_sdk.pipeline import BaseStep, OrderContext + + +def phase_gate_step( + cls: type[BaseStep], + expected_phases: ExpectedPhases, + next_phase: str | None = None, +) -> type[BaseStep]: + """Return a subclass of ``cls`` gated on ``expected_phases``. + + Use it to phase-gate a step class that cannot subclass :class:`PhaseGatedStep` + directly (for example a step defined elsewhere). The returned subclass runs + the phase check in ``pre()`` before the wrapped step's own ``pre()``. + + When ``next_phase`` is given, the returned step advances the order to that + phase (via :func:`advance_phase`) after the wrapped step's ``process()`` + completes successfully. This covers the simple linear transition; for a next + phase that depends on the step's own logic, subclass :class:`PhaseGatedStep` + and advance explicitly inside ``process()`` instead. + + Prefer subclassing :class:`PhaseGatedStep` when you own the step; this helper + exists for the case where you only have the class to wrap. + + Args: + cls: The step class to gate. + expected_phases: A phase, or a non-empty list of phases, the step runs for. + next_phase: The phase to advance to after a successful ``process()``, or + ``None`` to leave the phase unchanged. + + Returns: + A ``cls`` subclass that skips unless the order phase matches. + """ + phases = _normalize_phases(expected_phases) + + # ``cls`` is only known at runtime, so mypy cannot verify the base class. + class _Gated(cls): # type: ignore[misc, valid-type] # noqa: WPS431 + @override + async def pre(self, context: OrderContext) -> None: + require_phase(context, phases) + await super().pre(context) + + @override + async def process(self, context: OrderContext) -> None: + await super().process(context) + if next_phase is not None: + await advance_phase(context, next_phase) + + _Gated.__name__ = cls.__name__ + _Gated.__qualname__ = cls.__qualname__ + return _Gated diff --git a/phase-step/tests/test_phase_gate.py b/phase-step/tests/test_phase_gate.py new file mode 100644 index 0000000..5766ac1 --- /dev/null +++ b/phase-step/tests/test_phase_gate.py @@ -0,0 +1,93 @@ +from typing import override + +import pytest +from mpt_extension_contrib.phase_step import phase_gate_step +from mpt_extension_sdk.errors.step import SkipStepError +from mpt_extension_sdk.pipeline import BaseStep + +EXPECTED_PHASE = "createSubscription" +OTHER_PHASE = "checkOnboardStatus" + + +class PlainStep(BaseStep): + """A step that does not subclass PhaseGatedStep, wrapped by the gate helper.""" + + @override + async def process(self, context): + """No-op; patched with a mock in tests.""" + + +def test_phase_gate_step_preserves_name(): + gated = phase_gate_step(PlainStep, EXPECTED_PHASE) + + result = gated.__name__ + + assert result == "PlainStep" + + +async def test_phase_gate_step_runs_when_matching( + order_context_factory, phase_parameter_factory, mocker +): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + gated_class = phase_gate_step(PlainStep, EXPECTED_PHASE) + gated_step = gated_class() + process = mocker.patch.object(gated_step, "process") + + await gated_step.run(context) + + process.assert_awaited_once() + + +async def test_phase_gate_step_skips_when_not_matching( + order_context_factory, phase_parameter_factory, mocker +): + context = order_context_factory(fulfillment=[phase_parameter_factory(OTHER_PHASE)]) + gated_class = phase_gate_step(PlainStep, EXPECTED_PHASE) + gated_step = gated_class() + process = mocker.patch.object(gated_step, "process") + + with pytest.raises(SkipStepError): + await gated_step.run(context) + + process.assert_not_awaited() + + +async def test_phase_gate_step_advances_after_success( + order_context_factory, phase_parameter_factory +): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + gated_class = phase_gate_step(PlainStep, EXPECTED_PHASE, next_phase="checkOnboard") + gated_step = gated_class() + + await gated_step.run(context) + + orders.update.assert_awaited_once() + + +async def test_phase_gate_step_no_advance_when_unset( + order_context_factory, phase_parameter_factory +): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + gated_class = phase_gate_step(PlainStep, EXPECTED_PHASE) + gated_step = gated_class() + + await gated_step.run(context) + + orders.update.assert_not_awaited() + + +async def test_phase_gate_step_no_advance_on_failure( + order_context_factory, phase_parameter_factory, mocker +): + context = order_context_factory(fulfillment=[phase_parameter_factory(EXPECTED_PHASE)]) + orders = context.mpt_api_service.orders + gated_class = phase_gate_step(PlainStep, EXPECTED_PHASE, next_phase="checkOnboard") + gated_step = gated_class() + mocker.patch.object(PlainStep, "process", side_effect=RuntimeError) + + with pytest.raises(RuntimeError): + await gated_step.run(context) + + orders.update.assert_not_awaited()