重构为函数式通用 Markdown 修改库

This commit is contained in:
2026-08-26 15:33:37 +08:00
parent 1abf72ccb1
commit bb0507db30
89 changed files with 1589 additions and 14850 deletions
+121 -118
View File
@@ -1,14 +1,13 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import cast
import pytest
from mdpolish import (
Component,
DocumentSnapshot,
ErrorStage,
Modifier,
Pipeline,
ProposedChange,
RunStatus,
@@ -17,97 +16,84 @@ from mdpolish import (
)
class ReplaceComponent(Component):
def __init__(
self,
needle: str,
replacement: str,
*,
component_id: str,
version: str = "1.0.0",
parameters: object = None,
applicability: str = "处理精确测试字符串,要求完整匹配,排除其他内容。",
) -> None:
self.needle = needle
self.replacement = replacement
self._component_id = component_id
self._version = version
self._parameters = {"needle": needle, "replacement": replacement} if parameters is None else parameters
self._applicability = applicability
@property
def component_id(self) -> str:
return self._component_id
@property
def version(self) -> str:
return self._version
@property
def parameters(self) -> Mapping[str, object]:
return cast(Mapping[str, object], self._parameters)
@property
def applicability(self) -> str:
return self._applicability
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = snapshot.markdown.find(self.needle)
def replace_modifier(
needle: str,
replacement: str,
*,
modifier_id: str,
version: str = "1.0.0",
) -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
position = snapshot.markdown.find(needle)
if position < 0:
return ()
edit = TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(position, position + len(self.needle)),
expected_text=self.needle,
replacement=self.replacement,
span=TextSpan(position, position + len(needle)),
expected_text=needle,
replacement=replacement,
)
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason=f"replace test token for {self.component_id}",
reason=f"replace test token for {modifier_id}",
edits=(edit,),
),
)
return Modifier(
modifier_id=modifier_id,
version=version,
parameters={"needle": needle, "replacement": replacement},
applicability="处理精确测试字符串;排除其他内容。",
propose=propose,
)
class ExplodingComponent(ReplaceComponent):
def __init__(self, *, trigger: str | None = None, component_id: str = "test.exploding") -> None:
super().__init__("unused", "unused-replacement", component_id=component_id)
self.trigger = trigger
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if self.trigger is None or snapshot.markdown == self.trigger:
def exploding_modifier(*, trigger: str | None = None, modifier_id: str = "test.exploding") -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
if trigger is None or snapshot.markdown == trigger:
raise RuntimeError(f"SECRET source: {snapshot.markdown}")
return ()
class InvalidReturnComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.invalid-return")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
return cast(tuple[ProposedChange, ...], [])
return Modifier(
modifier_id=modifier_id,
version="1.0.0",
parameters={"trigger": trigger},
applicability="仅用于测试异常路径。",
propose=propose,
)
class StaleProposalComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.stale")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
def stale_proposal_modifier() -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
stale_snapshot = DocumentSnapshot(snapshot.markdown + "!")
edit = TextEdit(stale_snapshot.sha256, TextSpan(0, 1), stale_snapshot.markdown[0], "X")
return (ProposedChange(stale_snapshot.sha256, "stale test proposal", (edit,)),)
return Modifier(
modifier_id="test.stale",
version="1.0.0",
parameters=(),
applicability="仅用于测试过期快照。",
propose=propose,
)
class OverlapComponent(ReplaceComponent):
def __init__(self) -> None:
super().__init__("a", "A", component_id="test.overlap")
def _propose_changes(self, snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
def overlap_modifier() -> Modifier:
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
first = TextEdit(snapshot.sha256, TextSpan(0, 3), snapshot.markdown[0:3], "X")
second = TextEdit(snapshot.sha256, TextSpan(2, 4), snapshot.markdown[2:4], "Y")
return (ProposedChange(snapshot.sha256, "overlapping test proposal", (first, second)),)
return Modifier(
modifier_id="test.overlap",
version="1.0.0",
parameters=(),
applicability="仅用于测试冲突范围。",
propose=propose,
)
def test_empty_pipeline_returns_unchanged_success_for_empty_unicode_text() -> None:
for markdown in ("", "中文\nCafe\u0301\n🙂"):
@@ -117,14 +103,14 @@ def test_empty_pipeline_returns_unchanged_success_for_empty_unicode_text() -> No
assert result.output_markdown == markdown
assert result.partial_markdown is None
assert result.changes == ()
assert result.components == ()
assert result.modifiers == ()
def test_later_component_reads_snapshot_produced_by_earlier_component() -> None:
def test_later_modifier_reads_snapshot_produced_by_earlier_modifier() -> None:
pipeline = Pipeline(
[
ReplaceComponent("", "", component_id="test.first"),
ReplaceComponent("", "", component_id="test.second"),
replace_modifier("", "", modifier_id="test.first"),
replace_modifier("", "", modifier_id="test.second"),
]
)
@@ -132,15 +118,15 @@ def test_later_component_reads_snapshot_produced_by_earlier_component() -> None:
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == ""
assert [change.component_id for change in result.changes] == ["test.first", "test.second"]
assert [change.modifier_id for change in result.changes] == ["test.first", "test.second"]
assert result.changes[1].before_sha256 == result.changes[0].after_sha256
def test_same_input_components_and_parameters_produce_same_ordered_result() -> None:
def test_same_input_modifiers_and_parameters_produce_same_ordered_result() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.first"),
ReplaceComponent("b", "c", component_id="test.second"),
replace_modifier("a", "b", modifier_id="test.first"),
replace_modifier("b", "c", modifier_id="test.second"),
]
)
@@ -148,7 +134,7 @@ def test_same_input_components_and_parameters_produce_same_ordered_result() -> N
def test_successful_pipeline_is_idempotent_on_its_output() -> None:
pipeline = Pipeline([ReplaceComponent("old", "new", component_id="test.replace")])
pipeline = Pipeline([replace_modifier("old", "new", modifier_id="test.replace")])
first = pipeline.transform("old value")
assert first.status is RunStatus.SUCCESS
@@ -161,22 +147,12 @@ def test_successful_pipeline_is_idempotent_on_its_output() -> None:
assert second.changes == ()
def test_single_component_runs_through_pipeline_without_shortcut() -> None:
component = ReplaceComponent("a", "A", component_id="test.single")
result = Pipeline([component]).transform("a")
assert result.status is RunStatus.SUCCESS
assert result.output_markdown == "A"
assert len(result.changes) == 1
def test_transform_error_stops_later_components_and_keeps_only_partial_text() -> None:
def test_transform_error_stops_later_modifiers_and_keeps_only_partial_text() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.first"),
ExplodingComponent(),
ReplaceComponent("b", "c", component_id="test.never-runs"),
replace_modifier("a", "b", modifier_id="test.first"),
exploding_modifier(),
replace_modifier("b", "c", modifier_id="test.never-runs"),
]
)
@@ -185,14 +161,14 @@ def test_transform_error_stops_later_components_and_keeps_only_partial_text() ->
assert result.status is RunStatus.FAILED
assert result.output_markdown is None
assert result.partial_markdown == "b"
assert [change.component_id for change in result.changes] == ["test.first"]
assert [change.modifier_id for change in result.changes] == ["test.first"]
assert len(result.errors) == 1
assert result.errors[0].stage is ErrorStage.TRANSFORM
assert result.residual_proposals == ()
def test_unexpected_component_error_does_not_leak_source_or_exception_message() -> None:
result = Pipeline([ExplodingComponent()]).transform("private markdown")
def test_unexpected_modifier_error_does_not_leak_source_or_exception_message() -> None:
result = Pipeline([exploding_modifier()]).transform("private markdown")
assert result.status is RunStatus.FAILED
assert result.errors[0].error_type == "RuntimeError"
@@ -201,17 +177,27 @@ def test_unexpected_component_error_does_not_leak_source_or_exception_message()
def test_invalid_proposal_return_is_a_transform_contract_failure() -> None:
result = Pipeline([InvalidReturnComponent()]).transform("abc")
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
return cast(tuple[ProposedChange, ...], [])
modifier = Modifier(
modifier_id="test.invalid-return",
version="1.0.0",
parameters=(),
applicability="仅用于测试非法返回值。",
propose=propose,
)
result = Pipeline([modifier]).transform("abc")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "abc"
assert result.errors[0].error_type == "ComponentContractError"
assert result.errors[0].error_type == "ModifierContractError"
assert result.errors[0].stage is ErrorStage.TRANSFORM
@pytest.mark.parametrize("component", [StaleProposalComponent(), OverlapComponent()])
def test_invalid_edit_batch_fails_atomically(component: Component) -> None:
result = Pipeline([component]).transform("abcd")
@pytest.mark.parametrize("modifier", [stale_proposal_modifier(), overlap_modifier()])
def test_invalid_edit_batch_fails_atomically(modifier: Modifier) -> None:
result = Pipeline([modifier]).transform("abcd")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "abcd"
@@ -219,11 +205,11 @@ def test_invalid_edit_batch_fails_atomically(component: Component) -> None:
assert result.errors[0].error_type == "EditValidationError"
def test_duplicate_component_ids_fail_during_preflight_before_modification() -> None:
def test_duplicate_modifier_ids_fail_during_preflight_before_modification() -> None:
pipeline = Pipeline(
[
ReplaceComponent("a", "b", component_id="test.duplicate"),
ReplaceComponent("b", "c", component_id="test.duplicate"),
replace_modifier("a", "b", modifier_id="test.duplicate"),
replace_modifier("b", "c", modifier_id="test.duplicate"),
]
)
@@ -235,28 +221,45 @@ def test_duplicate_component_ids_fail_during_preflight_before_modification() ->
assert result.errors[0].error_type == "PipelineContractError"
@pytest.mark.parametrize(
"component",
[
ReplaceComponent("a", "b", component_id="test.bad-version", version="1.0"),
ReplaceComponent("a", "b", component_id="test.bad-parameters", parameters={"bad": {1}}),
ReplaceComponent("a", "b", component_id="test.bad-applicability", applicability=""),
],
)
def test_invalid_component_metadata_fails_before_modification(component: Component) -> None:
result = Pipeline([component]).transform("a")
def test_non_modifier_entry_fails_during_preflight() -> None:
pipeline = Pipeline(cast(list[Modifier], [object()]))
result = pipeline.transform("a")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "a"
assert result.errors[0].error_type == "ModifierContractError"
def test_metadata_changed_by_proposal_function_fails_before_application() -> None:
modifier: Modifier
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
object.__setattr__(modifier, "version", "2.0.0")
edit = TextEdit(snapshot.sha256, TextSpan(0, 1), "a", "A")
return (ProposedChange(snapshot.sha256, "mutating metadata", (edit,)),)
modifier = Modifier(
modifier_id="test.mutating-metadata",
version="1.0.0",
parameters=(),
applicability="仅用于测试运行期元数据变化。",
propose=propose,
)
result = Pipeline([modifier]).transform("a")
assert result.status is RunStatus.FAILED
assert result.partial_markdown == "a"
assert result.changes == ()
assert result.errors[0].stage is ErrorStage.TRANSFORM
assert result.errors[0].error_type == "ModifierContractError"
def test_cross_component_chain_is_reported_unstable_without_a_second_round() -> None:
def test_cross_modifier_chain_is_reported_unstable_without_a_second_round() -> None:
pipeline = Pipeline(
[
ReplaceComponent("bad", "good", component_id="test.to-good"),
ReplaceComponent("good", "bad", component_id="test.to-bad"),
replace_modifier("bad", "good", modifier_id="test.to-good"),
replace_modifier("good", "bad", modifier_id="test.to-bad"),
]
)
@@ -267,16 +270,16 @@ def test_cross_component_chain_is_reported_unstable_without_a_second_round() ->
assert result.partial_markdown == "bad"
assert len(result.changes) == 2
assert len(result.residual_proposals) == 1
assert result.residual_proposals[0].component_id == "test.to-good"
assert result.residual_proposals[0].modifier_id == "test.to-good"
assert result.errors == ()
def test_final_review_continues_after_error_and_keeps_valid_residual_proposal() -> None:
pipeline = Pipeline(
[
ExplodingComponent(trigger="done", component_id="test.review-error"),
ReplaceComponent("done", "clean", component_id="test.residual"),
ReplaceComponent("start", "done", component_id="test.producer"),
exploding_modifier(trigger="done", modifier_id="test.review-error"),
replace_modifier("done", "clean", modifier_id="test.residual"),
replace_modifier("start", "done", modifier_id="test.producer"),
]
)
@@ -287,7 +290,7 @@ def test_final_review_continues_after_error_and_keeps_valid_residual_proposal()
assert result.partial_markdown == "done"
assert len(result.errors) == 1
assert result.errors[0].stage is ErrorStage.FINAL_REVIEW
assert result.errors[0].component_id == "test.review-error"
assert result.errors[0].modifier_id == "test.review-error"
assert len(result.residual_proposals) == 1
assert result.residual_proposals[0].component_id == "test.residual"
assert result.residual_proposals[0].modifier_id == "test.residual"
assert result.residual_proposals[0].proposal_ref.snapshot_sha256 == result.current_sha256