migrate-guard 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. migrate_guard/__init__.py +3 -0
  2. migrate_guard/application/__init__.py +0 -0
  3. migrate_guard/application/use_cases/__init__.py +0 -0
  4. migrate_guard/application/use_cases/run_corpus.py +122 -0
  5. migrate_guard/cli/__init__.py +0 -0
  6. migrate_guard/cli/app.py +118 -0
  7. migrate_guard/domain/__init__.py +0 -0
  8. migrate_guard/domain/entities/__init__.py +0 -0
  9. migrate_guard/domain/entities/case.py +40 -0
  10. migrate_guard/domain/entities/case_result.py +59 -0
  11. migrate_guard/domain/entities/difference.py +42 -0
  12. migrate_guard/domain/entities/state.py +27 -0
  13. migrate_guard/domain/ports/__init__.py +0 -0
  14. migrate_guard/domain/ports/state.py +23 -0
  15. migrate_guard/domain/ports/target_runner.py +21 -0
  16. migrate_guard/domain/services/__init__.py +0 -0
  17. migrate_guard/domain/services/case_compare.py +23 -0
  18. migrate_guard/domain/services/redaction.py +94 -0
  19. migrate_guard/domain/services/response_diff.py +270 -0
  20. migrate_guard/domain/services/state_diff.py +27 -0
  21. migrate_guard/domain/services/state_values.py +60 -0
  22. migrate_guard/domain/value_objects/__init__.py +0 -0
  23. migrate_guard/domain/value_objects/json_pointer.py +35 -0
  24. migrate_guard/domain/value_objects/json_value.py +24 -0
  25. migrate_guard/domain/value_objects/rules.py +74 -0
  26. migrate_guard/domain/value_objects/state_rules.py +39 -0
  27. migrate_guard/infrastructure/__init__.py +0 -0
  28. migrate_guard/infrastructure/adapters/__init__.py +0 -0
  29. migrate_guard/infrastructure/adapters/reporters/__init__.py +0 -0
  30. migrate_guard/infrastructure/adapters/reporters/json_reporter.py +63 -0
  31. migrate_guard/infrastructure/adapters/reporters/junit_reporter.py +60 -0
  32. migrate_guard/infrastructure/adapters/reporters/plain_text.py +78 -0
  33. migrate_guard/infrastructure/adapters/reporters/terminal_reporter.py +87 -0
  34. migrate_guard/infrastructure/adapters/state/__init__.py +0 -0
  35. migrate_guard/infrastructure/adapters/state/sqlalchemy_state.py +167 -0
  36. migrate_guard/infrastructure/adapters/targets/__init__.py +0 -0
  37. migrate_guard/infrastructure/adapters/targets/http_target.py +114 -0
  38. migrate_guard/infrastructure/config/__init__.py +0 -0
  39. migrate_guard/infrastructure/config/loader.py +254 -0
  40. migrate_guard/infrastructure/config/state_config.py +195 -0
  41. migrate_guard/infrastructure/wiring.py +54 -0
  42. migrate_guard/py.typed +0 -0
  43. migrate_guard/pytest_plugin.py +136 -0
  44. migrate_guard-0.1.0.dist-info/METADATA +219 -0
  45. migrate_guard-0.1.0.dist-info/RECORD +48 -0
  46. migrate_guard-0.1.0.dist-info/WHEEL +4 -0
  47. migrate_guard-0.1.0.dist-info/entry_points.txt +6 -0
  48. migrate_guard-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,3 @@
