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.
- bad_decisions_client-1.0.1/.gitignore +28 -0
- bad_decisions_client-1.0.1/PKG-INFO +43 -0
- bad_decisions_client-1.0.1/README.md +27 -0
- bad_decisions_client-1.0.1/pyproject.toml +27 -0
- bad_decisions_client-1.0.1/src/bad_decisions_client/__init__.py +3 -0
- bad_decisions_client-1.0.1/src/bad_decisions_client/cli.py +103 -0
- bad_decisions_client-1.0.1/tests/test_cli.py +41 -0
|
@@ -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,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
|