bad-decisions-client 1.0.1__tar.gz

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,28 @@
1
+ # Python environments and caches
2
+ .venv/
3
+ .pypi-venv/
4
+ __pycache__/
5
+ *.py[cod]
6
+ .pytest_cache/
7
+
8
+ # Build and package artifacts
9
+ dist/
10
+ build/
11
+ *.egg-info/
12
+ client/dist/
13
+ client/build/
14
+ client/*.egg-info/
15
+ exports/
16
+
17
+ # Local configuration and secrets
18
+ .env
19
+ .env.*
20
+ *.env
21
+ !.env.example
22
+ *.pem
23
+ *.key
24
+
25
+ # Editor and operating-system metadata
26
+ .DS_Store
27
+ .vscode/
28
+ .idea/
@@ -0,0 +1,43 @@
1
+ Metadata-Version: 2.4
2
+ Name: bad-decisions-client
3
+ Version: 1.0.1
4
+ Summary: Cross-platform terminal client for Bad Decisions services
5
+ Project-URL: Homepage, https://github.com/BytesAndCoffee/cards-against-coffee
6
+ Project-URL: Documentation, https://github.com/BytesAndCoffee/cards-against-coffee#readme
7
+ Author: Bad Decisions contributors
8
+ License: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3 :: Only
14
+ Requires-Python: >=3.9
15
+ Description-Content-Type: text/markdown
16
+
17
+ # Bad Decisions terminal client
18
+
19
+ `bad-decisions-client` is a dependency-free terminal client for a Bad
20
+ Decisions service. It contains no card corpus and runs on Linux, macOS, and
21
+ Windows.
22
+
23
+ ```bash
24
+ python -m pip install bad-decisions-client
25
+ regret health
26
+ regret deal
27
+ regret deal --packs base,maha
28
+ regret --list-packs
29
+ ```
30
+
31
+ The default endpoint is `https://bytes.coffee/bad-decisions`. Use `--api-url`
32
+ for another compatible deployment. Connection and API failures return a
33
+ non-zero exit status.
34
+
35
+ ## Release
36
+
37
+ Build and validate before uploading a new immutable PyPI version:
38
+
39
+ ```bash
40
+ cd client
41
+ python -m build
42
+ twine check dist/*
43
+ ```
@@ -0,0 +1,27 @@
1
+ # Bad Decisions terminal client
2
+
3
+ `bad-decisions-client` is a dependency-free terminal client for a Bad
4
+ Decisions service. It contains no card corpus and runs on Linux, macOS, and
5
+ Windows.
6
+
7
+ ```bash
8
+ python -m pip install bad-decisions-client
9
+ regret health
10
+ regret deal
11
+ regret deal --packs base,maha
12
+ regret --list-packs
13
+ ```
14
+
15
+ The default endpoint is `https://bytes.coffee/bad-decisions`. Use `--api-url`
16
+ for another compatible deployment. Connection and API failures return a
17
+ non-zero exit status.
18
+
19
+ ## Release
20
+
21
+ Build and validate before uploading a new immutable PyPI version:
22
+
23
+ ```bash
24
+ cd client
25
+ python -m build
26
+ twine check dist/*
27
+ ```
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["hatchling==1.27.0"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "bad-decisions-client"
7
+ version = "1.0.1"
8
+ description = "Cross-platform terminal client for Bad Decisions services"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Bad Decisions contributors" }]
13
+ classifiers = ["Development Status :: 3 - Alpha", "Environment :: Console", "License :: OSI Approved :: MIT License", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only"]
14
+
15
+ [project.scripts]
16
+ regret = "bad_decisions_client.cli:main"
17
+
18
+ [project.urls]
19
+ Homepage = "https://github.com/BytesAndCoffee/cards-against-coffee"
20
+ Documentation = "https://github.com/BytesAndCoffee/cards-against-coffee#readme"
21
+
22
+ [tool.hatch.build.targets.wheel]
23
+ packages = ["src/bad_decisions_client"]
24
+
25
+ [tool.pytest.ini_options]
26
+ addopts = "-q"
27
+ testpaths = ["tests"]
@@ -0,0 +1,3 @@
1
+ """Dependency-free, cross-platform client for Bad Decisions services."""
2
+
3
+ __version__ = "1.0.1"
@@ -0,0 +1,103 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from typing import Any, Sequence
7
+ from urllib.error import HTTPError, URLError
8
+ from urllib.parse import urlencode
9
+ from urllib.request import Request, urlopen
10
+
11
+ from . import __version__
12
+
13
+ DEFAULT_API_URL = "https://bytes.coffee/bad-decisions"
14
+
15
+
16
+ def parser() -> argparse.ArgumentParser:
17
+ result = argparse.ArgumentParser(description="Deal a hand from a Bad Decisions service.")
18
+ result.add_argument("--api-url", default=DEFAULT_API_URL, help="API base URL (default: %(default)s)")
19
+ result.add_argument("--packs", help="comma-separated packs for both colors")
20
+ result.add_argument("--black-packs", help="comma-separated black-card packs")
21
+ result.add_argument("--white-packs", help="comma-separated white-card packs")
22
+ result.add_argument("--list-packs", action="store_true", help="list API pack metadata")
23
+ result.add_argument("--health", action="store_true", help="check API health")
24
+ result.add_argument("--json", action="store_true", help="print the complete JSON response")
25
+ result.add_argument("--timeout", type=float, default=10.0, help="request timeout in seconds (default: %(default)s)")
26
+ return result
27
+
28
+
29
+ def _base_url(value: str) -> str:
30
+ url = value.strip().rstrip("/")
31
+ if not url.startswith(("http://", "https://")):
32
+ raise ValueError("--api-url must start with http:// or https://")
33
+ return url
34
+
35
+
36
+ def _get_json(url: str, *, timeout: float) -> Any:
37
+ request = Request(url, headers={"Accept": "application/json", "User-Agent": f"bad-decisions-client/{__version__}"})
38
+ try:
39
+ with urlopen(request, timeout=timeout) as response:
40
+ return json.loads(response.read().decode("utf-8"))
41
+ except HTTPError as exc:
42
+ try:
43
+ payload = json.loads(exc.read().decode("utf-8"))
44
+ error = payload.get("error", {})
45
+ message = error.get("message", f"HTTP {exc.code}")
46
+ code = error.get("code")
47
+ raise RuntimeError(f"{code}: {message}" if code else message) from exc
48
+ except (UnicodeDecodeError, json.JSONDecodeError):
49
+ raise RuntimeError(f"HTTP {exc.code}: {exc.reason}") from exc
50
+ except (URLError, TimeoutError) as exc:
51
+ raise RuntimeError(f"Cannot reach API: {exc.reason if isinstance(exc, URLError) else exc}") from exc
52
+
53
+
54
+ def _print_packs(packs: list[dict[str, Any]]) -> None:
55
+ for pack in packs:
56
+ counts = pack.get("counts", {})
57
+ print(f"{pack['id']}\t{pack['name']}\tblack={counts.get('black', 0)}\twhite={counts.get('white', 0)}")
58
+
59
+
60
+ def run(argv: Sequence[str] | None = None) -> int:
61
+ values = list(sys.argv[1:] if argv is None else argv)
62
+ if values and values[0] == "health":
63
+ values[0] = "--health"
64
+ elif values and values[0] == "deal":
65
+ values.pop(0)
66
+ args = parser().parse_args(values)
67
+ if args.timeout <= 0:
68
+ parser().error("--timeout must be greater than zero")
69
+ if args.list_packs and args.health:
70
+ parser().error("--list-packs and --health cannot be combined")
71
+ try:
72
+ base = _base_url(args.api_url)
73
+ if args.health:
74
+ payload = _get_json(f"{base}/healthz", timeout=args.timeout)
75
+ print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else payload["status"])
76
+ return 0
77
+ if args.list_packs:
78
+ payload = _get_json(f"{base}/v1/packs", timeout=args.timeout)
79
+ if args.json:
80
+ print(json.dumps(payload, ensure_ascii=False, indent=2))
81
+ else:
82
+ _print_packs(payload)
83
+ return 0
84
+ params = {key: value for key, value in {"packs": args.packs, "black_packs": args.black_packs, "white_packs": args.white_packs}.items() if value is not None}
85
+ query = f"?{urlencode(params)}" if params else ""
86
+ payload = _get_json(f"{base}/v1/round{query}", timeout=args.timeout)
87
+ print(json.dumps(payload, ensure_ascii=False, indent=2) if args.json else payload["result"])
88
+ return 0
89
+ except (RuntimeError, ValueError, KeyError) as exc:
90
+ print(f"regret: {exc}", file=sys.stderr)
91
+ return 1
92
+
93
+
94
+ def main() -> int:
95
+ return run()
96
+
97
+
98
+ def legacy_main() -> int:
99
+ return run()
100
+
101
+
102
+ if __name__ == "__main__":
103
+ raise SystemExit(main())
@@ -0,0 +1,41 @@
1
+ from __future__ import annotations
2
+
3
+ from unittest.mock import patch
4
+
5
+ from bad_decisions_client import cli
6
+
7
+
8
+ class Response:
9
+ def __init__(self, payload: str):
10
+ self.payload = payload
11
+
12
+ def read(self) -> bytes:
13
+ return self.payload.encode("utf-8")
14
+
15
+ def __enter__(self):
16
+ return self
17
+
18
+ def __exit__(self, *_args):
19
+ return False
20
+
21
+
22
+ def test_round_prints_only_result(capsys):
23
+ with patch("bad_decisions_client.cli.urlopen", return_value=Response('{"result":"A completed round"}')) as request:
24
+ assert cli.run(["--packs", "maha"]) == 0
25
+ assert capsys.readouterr().out == "A completed round\n"
26
+ assert request.call_args.args[0].full_url.endswith("/v1/round?packs=maha")
27
+
28
+
29
+ def test_list_packs(capsys):
30
+ payload = '[{"id":"maha","name":"MAHA Pack","counts":{"black":27,"white":52}}]'
31
+ with patch("bad_decisions_client.cli.urlopen", return_value=Response(payload)):
32
+ assert cli.run(["--list-packs"]) == 0
33
+ assert capsys.readouterr().out == "maha\tMAHA Pack\tblack=27\twhite=52\n"
34
+
35
+
36
+ def test_json_and_validation(capsys):
37
+ with patch("bad_decisions_client.cli.urlopen", return_value=Response('{"status":"ok"}')):
38
+ assert cli.run(["--health", "--json"]) == 0
39
+ assert '"status": "ok"' in capsys.readouterr().out
40
+ assert cli.run(["--api-url", "ftp://bad"]) == 1
41
+ assert "must start" in capsys.readouterr().err