1
+ """Catch behavior changes when you rewrite a legacy service."""
2
+
3
+ __version__ = "0.1.0"
File without changes
File without changes
@@ -0,0 +1,122 @@
1
+ """Use case: replay every case against the baseline and the candidate, and compare."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Sequence
6
+ from dataclasses import dataclass, replace
7
+
8
+ from migrate_guard.domain.entities.case import Case, HttpResponse
9
+ from migrate_guard.domain.entities.case_result import CaseResult, CaseStatus, RunResult
10
+ from migrate_guard.domain.entities.state import StateSnapshot
11
+ from migrate_guard.domain.ports.state import Seeder, StateError, StateProbe
12
+ from migrate_guard.domain.ports.target_runner import TargetError, TargetRunner
13
+ from migrate_guard.domain.services.case_compare import compare_case
14
+ from migrate_guard.domain.services.state_diff import diff_states
15
+ from migrate_guard.domain.value_objects.rules import Rule
16
+ from migrate_guard.domain.value_objects.state_rules import StateRule
17
+
18
+
19
+ class RunRefused(Exception):
20
+ """The run did not start: nothing was sent to any target. Maps to exit code 2."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class StateCheck:
25
+ """The database side of a run. Seeding is not optional: every case starts from a known state."""
26
+
27
+ baseline_seeder: Seeder
28
+ candidate_seeder: Seeder
29
+ baseline_probe: StateProbe
30
+ candidate_probe: StateProbe
31
+ rules: tuple[StateRule, ...] = ()
32
+
33
+
34
+ class _CaseFailed(Exception):
35
+ """One step of a case failed: the case is an error and its remaining steps are skipped."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class RunCorpus:
40
+ baseline: TargetRunner
41
+ candidate: TargetRunner
42
+ rules: tuple[Rule, ...] = ()
43
+ allow_mutating: bool = False
44
+ state: StateCheck | None = None
45
+
46
+ def execute(self, cases: Sequence[Case]) -> RunResult:
47
+ # Every refusal happens before the first request, so a refused run touches nothing.
48
+ if not cases:
49
+ raise RunRefused("the corpus has no cases")
50
+ mutating = [c.name for c in cases if c.is_mutating]
51
+ if mutating and not self.allow_mutating:
52
+ raise RunRefused(
53
+ "mutating cases need `allow_mutating: true` in the config: " + ", ".join(mutating)
54
+ )
55
+ for side, target in (("baseline", self.baseline), ("candidate", self.candidate)):
56
+ try:
57
+ target.check_reachable()
58
+ except TargetError as e:
59
+ raise RunRefused(f"target unreachable: {side} ({e})") from e
60
+
61
+ results: list[CaseResult] = []
62
+ used: set[Rule | StateRule] = set()
63
+ compared_any = False
64
+ for case in cases:
65
+ try:
66
+ result, case_used = self._run_case(case)
67
+ except _CaseFailed as e:
68
+ results.append(CaseResult.failed(case.name, str(e)))
69
+ continue
70
+ results.append(result)
71
+ used.update(case_used)
72
+ compared_any = True
73
+
74
+ # With no case compared there is no evidence that a rule is stale: don't warn.
75
+ all_rules: tuple[Rule | StateRule, ...] = (
76
+ *self.rules,
77
+ *(self.state.rules if self.state else ()),
78
+ )
79
+ unused = tuple(r for r in all_rules if r not in used) if compared_any else ()
80
+ return RunResult(tuple(results), unused)
81
+
82
+ def _run_case(self, case: Case) -> tuple[CaseResult, set[Rule | StateRule]]:
83
+ # Order (SPEC §6): seed both -> baseline -> probe baseline -> candidate -> probe
84
+ # candidate -> compare. Seeding both first means a failed seed sends no request at all.
85
+ if self.state:
86
+ _step("seed failed: baseline", self.state.baseline_seeder.seed)
87
+ _step("seed failed: candidate", self.state.candidate_seeder.seed)
88
+ b_response = _request("baseline", self.baseline, case)
89
+ b_state = self._probe("baseline", self.state.baseline_probe if self.state else None)
90
+ c_response = _request("candidate", self.candidate, case)
91
+ c_state = self._probe("candidate", self.state.candidate_probe if self.state else None)
92
+
93
+ result, body = compare_case(case.name, b_response, c_response, self.rules)
94
+ used: set[Rule | StateRule] = {r for r in self.rules if r not in body.unused_rules}
95
+ if self.state and b_state is not None and c_state is not None:
96
+ state = diff_states(b_state, c_state, self.state.rules)
97
+ used.update(r for r in self.state.rules if r not in state.unused_rules)
98
+ if state.differences:
99
+ result = replace(
100
+ result, status=CaseStatus.DIFFERENCE, state_differences=state.differences
101
+ )
102
+ return result, used
103
+
104
+ @staticmethod
105
+ def _probe(side: str, probe: StateProbe | None) -> StateSnapshot | None:
106
+ if probe is None:
107
+ return None
108
+ return _step(f"state probe failed: {side}", probe.snapshot)
109
+
110
+
111
+ def _request(side: str, target: TargetRunner, case: Case) -> HttpResponse:
112
+ try:
113
+ return target.execute(case.request)
114
+ except TargetError as e:
115
+ raise _CaseFailed(f"{side}: {e}") from e
116
+
117
+
118
+ def _step[T](label: str, action: Callable[[], T]) -> T:
119
+ try:
120
+ return action()
121
+ except StateError as e:
122
+ raise _CaseFailed(f"{label}: {e}") from e
File without changes
@@ -0,0 +1,118 @@
1
+ """The `migrate-guard` command line."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ import tempfile
8
+ from enum import StrEnum
9
+ from pathlib import Path
10
+ from typing import Annotated
11
+
12
+ import typer
13
+ from rich.console import Console
14
+
15
+ from migrate_guard.application.use_cases.run_corpus import RunRefused
16
+ from migrate_guard.domain.entities.case_result import ExitCode
17
+ from migrate_guard.domain.services.redaction import RedactionPolicy
18
+ from migrate_guard.infrastructure.adapters.reporters.json_reporter import render_json_report
19
+ from migrate_guard.infrastructure.adapters.reporters.junit_reporter import render_junit_report
20
+ from migrate_guard.infrastructure.adapters.reporters.terminal_reporter import TerminalReporter
21
+ from migrate_guard.infrastructure.adapters.targets.http_target import UnsafeTargetError
22
+ from migrate_guard.infrastructure.config.loader import ConfigError, load_config
23
+ from migrate_guard.infrastructure.config.state_config import StateConfigError
24
+ from migrate_guard.infrastructure.wiring import select_cases, wire
25
+
26
+ app = typer.Typer(
27
+ help="Catch behavior changes when you rewrite a legacy service.",
28
+ no_args_is_help=True,
29
+ add_completion=False,
30
+ )
31
+
32
+
33
+ class ReportFormat(StrEnum):
34
+ TERMINAL = "terminal"
35
+ JSON = "json"
36
+ JUNIT = "junit"
37
+
38
+
39
+ _RENDER = {ReportFormat.JSON: render_json_report, ReportFormat.JUNIT: render_junit_report}
40
+
41
+
42
+ @app.callback()
43
+ def _root() -> None:
44
+ """Keeps `run` as a subcommand even while it is the only one."""
45
+
46
+
47
+ @app.command()
48
+ def run(
49
+ config: Annotated[
50
+ Path, typer.Option("--config", "-c", help="Path to migrate-guard.yaml.")
51
+ ] = Path("migrate-guard.yaml"),
52
+ case: Annotated[
53
+ list[str] | None, typer.Option("--case", help="Run only this case (repeatable).")
54
+ ] = None,
55
+ allow_remote: Annotated[
56
+ bool, typer.Option("--allow-remote", help="Allow targets that are not loopback.")
57
+ ] = False,
58
+ report: Annotated[
59
+ ReportFormat,
60
+ typer.Option(
61
+ "--report",
62
+ help="json or junit go to --output (plus the terminal summary), or alone to stdout.",
63
+ ),
64
+ ] = ReportFormat.TERMINAL,
65
+ output: Annotated[
66
+ Path | None, typer.Option("--output", "-o", help="File for the json or junit report.")
67
+ ] = None,
68
+ ) -> None:
69
+ """Replay the corpus against the baseline and the candidate, and compare responses and state.
70
+
71
+ Exit codes: 0 all match, 1 differences or case errors, 2 refused (config, target, safety).
72
+ """
73
+ # Refusals go to stderr, so stdout stays a valid report when it is one.
74
+ errors = TerminalReporter(Console(stderr=True, highlight=False), RedactionPolicy())
75
+ try:
76
+ if output and report is ReportFormat.TERMINAL:
77
+ raise ConfigError("--output needs --report json or --report junit")
78
+ loaded = load_config(config)
79
+ cases = select_cases(loaded.cases, case or [])
80
+ wiring = wire(loaded, allow_remote=allow_remote)
81
+ try:
82
+ result = wiring.run_corpus.execute(cases)
83
+ finally:
84
+ wiring.close()
85
+ except (ConfigError, StateConfigError, UnsafeTargetError, RunRefused) as e:
86
+ errors.refused(str(e))
87
+ raise typer.Exit(ExitCode.REFUSED) from e
88
+
89
+ if report is ReportFormat.TERMINAL or output:
90
+ TerminalReporter(Console(highlight=False), loaded.redaction).report(result)
91
+ if report is not ReportFormat.TERMINAL:
92
+ text = _RENDER[report](result, loaded.redaction)
93
+ try:
94
+ _write(text, output)
95
+ except OSError as e:
96
+ errors.refused(f"can't write the report: {e.strerror or type(e).__name__}")
97
+ raise typer.Exit(ExitCode.REFUSED) from e
98
+ raise typer.Exit(result.exit_code)
99
+
100
+
101
+ def _write(text: str, output: Path | None) -> None:
102
+ if output is None:
103
+ sys.stdout.write(text)
104
+ sys.stdout.flush()
105
+ return
106
+ # Write next to the target and rename: CI never picks up half a report.
107
+ fd, tmp = tempfile.mkstemp(dir=output.parent, prefix=f".{output.name}.", suffix=".tmp")
108
+ try:
109
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
110
+ f.write(text)
111
+ os.replace(tmp, output)
112
+ except BaseException:
113
+ Path(tmp).unlink(missing_ok=True)
114
+ raise
115
+
116
+
117
+ def main() -> None:
118
+ app()
File without changes
File without changes
@@ -0,0 +1,40 @@
1
+ """A case: one request replayed against both targets, and the response it gets back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ from migrate_guard.domain.value_objects.json_value import JsonValue
8
+
9
+ # Methods that can change state on the target: they need `allow_mutating: true` (SPEC §7).
10
+ MUTATING_METHODS = frozenset({"POST", "PUT", "PATCH", "DELETE"})
11
+
12
+ type Headers = tuple[tuple[str, str], ...]
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class HttpRequest:
17
+ method: str
18
+ path: str
19
+ query: tuple[tuple[str, str], ...] = ()
20
+ headers: Headers = ()
21
+ json: JsonValue | None = None
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Case:
26
+ name: str
27
+ request: HttpRequest
28
+ mutating: bool = False # set it for a GET that writes; mutating methods count anyway
29
+
30
+ @property
31
+ def is_mutating(self) -> bool:
32
+ return self.mutating or self.request.method.upper() in MUTATING_METHODS
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class HttpResponse:
37
+ status: int
38
+ headers: Headers
39
+ # The parsed JSON body (numbers as Decimal), or the raw text when the body is not JSON.
40
+ body: JsonValue
@@ -0,0 +1,59 @@
1
+ """The result of one case, and of a whole run."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import StrEnum
7
+
8
+ from migrate_guard.domain.entities.difference import Difference
9
+ from migrate_guard.domain.value_objects.rules import Rule
10
+ from migrate_guard.domain.value_objects.state_rules import StateRule
11
+
12
+
13
+ class CaseStatus(StrEnum):
14
+ MATCH = "match"
15
+ DIFFERENCE = "difference"
16
+ # The case could not be compared (timeout, connection error...). Never counted as a match.
17
+ ERROR = "error"
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class StatusMismatch:
22
+ baseline: int
23
+ candidate: int
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class CaseResult:
28
+ name: str
29
+ status: CaseStatus
30
+ status_mismatch: StatusMismatch | None = None
31
+ differences: tuple[Difference, ...] = () # in the response body
32
+ state_differences: tuple[Difference, ...] = () # in the database, at /table/row/column
33
+ error: str = ""
34
+
35
+ @classmethod
36
+ def failed(cls, name: str, error: str) -> CaseResult:
37
+ return cls(name, CaseStatus.ERROR, error=error)
38
+
39
+
40
+ class ExitCode:
41
+ OK = 0 # every case matched
42
+ DIFFERENCES = 1 # at least one difference or case error
43
+ REFUSED = 2 # invalid config, unreachable target or safety refusal
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RunResult:
48
+ cases: tuple[CaseResult, ...]
49
+ # Rules that matched nothing in any case of the run: probably stale (SPEC R2).
50
+ unused_rules: tuple[Rule | StateRule, ...] = ()
51
+
52
+ @property
53
+ def exit_code(self) -> int:
54
+ if all(c.status is CaseStatus.MATCH for c in self.cases):
55
+ return ExitCode.OK
56
+ return ExitCode.DIFFERENCES
57
+
58
+ def count(self, status: CaseStatus) -> int:
59
+ return sum(1 for c in self.cases if c.status is status)
@@ -0,0 +1,42 @@
1
+ """One mismatch between the baseline and the candidate."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum, StrEnum
7
+ from typing import Literal
8
+
9
+ from migrate_guard.domain.value_objects.json_pointer import JsonPointer
10
+ from migrate_guard.domain.value_objects.json_value import JsonValue
11
+
12
+
13
+ class _Missing(Enum):
14
+ MISSING = "missing"
15
+
16
+ def __repr__(self) -> str:
17
+ return "MISSING"
18
+
19
+
20
+ # A key that does not exist, which is not the same as a key whose value is null.
21
+ MISSING = _Missing.MISSING
22
+ type Side = JsonValue | Literal[_Missing.MISSING]
23
+
24
+
25
+ class DiffKind(StrEnum):
26
+ VALUE_CHANGED = "value_changed" # same type, different value: 2.68 vs 2.67
27
+ TYPE_CHANGED = "type_changed" # "10" vs 10, null vs 0
28
+ MISSING_IN_CANDIDATE = "missing_in_candidate" # the rewrite dropped a field
29
+ ADDED_IN_CANDIDATE = "added_in_candidate" # the rewrite added a field
30
+ DUPLICATE_KEY = "duplicate_key" # an `unordered` array has two items with the same key
31
+ # A rule matched but can't read the value (bad datetime, no timezone, missing key...).
32
+ # Never a silent match: the rule's assumption is broken, so a human has to look.
33
+ RULE_NOT_APPLICABLE = "rule_not_applicable"
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class Difference:
38
+ kind: DiffKind
39
+ location: JsonPointer
40
+ baseline: Side
41
+ candidate: Side
42
+ detail: str = "" # why, in words, when the kind alone doesn't say it
@@ -0,0 +1,27 @@
1
+ """What a target's database looks like after a case: the watched tables, row by row."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+ from migrate_guard.domain.value_objects.json_value import JsonValue
8
+
9
+ type Row = dict[str, JsonValue] # logical column name -> normalized value
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class TableSnapshot:
14
+ # Row key text (e.g. "sku=A1") -> the row. The key comes from the configured key columns.
15
+ rows: dict[str, Row] = field(default_factory=dict)
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class StateSnapshot:
20
+ tables: dict[str, TableSnapshot] = field(default_factory=dict) # by logical table name
21
+
22
+ def as_json(self) -> JsonValue:
23
+ """The snapshot as one JSON document, so the response diff engine can compare it."""
24
+ return {
25
+ name: {key: dict(row) for key, row in table.rows.items()}
26
+ for name, table in self.tables.items()
27
+ }
File without changes
@@ -0,0 +1,23 @@
1
+ """Ports for the database side: read a target's state, and reset it before each case."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from migrate_guard.domain.entities.state import StateSnapshot
8
+
9
+
10
+ class StateError(Exception):
11
+ """Seeding or probing failed. The case becomes an error, never a match."""
12
+
13
+
14
+ class StateProbe(ABC):
15
+ @abstractmethod
16
+ def snapshot(self) -> StateSnapshot:
17
+ """Read the watched tables. Read-only: it must never write."""
18
+
19
+
20
+ class Seeder(ABC):
21
+ @abstractmethod
22
+ def seed(self) -> None:
23
+ """Reset the database to the known starting state, all or nothing."""
@@ -0,0 +1,21 @@
1
+ """Port: something that can execute a case (a running service, an in-process app...)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+
7
+ from migrate_guard.domain.entities.case import HttpRequest, HttpResponse
8
+
9
+
10
+ class TargetError(Exception):
11
+ """The target could not answer: connection refused, timeout, invalid response..."""
12
+
13
+
14
+ class TargetRunner(ABC):
15
+ @abstractmethod
16
+ def check_reachable(self) -> None:
17
+ """Raise TargetError if the target can't be reached at all, before any case runs."""
18
+
19
+ @abstractmethod
20
+ def execute(self, request: HttpRequest) -> HttpResponse:
21
+ """Send the request and return the response; raise TargetError if there is none."""
File without changes
@@ -0,0 +1,23 @@
1
+ """Compare the two responses of one case: the status code, then the body with the rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from migrate_guard.domain.entities.case import HttpResponse
6
+ from migrate_guard.domain.entities.case_result import CaseResult, CaseStatus, StatusMismatch
7
+ from migrate_guard.domain.services.response_diff import ResponseDiff, diff_responses
8
+ from migrate_guard.domain.value_objects.rules import Rule
9
+
10
+
11
+ def compare_case(
12
+ name: str, baseline: HttpResponse, candidate: HttpResponse, rules: tuple[Rule, ...]
13
+ ) -> tuple[CaseResult, ResponseDiff]:
14
+ """The result, plus the raw body diff so the caller can track which rules were used."""
15
+ mismatch = (
16
+ StatusMismatch(baseline.status, candidate.status)
17
+ if baseline.status != candidate.status
18
+ else None
19
+ )
20
+ # The body is compared even when the status differs: both facts help to find the cause.
21
+ body = diff_responses(baseline.body, candidate.body, rules)
22
+ status = CaseStatus.MATCH if mismatch is None and body.is_match else CaseStatus.DIFFERENCE
23
+ return CaseResult(name, status, mismatch, body.differences), body
@@ -0,0 +1,94 @@
1
+ """Hide secrets before anything reaches a report or a log (SPEC §7)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass, replace
7
+
8
+ from migrate_guard.domain.entities.case import Headers
9
+ from migrate_guard.domain.entities.difference import MISSING, Difference, Side
10
+ from migrate_guard.domain.value_objects.json_value import JsonValue
11
+
12
+ REDACTED = "[REDACTED]"
13
+
14
+ SENSITIVE_HEADERS = frozenset({"authorization", "cookie", "set-cookie", "x-api-key"})
15
+ DEFAULT_FIELD_PATTERNS = ("password", "passwd", "token", "secret", "api_key", "apikey")
16
+
17
+ # 13 to 19 digits, optionally grouped by spaces or dashes. Luhn-checked to limit false positives.
18
+ _CARD_CANDIDATE = re.compile(r"(?<!\d)\d(?:[ -]?\d){12,18}(?!\d)")
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class RedactionPolicy:
23
+ """Field names are matched case-insensitively, as substrings: `token` hides `access_token`."""
24
+
25
+ field_patterns: tuple[str, ...] = DEFAULT_FIELD_PATTERNS
26
+
27
+ def is_sensitive_field(self, name: str) -> bool:
28
+ lowered = name.lower()
29
+ return any(p.lower() in lowered for p in self.field_patterns)
30
+
31
+
32
+ def redact_headers(headers: Headers, policy: RedactionPolicy) -> Headers:
33
+ return tuple(
34
+ (name, REDACTED)
35
+ if name.lower() in SENSITIVE_HEADERS or policy.is_sensitive_field(name)
36
+ else (name, value)
37
+ for name, value in headers
38
+ )
39
+
40
+
41
+ def redact_value(value: JsonValue, policy: RedactionPolicy) -> JsonValue:
42
+ if isinstance(value, dict):
43
+ return {
44
+ k: REDACTED if policy.is_sensitive_field(k) else redact_value(v, policy)
45
+ for k, v in value.items()
46
+ }
47
+ if isinstance(value, list):
48
+ return [redact_value(v, policy) for v in value]
49
+ if isinstance(value, str):
50
+ return redact_text(value)
51
+ if isinstance(value, int) and not isinstance(value, bool) and _is_card(str(value)):
52
+ return REDACTED
53
+ return value
54
+
55
+
56
+ def redact_text(text: str) -> str:
57
+ return _CARD_CANDIDATE.sub(lambda m: REDACTED if _is_card(m.group()) else m.group(), text)
58
+
59
+
60
+ def redact_difference(diff: Difference, policy: RedactionPolicy) -> Difference:
61
+ # Under a sensitive field everything goes, the detail too: it can quote the values.
62
+ if any(policy.is_sensitive_field(token) for token in diff.location.tokens):
63
+ return replace(
64
+ diff,
65
+ baseline=_hide(diff.baseline),
66
+ candidate=_hide(diff.candidate),
67
+ detail=REDACTED if diff.detail else "",
68
+ )
69
+ return replace(
70
+ diff,
71
+ baseline=_redact_side(diff.baseline, policy),
72
+ candidate=_redact_side(diff.candidate, policy),
73
+ detail=redact_text(diff.detail),
74
+ )
75
+
76
+
77
+ def _hide(side: Side) -> Side:
78
+ return side if side is MISSING else REDACTED
79
+
80
+
81
+ def _redact_side(side: Side, policy: RedactionPolicy) -> Side:
82
+ return side if side is MISSING else redact_value(side, policy)
83
+
84
+
85
+ def _is_card(candidate: str) -> bool:
86
+ digits = [int(c) for c in candidate if c.isdigit()]
87
+ if not 13 <= len(digits) <= 19:
88
+ return False
89
+ total = 0
90
+ for i, d in enumerate(reversed(digits)):
91
+ if i % 2 == 1:
92
+ d = d * 2 - 9 if d > 4 else d * 2
93
+ total += d
94
+ return total % 10 == 0