bad-decisions 1.0.4__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.
@@ -0,0 +1,3 @@
1
+ """Reusable engine and service for fill-in-the-blank party card games."""
2
+
3
+ __version__ = "1.0.4"
bad_decisions/api.py ADDED
@@ -0,0 +1,169 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import re
5
+ import time
6
+ import uuid
7
+ from contextlib import asynccontextmanager
8
+ from importlib.resources import files
9
+ from typing import Annotated
10
+
11
+ from fastapi import FastAPI, Query, Request
12
+ from fastapi.exceptions import RequestValidationError
13
+ from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, PlainTextResponse
14
+ from starlette.exceptions import HTTPException as StarletteHTTPException
15
+
16
+ from . import __version__
17
+ from .engine import generate_from_resolved
18
+ from .errors import BadDecisionsError
19
+ from .models import Round
20
+ from .packs import Registry, load_registry, resolve_pools
21
+ from .settings import Settings
22
+
23
+ REQUEST_ID = re.compile(r"^[A-Za-z0-9._-]{1,128}$")
24
+ WEB_ASSETS = {"index.html": "text/html; charset=utf-8", "app.js": "application/javascript; charset=utf-8", "style.css": "text/css; charset=utf-8"}
25
+
26
+
27
+ def envelope(code: str, message: str, details: dict | None = None) -> dict:
28
+ return {"error": {"code": code, "message": message, "details": details or {}}}
29
+
30
+
31
+ def create_app() -> FastAPI:
32
+ settings = Settings.from_env()
33
+ logging.basicConfig(level=settings.log_level, format="%(asctime)s %(levelname)s %(message)s")
34
+ logger = logging.getLogger("bad_decisions.api")
35
+
36
+ @asynccontextmanager
37
+ async def lifespan(app: FastAPI):
38
+ app.state.registry = load_registry(settings.pack_dir)
39
+ app.state.ready = True
40
+ yield
41
+ app.state.ready = False
42
+
43
+ app = FastAPI(title="Bad Decisions API", version=__version__, lifespan=lifespan, root_path=settings.root_path)
44
+
45
+ @app.middleware("http")
46
+ async def request_context(request: Request, call_next):
47
+ supplied = request.headers.get("x-request-id", "")
48
+ request_id = supplied if REQUEST_ID.fullmatch(supplied) else uuid.uuid4().hex
49
+ started = time.monotonic()
50
+ try:
51
+ response = await call_next(request)
52
+ except Exception:
53
+ logger.exception("unhandled request failure request_id=%s", request_id)
54
+ response = JSONResponse(envelope("internal_error", "Internal server error", {"request_id": request_id}), status_code=500)
55
+ response.headers["X-Request-ID"] = request_id
56
+ logger.info(
57
+ "request method=%s path=%s status=%s duration_ms=%.2f request_id=%s",
58
+ request.method,
59
+ request.url.path,
60
+ response.status_code,
61
+ (time.monotonic() - started) * 1000,
62
+ request_id,
63
+ )
64
+ return response
65
+
66
+ @app.exception_handler(BadDecisionsError)
67
+ async def domain_error(_request: Request, exc: BadDecisionsError):
68
+ return JSONResponse(envelope(exc.code, exc.message, exc.details), status_code=400)
69
+
70
+ @app.exception_handler(RequestValidationError)
71
+ async def validation_error(_request: Request, exc: RequestValidationError):
72
+ return JSONResponse(envelope("request_validation", "Request validation failed", {"errors": exc.errors()}), status_code=422)
73
+
74
+ @app.exception_handler(StarletteHTTPException)
75
+ async def http_error(_request: Request, exc: StarletteHTTPException):
76
+ if exc.status_code == 404:
77
+ return JSONResponse(envelope("path_not_found", "Path not found"), status_code=404)
78
+ return JSONResponse(envelope("http_error", "HTTP error", {"status": exc.status_code}), status_code=exc.status_code)
79
+
80
+ @app.get("/healthz")
81
+ def health(request: Request):
82
+ ready = bool(getattr(request.app.state, "ready", False))
83
+ registry: Registry | None = getattr(request.app.state, "registry", None)
84
+ return JSONResponse(
85
+ {"status": "ok" if ready else "unavailable", "version": __version__, "pack_count": len(registry.packs) if registry else 0},
86
+ status_code=200 if ready else 503,
87
+ )
88
+
89
+ @app.get("/", response_class=PlainTextResponse, include_in_schema=False)
90
+ def howto():
91
+ prefix = settings.root_path.rstrip("/")
92
+ return f"""Bad Decisions API
93
+
94
+ Reusable service for fill-in-the-blank card games.
95
+
96
+ Deal a completed hand:
97
+ GET {prefix}/v1/round
98
+ GET {prefix}/v1/round?packs=example
99
+ GET {prefix}/v1/round?black_packs=prompts&white_packs=responses
100
+
101
+ Inspect the loaded registry:
102
+ GET {prefix}/v1/packs
103
+ GET {prefix}/v1/packs/{{pack_id}}
104
+
105
+ Portable packs:
106
+ bad-decisions pack validate example.carddeck
107
+ Format documentation: CARDDECK.md
108
+
109
+ Service interfaces:
110
+ Interactive API docs: {prefix}/docs
111
+ OpenAPI schema: {prefix}/openapi.json
112
+ Health: {prefix}/healthz
113
+ """
114
+
115
+ def web_asset(name: str):
116
+ media_type = WEB_ASSETS.get(name)
117
+ if media_type is None:
118
+ return JSONResponse(envelope("path_not_found", "Path not found"), status_code=404)
119
+ return FileResponse(str(files("bad_decisions").joinpath("web", name)), media_type=media_type)
120
+
121
+ @app.get("/web", include_in_schema=False)
122
+ @app.get("/web/", include_in_schema=False)
123
+ def web_index(request: Request):
124
+ root_path = request.scope.get("root_path", "").rstrip("/")
125
+ asset_base = f"{root_path}/web/" or "/web/"
126
+ document = files("bad_decisions").joinpath("web", "index.html").read_text(encoding="utf-8")
127
+ return HTMLResponse(document.replace("__WEB_BASE__", asset_base))
128
+
129
+ @app.get("/web/{asset:path}", include_in_schema=False)
130
+ def web_file(asset: str):
131
+ return web_asset(asset)
132
+
133
+ def metadata(pack):
134
+ result = pack.metadata.model_dump(mode="json")
135
+ result["counts"] = {"black": len(pack.black), "white": len(pack.white)}
136
+ return result
137
+
138
+ @app.get("/v1/packs")
139
+ def list_packs(request: Request):
140
+ return [metadata(pack) for pack in request.app.state.registry.packs.values()]
141
+
142
+ @app.get("/v1/packs/{pack_id}")
143
+ def get_pack(pack_id: str, request: Request):
144
+ pack = request.app.state.registry.packs.get(pack_id)
145
+ if pack is None:
146
+ return JSONResponse(envelope("pack_not_found", f"Pack not found: {pack_id}", {"available_packs": list(request.app.state.registry.ids)}), status_code=404)
147
+ return metadata(pack)
148
+
149
+ @app.get("/v1/round", response_model=Round)
150
+ def round_endpoint(
151
+ request: Request,
152
+ packs: Annotated[str, Query()] = "base",
153
+ black_packs: Annotated[str | None, Query()] = None,
154
+ white_packs: Annotated[str | None, Query()] = None,
155
+ ):
156
+ allowed = {"packs", "black_packs", "white_packs"}
157
+ unknown = sorted(set(request.query_params) - allowed)
158
+ repeated = sorted(key for key in allowed if len(request.query_params.getlist(key)) > 1)
159
+ if unknown:
160
+ return JSONResponse(envelope("unknown_query_parameter", "Unknown query parameter", {"parameters": unknown}), status_code=400)
161
+ if repeated:
162
+ return JSONResponse(envelope("repeated_query_parameter", "Selector parameters must occur once", {"parameters": repeated}), status_code=400)
163
+ registry = request.app.state.registry
164
+ resolved = resolve_pools(registry, packs=packs, black_packs=black_packs, white_packs=white_packs)
165
+ response = JSONResponse(generate_from_resolved(resolved, registry).model_dump(mode="json"))
166
+ response.headers["Cache-Control"] = "no-store"
167
+ return response
168
+
169
+ return app
@@ -0,0 +1,199 @@
1
+ """Portable, offline CardDeck archive support."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ import os
8
+ import stat
9
+ import tempfile
10
+ import zipfile
11
+ from importlib.resources import files
12
+ from pathlib import Path
13
+
14
+ from pydantic import BaseModel, ConfigDict, Field, ValidationError
15
+
16
+ from .errors import PackConfigurationError
17
+ from .models import ID_PATTERN, Pack
18
+
19
+ FORMAT = "carddeck"
20
+ FORMAT_VERSION = 1
21
+ CARDDECK_SUFFIX = ".carddeck"
22
+ REQUIRED_MEMBERS = frozenset({"manifest.json", "pack.json", "LICENSE.txt", "ATTRIBUTION.md"})
23
+ MAX_MEMBER_BYTES = 2 * 1024 * 1024
24
+ MAX_ARCHIVE_BYTES = 5 * 1024 * 1024
25
+ MAX_COMPRESSION_RATIO = 100
26
+
27
+
28
+ class ArchiveManifest(BaseModel):
29
+ model_config = ConfigDict(extra="forbid", strict=True)
30
+
31
+ format: str
32
+ format_version: int
33
+ pack_id: str = Field(pattern=ID_PATTERN)
34
+ pack_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
35
+
36
+
37
+ def _error(message: str) -> PackConfigurationError:
38
+ return PackConfigurationError(f"carddeck: {message}")
39
+
40
+
41
+ def _format_for_path(path: Path) -> str:
42
+ if path.suffix == CARDDECK_SUFFIX:
43
+ return FORMAT
44
+ raise _error("archive filename must end in .carddeck")
45
+
46
+
47
+ def _canonical_json(value: object) -> bytes:
48
+ return (json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")) + "\n").encode("utf-8")
49
+
50
+
51
+ def _pack_payload(pack: Pack) -> bytes:
52
+ return _canonical_json(pack.model_dump(mode="json"))
53
+
54
+
55
+ def _read_archive(path: Path) -> tuple[ArchiveManifest, Pack, bytes, str, str]:
56
+ expected_format = _format_for_path(path)
57
+ try:
58
+ with zipfile.ZipFile(path) as archive:
59
+ infos = archive.infolist()
60
+ names = [info.filename for info in infos]
61
+ if len(infos) != len(REQUIRED_MEMBERS) or set(names) != REQUIRED_MEMBERS:
62
+ raise _error("archive must contain exactly manifest.json, pack.json, LICENSE.txt, and ATTRIBUTION.md")
63
+ total = 0
64
+ for info in infos:
65
+ if Path(info.filename).name != info.filename or info.is_dir():
66
+ raise _error(f"unsafe archive member: {info.filename!r}")
67
+ if stat.S_ISLNK(info.external_attr >> 16):
68
+ raise _error(f"symbolic links are not allowed: {info.filename!r}")
69
+ if info.file_size > MAX_MEMBER_BYTES:
70
+ raise _error(f"archive member is too large: {info.filename!r}")
71
+ if info.file_size and not info.compress_size:
72
+ raise _error(f"invalid compressed member: {info.filename!r}")
73
+ if info.compress_size and info.file_size / info.compress_size > MAX_COMPRESSION_RATIO:
74
+ raise _error(f"archive member compression ratio is too high: {info.filename!r}")
75
+ total += info.file_size
76
+ if total > MAX_ARCHIVE_BYTES:
77
+ raise _error("archive is too large")
78
+ members = {name: archive.read(name) for name in REQUIRED_MEMBERS}
79
+ except (OSError, zipfile.BadZipFile, RuntimeError) as exc:
80
+ raise _error(f"cannot read archive: {exc}") from exc
81
+
82
+ try:
83
+ manifest = ArchiveManifest.model_validate_json(members["manifest.json"])
84
+ except ValidationError as exc:
85
+ raise _error(f"invalid manifest: {exc}") from exc
86
+ if manifest.format != expected_format or manifest.format_version != FORMAT_VERSION:
87
+ raise _error("unsupported archive format or version")
88
+ payload = members["pack.json"]
89
+ if hashlib.sha256(payload).hexdigest() != manifest.pack_sha256:
90
+ raise _error("pack.json checksum does not match manifest")
91
+ try:
92
+ pack = Pack.model_validate_json(payload)
93
+ except ValidationError as exc:
94
+ raise _error(f"invalid pack.json: {exc}") from exc
95
+ if pack.metadata.id != manifest.pack_id:
96
+ raise _error("manifest pack_id does not match pack metadata")
97
+ try:
98
+ license_text = members["LICENSE.txt"].decode("utf-8").strip()
99
+ attribution = members["ATTRIBUTION.md"].decode("utf-8").strip()
100
+ except UnicodeDecodeError as exc:
101
+ raise _error("license and attribution files must be UTF-8") from exc
102
+ if not license_text or not attribution:
103
+ raise _error("license and attribution files must not be empty")
104
+ return manifest, pack, payload, license_text, attribution
105
+
106
+
107
+ def validate_archive(path: str | Path) -> Pack:
108
+ """Validate an archive fully and return its pack without mutating disk."""
109
+
110
+ _, pack, _, _, _ = _read_archive(Path(path))
111
+ return pack
112
+
113
+
114
+ def export_pack(pack: Pack, destination: str | Path) -> Path:
115
+ """Write a deterministic portable archive, refusing an existing target."""
116
+
117
+ target = Path(destination)
118
+ format_name = _format_for_path(target)
119
+ if target.exists():
120
+ raise _error(f"destination already exists: {target}")
121
+ target.parent.mkdir(parents=True, exist_ok=True)
122
+ payload = _pack_payload(pack)
123
+ manifest = ArchiveManifest(
124
+ format=format_name,
125
+ format_version=FORMAT_VERSION,
126
+ pack_id=pack.metadata.id,
127
+ pack_sha256=hashlib.sha256(payload).hexdigest(),
128
+ )
129
+ contents = {
130
+ "manifest.json": _canonical_json(manifest.model_dump()),
131
+ "pack.json": payload,
132
+ "LICENSE.txt": (pack.metadata.license_notice.strip() + "\n").encode("utf-8"),
133
+ "ATTRIBUTION.md": (pack.metadata.attribution.strip() + "\n").encode("utf-8"),
134
+ }
135
+ try:
136
+ with zipfile.ZipFile(target, "x", compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
137
+ for name in sorted(contents):
138
+ info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
139
+ info.compress_type = zipfile.ZIP_DEFLATED
140
+ info.external_attr = 0o100644 << 16
141
+ archive.writestr(info, contents[name])
142
+ except (OSError, zipfile.BadZipFile) as exc:
143
+ raise _error(f"cannot create archive: {exc}") from exc
144
+ return target
145
+
146
+
147
+ def import_pack(archive_path: str | Path, registry_dir: str | Path) -> Path:
148
+ """Atomically install an archive's JSON pack into an absolute registry directory."""
149
+
150
+ _, pack, payload, _, _ = _read_archive(Path(archive_path))
151
+ destination_dir = Path(registry_dir)
152
+ if not destination_dir.is_absolute():
153
+ raise _error("registry directory must be an absolute path")
154
+ destination_dir.mkdir(parents=True, exist_ok=True)
155
+ if not destination_dir.is_dir():
156
+ raise _error(f"registry path is not a directory: {destination_dir}")
157
+ destination = destination_dir / f"{pack.metadata.id}.json"
158
+ if destination.exists():
159
+ raise _error(f"refusing to overwrite existing pack: {destination}")
160
+ try:
161
+ with tempfile.NamedTemporaryFile("wb", dir=destination_dir, prefix=".carddeck-", delete=False) as handle:
162
+ temporary = Path(handle.name)
163
+ handle.write(payload)
164
+ os.chmod(temporary, 0o644)
165
+ os.replace(temporary, destination)
166
+ except OSError as exc:
167
+ try:
168
+ temporary.unlink(missing_ok=True)
169
+ except UnboundLocalError:
170
+ pass
171
+ raise _error(f"cannot import pack: {exc}") from exc
172
+ return destination
173
+
174
+
175
+ def initialize_registry(registry_dir: str | Path) -> tuple[Path, ...]:
176
+ """Create an empty absolute registry directory populated with bundled packs."""
177
+
178
+ destination_dir = Path(registry_dir)
179
+ if not destination_dir.is_absolute():
180
+ raise _error("registry directory must be an absolute path")
181
+ destination_dir.mkdir(parents=True, exist_ok=True)
182
+ if not destination_dir.is_dir():
183
+ raise _error(f"registry path is not a directory: {destination_dir}")
184
+ if any(destination_dir.iterdir()):
185
+ raise _error(f"refusing to initialize non-empty registry: {destination_dir}")
186
+ source_dir = files("bad_decisions").joinpath("data/packs")
187
+ copied: list[Path] = []
188
+ try:
189
+ for source in sorted(source_dir.iterdir(), key=lambda item: item.name):
190
+ if source.name.endswith(".json"):
191
+ target = destination_dir / source.name
192
+ target.write_bytes(source.read_bytes())
193
+ os.chmod(target, 0o644)
194
+ copied.append(target)
195
+ except OSError as exc:
196
+ raise _error(f"cannot initialize registry: {exc}") from exc
197
+ if not copied:
198
+ raise _error("no bundled pack files found")
199
+ return tuple(copied)
bad_decisions/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import math
5
+ import signal
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Sequence
10
+
11
+ from .archive import export_pack, import_pack, initialize_registry, validate_archive
12
+ from .engine import generate_from_resolved, render_round
13
+ from .errors import BadDecisionsError, PackConfigurationError, UnknownPackError
14
+ from .packs import load_registry, resolve_pools
15
+ from . import operations
16
+
17
+
18
+ class CliArgumentError(ValueError):
19
+ pass
20
+
21
+
22
+ def positive_finite(value: str) -> float:
23
+ try:
24
+ number = float(value)
25
+ except ValueError as exc:
26
+ raise argparse.ArgumentTypeError("must be a number") from exc
27
+ if not math.isfinite(number) or number <= 0:
28
+ raise argparse.ArgumentTypeError("must be finite and greater than zero")
29
+ return number
30
+
31
+
32
+ def parser() -> argparse.ArgumentParser:
33
+ result = argparse.ArgumentParser(description="Deal hands and manage a Bad Decisions service.")
34
+ mode = result.add_mutually_exclusive_group()
35
+ mode.add_argument("--oneshot", action="store_true", help="print one rendered round and exit")
36
+ mode.add_argument("--rapid", action="store_true", help="continuously print completed rounds")
37
+ result.add_argument("--delay", type=positive_finite, help="positive seconds between rapid rounds (default: 1.0)")
38
+ result.add_argument("--packs", default="base", help="comma-separated pack IDs for both colors (default: base)")
39
+ result.add_argument("--black-packs", help="override the black-card selector")
40
+ result.add_argument("--white-packs", help="override the white-card selector")
41
+ result.add_argument("--list-packs", action="store_true", help="list available packs and exit")
42
+ return result
43
+
44
+
45
+ def pack_parser() -> argparse.ArgumentParser:
46
+ result = argparse.ArgumentParser(prog="bad-decisions pack", description="Validate, export, or import portable .carddeck archives.")
47
+ commands = result.add_subparsers(dest="command", required=True)
48
+ validate = commands.add_parser("validate", help="validate an archive without changing disk")
49
+ validate.add_argument("archive", type=Path)
50
+ export = commands.add_parser("export", help="export one registered pack to an archive")
51
+ export.add_argument("pack_id")
52
+ export.add_argument("archive", type=Path)
53
+ imported = commands.add_parser("import", help="import an archive into an absolute pack registry directory")
54
+ imported.add_argument("archive", type=Path)
55
+ imported.add_argument("registry_dir", type=Path)
56
+ initialized = commands.add_parser("init-registry", help="initialize an empty absolute registry with bundled packs")
57
+ initialized.add_argument("registry_dir", type=Path)
58
+ return result
59
+
60
+
61
+ def _write(text: str = "", *, stream=sys.stdout, flush: bool = False) -> None:
62
+ print(text, file=stream, flush=flush)
63
+
64
+
65
+ def _interactive(resolved, registry) -> int:
66
+ if not sys.stdin.isatty() or not sys.stdout.isatty():
67
+ _write("Interactive mode requires a terminal; use --oneshot or --rapid.", stream=sys.stderr)
68
+ return 2
69
+ while True:
70
+ generated = generate_from_resolved(resolved, registry)
71
+ _write()
72
+ _write("═" * 72)
73
+ _write(generated.black.repr)
74
+ try:
75
+ command = input("\nPress Enter to reveal, or q to quit: ").strip().lower()
76
+ except (EOFError, KeyboardInterrupt):
77
+ _write()
78
+ return 0
79
+ if command == "q":
80
+ return 0
81
+ _write()
82
+ for answer in generated.white:
83
+ _write(f"⬜ {answer.text}")
84
+ _write(f"\n→ {generated.result}")
85
+ try:
86
+ command = input("\nPress Enter for another round, or q to quit: ").strip().lower()
87
+ except (EOFError, KeyboardInterrupt):
88
+ _write()
89
+ return 0
90
+ if command == "q":
91
+ return 0
92
+
93
+
94
+ def _run_pack(argv: Sequence[str]) -> int:
95
+ args = pack_parser().parse_args(argv)
96
+ if args.command == "validate":
97
+ pack = validate_archive(args.archive)
98
+ _write(f"valid carddeck: {pack.metadata.id}")
99
+ return 0
100
+ if args.command == "export":
101
+ registry = load_registry()
102
+ if args.pack_id not in registry.packs:
103
+ raise UnknownPackError(f"Unknown pack: {args.pack_id}", {"available_packs": list(registry.ids)})
104
+ target = export_pack(registry.packs[args.pack_id], args.archive)
105
+ _write(f"exported {args.pack_id} to {target}")
106
+ return 0
107
+ if args.command == "init-registry":
108
+ copied = initialize_registry(args.registry_dir)
109
+ _write(f"initialized registry with {len(copied)} bundled packs at {args.registry_dir}")
110
+ return 0
111
+ target = import_pack(args.archive, args.registry_dir)
112
+ _write(f"imported {target.stem} to {target}")
113
+ return 0
114
+
115
+
116
+ def run(argv: Sequence[str] | None = None, *, sleep=time.sleep) -> int:
117
+ values = list(sys.argv[1:] if argv is None else argv)
118
+ if values and values[0] in {"setup", "serve", "deploy", "status", "reload", "stop"}:
119
+ return operations.run(values)
120
+ if values and values[0] == "pack":
121
+ return _run_pack(values[1:])
122
+ args = parser().parse_args(values)
123
+ if args.delay is not None and not args.rapid:
124
+ parser().error("--delay may only be used with --rapid")
125
+ registry = load_registry()
126
+ if args.list_packs:
127
+ for pack_id, pack in registry.packs.items():
128
+ _write(f"{pack_id}\t{pack.metadata.name}\tblack={len(pack.black)}\twhite={len(pack.white)}")
129
+ return 0
130
+ resolved = resolve_pools(registry, packs=args.packs, black_packs=args.black_packs, white_packs=args.white_packs)
131
+ if args.oneshot:
132
+ _write(generate_from_resolved(resolved, registry).result)
133
+ return 0
134
+ if args.rapid:
135
+ delay = args.delay if args.delay is not None else 1.0
136
+ while True:
137
+ _write(generate_from_resolved(resolved, registry).result, flush=True)
138
+ sleep(delay)
139
+ return _interactive(resolved, registry)
140
+
141
+
142
+ def main(argv: Sequence[str] | None = None) -> int:
143
+ signal.signal(signal.SIGPIPE, signal.SIG_DFL)
144
+ try:
145
+ return run(argv)
146
+ except KeyboardInterrupt:
147
+ return 0
148
+ except PackConfigurationError as exc:
149
+ _write(f"configuration error: {exc.message}", stream=sys.stderr)
150
+ return 1
151
+ except BadDecisionsError as exc:
152
+ _write(f"{exc.code}: {exc.message}", stream=sys.stderr)
153
+ return 2
154
+ except BrokenPipeError:
155
+ return 0
156
+
157
+
158
+ if __name__ == "__main__":
159
+ raise SystemExit(main())