abkit 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.
- abkit/__init__.py +23 -0
- abkit/audit.py +58 -0
- abkit/bootstrap.py +46 -0
- abkit/cli.py +71 -0
- abkit/demo.py +177 -0
- abkit/io.py +38 -0
- abkit/probes.py +428 -0
- abkit/py.typed +0 -0
- abkit/report.py +267 -0
- abkit/result.py +82 -0
- abkit/schema.py +71 -0
- abkit/stats.py +222 -0
- abkit/synthetic.py +144 -0
- abkit-0.1.0.dist-info/METADATA +262 -0
- abkit-0.1.0.dist-info/RECORD +19 -0
- abkit-0.1.0.dist-info/WHEEL +5 -0
- abkit-0.1.0.dist-info/entry_points.txt +2 -0
- abkit-0.1.0.dist-info/licenses/LICENSE +21 -0
- abkit-0.1.0.dist-info/top_level.txt +1 -0
abkit/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""abkit: audit an A/B-test readout before you ship the decision."""
|
|
2
|
+
|
|
3
|
+
from .audit import run_audit
|
|
4
|
+
from .io import dump_design, dump_units, load_design, load_units
|
|
5
|
+
from .result import AuditResult, ProbeResult, SkippedProbe
|
|
6
|
+
from .schema import Design, Look, Unit
|
|
7
|
+
|
|
8
|
+
__version__ = "0.1.0"
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AuditResult",
|
|
12
|
+
"Design",
|
|
13
|
+
"Look",
|
|
14
|
+
"ProbeResult",
|
|
15
|
+
"SkippedProbe",
|
|
16
|
+
"Unit",
|
|
17
|
+
"__version__",
|
|
18
|
+
"dump_design",
|
|
19
|
+
"dump_units",
|
|
20
|
+
"load_design",
|
|
21
|
+
"load_units",
|
|
22
|
+
"run_audit",
|
|
23
|
+
]
|
abkit/audit.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Run every applicable probe against one experiment readout."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
|
|
7
|
+
from . import probes
|
|
8
|
+
from .result import AuditResult, ProbeResult, SkippedProbe
|
|
9
|
+
from .schema import Design, Unit
|
|
10
|
+
from .stats import welch
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def run_audit(units: Sequence[Unit], design: Design, name: str = "experiment") -> AuditResult:
|
|
14
|
+
result = AuditResult(experiment=name)
|
|
15
|
+
|
|
16
|
+
treat = [
|
|
17
|
+
u.metrics[design.primary_metric]
|
|
18
|
+
for u in units
|
|
19
|
+
if u.arm == design.treatment and design.primary_metric in u.metrics
|
|
20
|
+
]
|
|
21
|
+
ctrl = [
|
|
22
|
+
u.metrics[design.primary_metric]
|
|
23
|
+
for u in units
|
|
24
|
+
if u.arm == design.control and design.primary_metric in u.metrics
|
|
25
|
+
]
|
|
26
|
+
if len(treat) >= 2 and len(ctrl) >= 2:
|
|
27
|
+
effect, se, z, p = welch(treat, ctrl)
|
|
28
|
+
result.summary = {
|
|
29
|
+
"primary_metric": design.primary_metric,
|
|
30
|
+
"effect": effect,
|
|
31
|
+
"se": se,
|
|
32
|
+
"z": z,
|
|
33
|
+
"p": p,
|
|
34
|
+
"n_treatment": len(treat),
|
|
35
|
+
"n_control": len(ctrl),
|
|
36
|
+
"alpha": design.alpha,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
outcomes: list[ProbeResult | SkippedProbe | list[ProbeResult]] = [
|
|
40
|
+
probes.sample_ratio(units, design),
|
|
41
|
+
probes.contamination(units),
|
|
42
|
+
probes.covariate_balance(units, design),
|
|
43
|
+
probes.outlier_fragility(units, design),
|
|
44
|
+
probes.uncorrected_winners(units, design),
|
|
45
|
+
probes.peeking(design),
|
|
46
|
+
probes.novelty(units, design),
|
|
47
|
+
probes.winners_curse(units, design),
|
|
48
|
+
probes.variance_ratio(units, design),
|
|
49
|
+
probes.cuped_headroom(units, design),
|
|
50
|
+
]
|
|
51
|
+
for outcome in outcomes:
|
|
52
|
+
if isinstance(outcome, SkippedProbe):
|
|
53
|
+
result.skipped.append(outcome)
|
|
54
|
+
elif isinstance(outcome, list):
|
|
55
|
+
result.probes.extend(outcome)
|
|
56
|
+
else:
|
|
57
|
+
result.probes.append(outcome)
|
|
58
|
+
return result
|
abkit/bootstrap.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Stratified bootstrap confidence intervals.
|
|
2
|
+
|
|
3
|
+
Units are resampled within their arm (or other stratum), never across, so the
|
|
4
|
+
group sizes that determine a statistic's sampling distribution are preserved.
|
|
5
|
+
Percentile intervals, seeded PCG64, no BLAS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import math
|
|
11
|
+
from collections.abc import Callable, Sequence
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
MAX_UNDEFINED_SHARE = 0.1
|
|
16
|
+
"""If more than this share of resamples yields nan, the CI is not reported."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def bootstrap_ci(
|
|
20
|
+
statistic: Callable[..., float],
|
|
21
|
+
groups: Sequence[Sequence[float]],
|
|
22
|
+
n_resamples: int = 1000,
|
|
23
|
+
seed: int = 0,
|
|
24
|
+
) -> tuple[float, tuple[float, float] | None]:
|
|
25
|
+
"""Point estimate and 95% percentile CI, resampling within each group."""
|
|
26
|
+
point = statistic(*groups)
|
|
27
|
+
rng = np.random.Generator(np.random.PCG64(seed))
|
|
28
|
+
arrays = [list(group) for group in groups]
|
|
29
|
+
values: list[float] = []
|
|
30
|
+
undefined = 0
|
|
31
|
+
for _ in range(n_resamples):
|
|
32
|
+
resampled = []
|
|
33
|
+
for group in arrays:
|
|
34
|
+
indices = rng.integers(0, len(group), size=len(group)).tolist()
|
|
35
|
+
resampled.append([group[i] for i in indices])
|
|
36
|
+
value = statistic(*resampled)
|
|
37
|
+
if math.isnan(value):
|
|
38
|
+
undefined += 1
|
|
39
|
+
else:
|
|
40
|
+
values.append(value)
|
|
41
|
+
if undefined > MAX_UNDEFINED_SHARE * n_resamples or not values:
|
|
42
|
+
return point, None
|
|
43
|
+
values.sort()
|
|
44
|
+
lo = values[int(0.025 * len(values))]
|
|
45
|
+
hi = values[min(len(values) - 1, int(0.975 * len(values)))]
|
|
46
|
+
return point, (lo, hi)
|
abkit/cli.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Command line interface."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .audit import run_audit
|
|
10
|
+
from .demo import run_demo
|
|
11
|
+
from .io import load_design, load_units
|
|
12
|
+
from .report import inject_readme, write_report
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main(argv: list[str] | None = None) -> int:
|
|
16
|
+
parser = argparse.ArgumentParser(
|
|
17
|
+
prog="abkit", description="Audit an A/B-test readout before you ship the decision."
|
|
18
|
+
)
|
|
19
|
+
parser.add_argument("--version", action="version", version=f"abkit {__version__}")
|
|
20
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
report_cmd = sub.add_parser("report", help="audit a units JSONL against a design JSON")
|
|
23
|
+
report_cmd.add_argument("units", help="JSONL file, one unit per line")
|
|
24
|
+
report_cmd.add_argument("--design", required=True, help="design JSON file")
|
|
25
|
+
report_cmd.add_argument("--out", default="audit", help="output directory")
|
|
26
|
+
report_cmd.add_argument("--name", default=None, help="experiment name in the report")
|
|
27
|
+
report_cmd.add_argument(
|
|
28
|
+
"--fail-on-flags", action="store_true", help="exit 2 if any probe triggers"
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
demo_cmd = sub.add_parser("demo", help="rebuild the implanted-defect demo")
|
|
32
|
+
demo_cmd.add_argument("--out", default="results", help="output directory")
|
|
33
|
+
|
|
34
|
+
inject_cmd = sub.add_parser(
|
|
35
|
+
"inject-readme", help="refresh the demo table between README markers"
|
|
36
|
+
)
|
|
37
|
+
inject_cmd.add_argument("readme")
|
|
38
|
+
inject_cmd.add_argument("--results", default="results", help="demo output directory")
|
|
39
|
+
|
|
40
|
+
args = parser.parse_args(argv)
|
|
41
|
+
|
|
42
|
+
if args.command == "report":
|
|
43
|
+
units = load_units(args.units)
|
|
44
|
+
design = load_design(args.design)
|
|
45
|
+
name = args.name or Path(args.units).stem
|
|
46
|
+
audit = run_audit(units, design, name=name)
|
|
47
|
+
report_path = write_report(audit, args.out, design=design, units=units)
|
|
48
|
+
print(f"wrote {report_path}")
|
|
49
|
+
for probe in audit.flags:
|
|
50
|
+
print(f"FLAG {probe.name} -- {probe.detail}")
|
|
51
|
+
if args.fail_on_flags and audit.flags:
|
|
52
|
+
return 2
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
if args.command == "demo":
|
|
56
|
+
audits = run_demo(args.out)
|
|
57
|
+
flagged = sum(1 for audit in audits if audit.flags)
|
|
58
|
+
print(f"wrote {args.out}/report.md ({flagged}/{len(audits)} experiments flagged)")
|
|
59
|
+
return 0
|
|
60
|
+
|
|
61
|
+
if args.command == "inject-readme":
|
|
62
|
+
table = (Path(args.results) / "table.md").read_text(encoding="utf-8")
|
|
63
|
+
inject_readme(args.readme, table)
|
|
64
|
+
print(f"updated {args.readme}")
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
raise AssertionError("unreachable")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
if __name__ == "__main__":
|
|
71
|
+
raise SystemExit(main())
|
abkit/demo.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Eight experiments, seven implanted defects, one table.
|
|
2
|
+
|
|
3
|
+
Each demo experiment implants exactly one failure mode at a severity chosen to
|
|
4
|
+
be decisive, then runs the full audit. The table is regenerated from fixed
|
|
5
|
+
seeds; CI diffs it against the committed copy.
|
|
6
|
+
|
|
7
|
+
Two experiments deliberately search over seeds: the peeker keeps trying null
|
|
8
|
+
experiments until one stops early at an interim look, and the underpowered
|
|
9
|
+
winner keeps trying until a true effect of 0.1 comes out significant. That
|
|
10
|
+
selection is not a trick — it is the defect. Peeking and publishing only
|
|
11
|
+
winners are selection processes, and the demo reproduces the selection.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .audit import run_audit
|
|
19
|
+
from .probes import uncorrected_winners
|
|
20
|
+
from .report import (
|
|
21
|
+
exaggeration_figure,
|
|
22
|
+
fragility_figure,
|
|
23
|
+
peeking_figure,
|
|
24
|
+
render_report,
|
|
25
|
+
)
|
|
26
|
+
from .result import AuditResult, SkippedProbe
|
|
27
|
+
from .schema import Design, Unit
|
|
28
|
+
from .stats import design_analysis, welch
|
|
29
|
+
from .synthetic import simulate, simulate_peeking
|
|
30
|
+
|
|
31
|
+
DEFECTS = {
|
|
32
|
+
"clean": "nothing",
|
|
33
|
+
"traffic-leak": "3% of traffic diverted to treatment",
|
|
34
|
+
"double-dipper": "25 units assigned to both arms",
|
|
35
|
+
"whale-driven": "3 extreme units carry the significance",
|
|
36
|
+
"peeker": "null effect, stopped at the first significant look",
|
|
37
|
+
"fading-novelty": "early-half effect 0.30, late-half effect 0.00",
|
|
38
|
+
"metric-fisher": "null effect, 40 secondary metrics tested",
|
|
39
|
+
"underpowered-winner": "true effect 0.1, powered for 0.14 of that chance",
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _confirm(
|
|
44
|
+
units: list[Unit], design: Design, name: str, target: set[str]
|
|
45
|
+
) -> tuple[list[Unit], Design] | None:
|
|
46
|
+
audit = run_audit(units, design, name=name)
|
|
47
|
+
if {probe.name for probe in audit.flags} == target:
|
|
48
|
+
return units, design
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _find_peeker() -> tuple[list[Unit], Design]:
|
|
53
|
+
for seed in range(500):
|
|
54
|
+
units, design = simulate_peeking(n_max=5000, look_every=250, effect=0.0, seed=seed)
|
|
55
|
+
looks = design.looks or []
|
|
56
|
+
if not looks or looks[-1].n < 1500 or looks[-1].n > 3500 or abs(looks[-1].z) < 2.2:
|
|
57
|
+
continue
|
|
58
|
+
found = _confirm(units, design, "peeker", {"peeking"})
|
|
59
|
+
if found:
|
|
60
|
+
return found
|
|
61
|
+
raise RuntimeError("no early-stopping null experiment found in 500 seeds")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _find_underpowered() -> tuple[list[Unit], Design]:
|
|
65
|
+
for seed in range(500):
|
|
66
|
+
units, design = simulate(n=300, effect=0.1, mde=0.1, seed=seed)
|
|
67
|
+
treat = [u.metrics["outcome"] for u in units if u.arm == "treatment"]
|
|
68
|
+
ctrl = [u.metrics["outcome"] for u in units if u.arm == "control"]
|
|
69
|
+
effect, _, _, p = welch(treat, ctrl)
|
|
70
|
+
if p >= design.alpha or effect <= 0:
|
|
71
|
+
continue
|
|
72
|
+
found = _confirm(units, design, "underpowered-winner", {"winner's curse"})
|
|
73
|
+
if found:
|
|
74
|
+
return found
|
|
75
|
+
raise RuntimeError("no significant underpowered experiment found in 500 seeds")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _find_metric_fisher() -> tuple[list[Unit], Design]:
|
|
79
|
+
for seed in range(500):
|
|
80
|
+
units, design = simulate(n=2000, effect=0.0, extra_metrics=40, seed=seed)
|
|
81
|
+
probe = uncorrected_winners(units, design)
|
|
82
|
+
if isinstance(probe, SkippedProbe) or not probe.triggered:
|
|
83
|
+
continue
|
|
84
|
+
found = _confirm(units, design, "metric-fisher", {"uncorrected winners"})
|
|
85
|
+
if found:
|
|
86
|
+
return found
|
|
87
|
+
raise RuntimeError("no experiment with uncorrected winners found in 500 seeds")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def build_experiments() -> list[tuple[str, list[Unit], Design]]:
|
|
91
|
+
return [
|
|
92
|
+
("clean", *simulate(n=8000, effect=0.2, mde=0.2, seed=11)),
|
|
93
|
+
("traffic-leak", *simulate(n=8000, effect=0.2, srm=0.03, mde=0.2, seed=12)),
|
|
94
|
+
("double-dipper", *simulate(n=4000, effect=0.2, contaminated=25, mde=0.2, seed=13)),
|
|
95
|
+
(
|
|
96
|
+
"whale-driven",
|
|
97
|
+
*simulate(n=1000, effect=0.12, whales=3, whale_value=15.0, mde=0.2, seed=2),
|
|
98
|
+
),
|
|
99
|
+
("peeker", *_find_peeker()),
|
|
100
|
+
("fading-novelty", *simulate(n=6000, effect=0.15, novelty=0.3, mde=0.15, seed=16)),
|
|
101
|
+
("metric-fisher", *_find_metric_fisher()),
|
|
102
|
+
("underpowered-winner", *_find_underpowered()),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cell(audit: AuditResult, probe_name: str, fmt: str) -> str:
|
|
107
|
+
for probe in audit.probes:
|
|
108
|
+
if probe.name == probe_name:
|
|
109
|
+
text = format(probe.value, fmt)
|
|
110
|
+
return f"**{text}**" if probe.triggered else text
|
|
111
|
+
return "-"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def demo_table(audits: list[AuditResult]) -> str:
|
|
115
|
+
lines = [
|
|
116
|
+
"| experiment | implanted defect | SRM p | contam. | fragility "
|
|
117
|
+
"| look FPR | novelty gap | uncorrected | exagg. | flags |",
|
|
118
|
+
"|---|---|---|---|---|---|---|---|---|---|",
|
|
119
|
+
]
|
|
120
|
+
curse = "winner's curse"
|
|
121
|
+
for audit in audits:
|
|
122
|
+
flags = ", ".join(p.name for p in audit.flags) or "none"
|
|
123
|
+
lines.append(
|
|
124
|
+
f"| {audit.experiment} | {DEFECTS[audit.experiment]} "
|
|
125
|
+
f"| {_cell(audit, 'sample ratio mismatch', '.2g')} "
|
|
126
|
+
f"| {_cell(audit, 'assignment contamination', '.0f')} "
|
|
127
|
+
f"| {_cell(audit, 'outlier fragility', '.0f')} "
|
|
128
|
+
f"| {_cell(audit, 'peeking', '.2f')} "
|
|
129
|
+
f"| {_cell(audit, 'novelty', '.2f')} "
|
|
130
|
+
f"| {_cell(audit, 'uncorrected winners', '.0f')} "
|
|
131
|
+
f"| {_cell(audit, curse, '.1f')} "
|
|
132
|
+
f"| {flags} |"
|
|
133
|
+
)
|
|
134
|
+
return "\n".join(lines)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def run_demo(out_dir: str | Path) -> list[AuditResult]:
|
|
138
|
+
out = Path(out_dir)
|
|
139
|
+
figures = out / "figures"
|
|
140
|
+
figures.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
experiments = build_experiments()
|
|
142
|
+
audits = []
|
|
143
|
+
sections = []
|
|
144
|
+
marks = []
|
|
145
|
+
for name, units, design in experiments:
|
|
146
|
+
audit = run_audit(units, design, name=name)
|
|
147
|
+
audits.append(audit)
|
|
148
|
+
sections.append(render_report(audit).replace("# abkit audit:", "## "))
|
|
149
|
+
if (
|
|
150
|
+
name in ("clean", "whale-driven", "underpowered-winner")
|
|
151
|
+
and audit.summary
|
|
152
|
+
and design.mde is not None
|
|
153
|
+
):
|
|
154
|
+
se = float(audit.summary["se"]) # type: ignore[arg-type]
|
|
155
|
+
power, _, ratio = design_analysis(design.mde, se, design.alpha)
|
|
156
|
+
if float(audit.summary["p"]) < design.alpha: # type: ignore[arg-type]
|
|
157
|
+
marks.append((name, power, ratio))
|
|
158
|
+
if name == "peeker" and design.looks:
|
|
159
|
+
peeking_figure(design.looks, design.alpha, figures / "peeking.png")
|
|
160
|
+
if name == "whale-driven":
|
|
161
|
+
treat = [u.metrics["outcome"] for u in units if u.arm == "treatment"]
|
|
162
|
+
ctrl = [u.metrics["outcome"] for u in units if u.arm == "control"]
|
|
163
|
+
fragility_figure(treat, ctrl, design.alpha, figures / "fragility.png")
|
|
164
|
+
exaggeration_figure(marks, 0.05, figures / "exaggeration.png")
|
|
165
|
+
|
|
166
|
+
table = demo_table(audits)
|
|
167
|
+
(out / "table.md").write_text(table + "\n", encoding="utf-8")
|
|
168
|
+
header = (
|
|
169
|
+
"# abkit demo\n\n"
|
|
170
|
+
"Eight synthetic experiments, seven implanted defects. Bold values are\n"
|
|
171
|
+
"flags; each lands on the row where its defect was implanted.\n\n"
|
|
172
|
+
)
|
|
173
|
+
(out / "report.md").write_text(header + table + "\n\n" + "\n".join(sections), encoding="utf-8")
|
|
174
|
+
return audits
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
__all__ = ["build_experiments", "demo_table", "run_demo", "DEFECTS"]
|
abkit/io.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Read and write experiment data.
|
|
2
|
+
|
|
3
|
+
Units are one JSON object per line; the design is a single JSON document.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from .schema import Design, Unit
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def load_units(path: str | Path) -> list[Unit]:
|
|
15
|
+
units = []
|
|
16
|
+
with open(path, encoding="utf-8") as handle:
|
|
17
|
+
for line in handle:
|
|
18
|
+
line = line.strip()
|
|
19
|
+
if line:
|
|
20
|
+
units.append(Unit.model_validate_json(line))
|
|
21
|
+
return units
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def dump_units(units: list[Unit], path: str | Path) -> None:
|
|
25
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
26
|
+
for unit in units:
|
|
27
|
+
handle.write(unit.model_dump_json(exclude_defaults=True) + "\n")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def load_design(path: str | Path) -> Design:
|
|
31
|
+
return Design.model_validate_json(Path(path).read_text(encoding="utf-8"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def dump_design(design: Design, path: str | Path) -> None:
|
|
35
|
+
Path(path).write_text(
|
|
36
|
+
json.dumps(design.model_dump(exclude_none=True), indent=2) + "\n",
|
|
37
|
+
encoding="utf-8",
|
|
38
|
+
)
|