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.
- bad_decisions/__init__.py +3 -0
- bad_decisions/api.py +169 -0
- bad_decisions/archive.py +199 -0
- bad_decisions/cli.py +159 -0
- bad_decisions/data/packs/base.json +3674 -0
- bad_decisions/data/packs/maha.json +562 -0
- bad_decisions/engine.py +56 -0
- bad_decisions/errors.py +36 -0
- bad_decisions/models.py +174 -0
- bad_decisions/operations.py +93 -0
- bad_decisions/packs.py +103 -0
- bad_decisions/settings.py +22 -0
- bad_decisions/web/app.js +90 -0
- bad_decisions/web/index.html +81 -0
- bad_decisions/web/style.css +46 -0
- bad_decisions-1.0.4.dist-info/METADATA +124 -0
- bad_decisions-1.0.4.dist-info/RECORD +20 -0
- bad_decisions-1.0.4.dist-info/WHEEL +4 -0
- bad_decisions-1.0.4.dist-info/entry_points.txt +2 -0
- bad_decisions-1.0.4.dist-info/licenses/LICENSE +25 -0
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
|
bad_decisions/archive.py
ADDED
|
@@ -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())
|