pyDeciders 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.
- pyDeciders/__init__.py +7 -0
- pyDeciders/animation.py +95 -0
- pyDeciders/cli.py +72 -0
- pyDeciders/coin.py +44 -0
- pyDeciders/dice.py +40 -0
- pyDeciders/eight_ball.py +54 -0
- pyDeciders/pick.py +31 -0
- pyDeciders/wheel.py +25 -0
- pydeciders-0.1.0.dist-info/METADATA +902 -0
- pydeciders-0.1.0.dist-info/RECORD +14 -0
- pydeciders-0.1.0.dist-info/WHEEL +5 -0
- pydeciders-0.1.0.dist-info/entry_points.txt +2 -0
- pydeciders-0.1.0.dist-info/licenses/LICENSE +674 -0
- pydeciders-0.1.0.dist-info/top_level.txt +1 -0
pyDeciders/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
from pyDeciders.coin import flip_coin
|
|
2
|
+
from pyDeciders.dice import roll_dice
|
|
3
|
+
from pyDeciders.eight_ball import magic_8_ball
|
|
4
|
+
from pyDeciders.pick import pick_one
|
|
5
|
+
from pyDeciders.wheel import spin_wheel
|
|
6
|
+
|
|
7
|
+
__all__ = ["flip_coin", "magic_8_ball", "pick_one", "roll_dice", "spin_wheel"]
|
pyDeciders/animation.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Small terminal animations for the decisions command.
|
|
2
|
+
|
|
3
|
+
Only the command line tool uses this module. The library functions never
|
|
4
|
+
print or animate, so they stay safe to call from other programs.
|
|
5
|
+
|
|
6
|
+
The result is always decided before the animation starts. The animation only
|
|
7
|
+
shows random "in progress" frames, so it never changes the answer, and the
|
|
8
|
+
same --seed gives the same answer with or without it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import random
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
from rich.live import Live
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
FRAME_COUNT = 12
|
|
19
|
+
|
|
20
|
+
FIRST_DELAY = 0.04
|
|
21
|
+
DELAY_STEP = 0.012
|
|
22
|
+
|
|
23
|
+
COIN_SPIN = ("( H )", "( | )", "( T )", "( | )")
|
|
24
|
+
BALL_SHAKE = ("shaking the ball", "shaking the ball.", "shaking the ball..", "shaking the ball...")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def should_animate(no_animation, stream):
|
|
28
|
+
"""Decide whether to animate.
|
|
29
|
+
|
|
30
|
+
Animations are off when the user passed --no-animation, or when the output
|
|
31
|
+
is not a terminal (for example when it is piped into a file or another
|
|
32
|
+
program), so scripts only ever see the final answer.
|
|
33
|
+
"""
|
|
34
|
+
if no_animation:
|
|
35
|
+
return False
|
|
36
|
+
isatty = getattr(stream, "isatty", None)
|
|
37
|
+
if isatty is None:
|
|
38
|
+
return False
|
|
39
|
+
return bool(isatty())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def build_frames(command, args, rng=None, count=FRAME_COUNT):
|
|
43
|
+
"""Return the list of in-progress frames to show for a command.
|
|
44
|
+
|
|
45
|
+
The frames are just for show. They come from their own random generator,
|
|
46
|
+
so they never use up the numbers that decide the real result.
|
|
47
|
+
"""
|
|
48
|
+
if rng is None:
|
|
49
|
+
rng = random.Random()
|
|
50
|
+
|
|
51
|
+
if command == "dice":
|
|
52
|
+
return [
|
|
53
|
+
" ".join(f"[{rng.randint(1, args.sides)}]" for _ in range(args.n))
|
|
54
|
+
for _ in range(count)
|
|
55
|
+
]
|
|
56
|
+
if command == "coin":
|
|
57
|
+
# each coin starts at a different point in the spin
|
|
58
|
+
starts = [rng.randrange(len(COIN_SPIN)) for _ in range(args.times)]
|
|
59
|
+
return [
|
|
60
|
+
" ".join(COIN_SPIN[(start + i) % len(COIN_SPIN)] for start in starts)
|
|
61
|
+
for i in range(count)
|
|
62
|
+
]
|
|
63
|
+
if command in ("wheel", "pick"):
|
|
64
|
+
# move through the options in order, like a pointer going round
|
|
65
|
+
start = rng.randrange(len(args.options))
|
|
66
|
+
return [
|
|
67
|
+
f"> {args.options[(start + i) % len(args.options)]} <"
|
|
68
|
+
for i in range(count)
|
|
69
|
+
]
|
|
70
|
+
if command == "8ball":
|
|
71
|
+
return [BALL_SHAKE[i % len(BALL_SHAKE)] for i in range(count)]
|
|
72
|
+
raise ValueError(f"no animation for command {command!r}")
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def frame_delays(count=FRAME_COUNT):
|
|
76
|
+
"""How long to show each frame, in seconds, getting slower towards the end."""
|
|
77
|
+
return [FIRST_DELAY + DELAY_STEP * i for i in range(count)]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def animate(command, args, console=None, sleep=time.sleep):
|
|
81
|
+
"""Play the animation for a command, then clear it from the screen.
|
|
82
|
+
|
|
83
|
+
The caller prints the final answer afterwards. console and sleep can be
|
|
84
|
+
replaced in tests so nothing is drawn and nothing waits.
|
|
85
|
+
"""
|
|
86
|
+
if console is None:
|
|
87
|
+
console = Console()
|
|
88
|
+
frames = build_frames(command, args)
|
|
89
|
+
delays = frame_delays(len(frames))
|
|
90
|
+
|
|
91
|
+
# transient=True removes the animation once it ends, leaving only the answer
|
|
92
|
+
with Live(console=console, transient=True, auto_refresh=False) as live:
|
|
93
|
+
for frame, delay in zip(frames, delays):
|
|
94
|
+
live.update(Text(frame, style="bold cyan"), refresh=True)
|
|
95
|
+
sleep(delay)
|
pyDeciders/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pyDeciders import flip_coin, magic_8_ball, pick_one, roll_dice, spin_wheel
|
|
5
|
+
from pyDeciders.animation import animate, should_animate
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def build_parser():
|
|
9
|
+
parser = argparse.ArgumentParser(
|
|
10
|
+
prog="decisions",
|
|
11
|
+
description="Roll dice, flip coins, and spin a wheel.",
|
|
12
|
+
)
|
|
13
|
+
common = argparse.ArgumentParser(add_help=False)
|
|
14
|
+
common.add_argument("--seed", type=int, default=None)
|
|
15
|
+
common.add_argument(
|
|
16
|
+
"--no-animation",
|
|
17
|
+
action="store_true",
|
|
18
|
+
help="print the answer straight away, without the animation",
|
|
19
|
+
)
|
|
20
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
21
|
+
|
|
22
|
+
dice = commands.add_parser("dice", parents=[common], help="roll dice")
|
|
23
|
+
dice.add_argument("-n", type=int, default=1)
|
|
24
|
+
dice.add_argument("--sides", type=int, default=6)
|
|
25
|
+
dice.set_defaults(run=lambda a: roll_dice(a.n, a.sides, a.seed))
|
|
26
|
+
|
|
27
|
+
coin = commands.add_parser("coin", parents=[common], help="flip a coin")
|
|
28
|
+
coin.add_argument("--times", type=int, default=1)
|
|
29
|
+
coin.add_argument("--bias", type=float, default=0.5)
|
|
30
|
+
coin.set_defaults(run=lambda a: flip_coin(a.times, a.bias, a.seed))
|
|
31
|
+
|
|
32
|
+
wheel = commands.add_parser("wheel", parents=[common], help="spin a wheel")
|
|
33
|
+
wheel.add_argument("options", nargs="+")
|
|
34
|
+
wheel.add_argument("--weights", type=float, nargs="+", default=None)
|
|
35
|
+
wheel.set_defaults(run=lambda a: spin_wheel(a.options, a.weights, a.seed))
|
|
36
|
+
|
|
37
|
+
pick = commands.add_parser("pick", parents=[common], help="pick one option")
|
|
38
|
+
pick.add_argument("options", nargs="+")
|
|
39
|
+
pick.set_defaults(run=lambda a: pick_one(a.options, a.seed))
|
|
40
|
+
|
|
41
|
+
ball = commands.add_parser("8ball", parents=[common], help="ask the magic 8 ball")
|
|
42
|
+
ball.add_argument("question")
|
|
43
|
+
ball.set_defaults(run=lambda a: magic_8_ball(a.question, a.seed))
|
|
44
|
+
|
|
45
|
+
return parser
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def print_result(result):
|
|
49
|
+
if isinstance(result, (list, tuple)):
|
|
50
|
+
print(" ".join(str(item) for item in result))
|
|
51
|
+
else:
|
|
52
|
+
print(result)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv=None):
|
|
56
|
+
args = build_parser().parse_args(argv)
|
|
57
|
+
try:
|
|
58
|
+
result = args.run(args)
|
|
59
|
+
except NotImplementedError:
|
|
60
|
+
print(f"decisions: {args.command} is not implemented yet", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
except ValueError as error:
|
|
63
|
+
print(f"decisions: {error}", file=sys.stderr)
|
|
64
|
+
return 1
|
|
65
|
+
|
|
66
|
+
if should_animate(args.no_animation, sys.stdout):
|
|
67
|
+
try:
|
|
68
|
+
animate(args.command, args)
|
|
69
|
+
except KeyboardInterrupt:
|
|
70
|
+
pass
|
|
71
|
+
print_result(result)
|
|
72
|
+
return 0
|
pyDeciders/coin.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Coin flipping for the indecisive."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
HEADS = "heads"
|
|
6
|
+
TAILS = "tails"
|
|
7
|
+
|
|
8
|
+
def flip_coin(times=1, bias=0.5, seed=None):
|
|
9
|
+
"""Flipping a coin one or more times.
|
|
10
|
+
|
|
11
|
+
Args:
|
|
12
|
+
times: how many flips to make. Must be a whole number of at least 1.
|
|
13
|
+
bias: chance of landing heads, from 0.0 (always tails) to 1.0 (always heads).
|
|
14
|
+
The default of 0.5 is a fair coin.
|
|
15
|
+
seed: optional number that makes the result repeatable. The same seed
|
|
16
|
+
always gives the same flips.
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
A list of "heads" / "tails" strings, one per flip.
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
TypeError: if times is not an int, or bias is not a number.
|
|
23
|
+
ValueError: if times is less than 1, or bias is outside 0.0-1.0.
|
|
24
|
+
"""
|
|
25
|
+
if isinstance(times, bool) or not isinstance(times, int):
|
|
26
|
+
raise TypeError("times must be a whole number")
|
|
27
|
+
if times < 1:
|
|
28
|
+
raise ValueError("times must be at least 1")
|
|
29
|
+
# checks for bool too as True == 1;
|
|
30
|
+
if isinstance(bias, bool) or not isinstance(bias, (int, float)):
|
|
31
|
+
raise TypeError("bias must be a number between 0 and 1")
|
|
32
|
+
if not 0 <= bias <= 1:
|
|
33
|
+
raise ValueError("bias must be between 0 and 1")
|
|
34
|
+
|
|
35
|
+
# a private random generator, so seeding here never affects other code
|
|
36
|
+
rng = random.Random(seed)
|
|
37
|
+
|
|
38
|
+
results = []
|
|
39
|
+
for _ in range(times):
|
|
40
|
+
if rng.random() < bias:
|
|
41
|
+
results.append(HEADS)
|
|
42
|
+
else:
|
|
43
|
+
results.append(TAILS)
|
|
44
|
+
return results
|
pyDeciders/dice.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Dice rolling for the indecisive."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def roll_dice(n=1, sides=6, seed=None):
|
|
7
|
+
"""Roll one or more dice.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
n: how many dice to roll. Must be a whole number of at least 1.
|
|
11
|
+
sides: how many sides each die has. Must be a whole number of at least 2.
|
|
12
|
+
The default of 6 is a regular die; use 20 for a D&D d20.
|
|
13
|
+
seed: optional number that makes the result repeatable. The same seed
|
|
14
|
+
always gives the same rolls.
|
|
15
|
+
|
|
16
|
+
Returns:
|
|
17
|
+
A list of ints, one per die, each between 1 and sides.
|
|
18
|
+
|
|
19
|
+
Raises:
|
|
20
|
+
TypeError: if n or sides is not a whole number.
|
|
21
|
+
ValueError: if n is less than 1, or sides is less than 2.
|
|
22
|
+
"""
|
|
23
|
+
# bool is a subclass of int, so True would sneak through as 1
|
|
24
|
+
if isinstance(n, bool) or not isinstance(n, int):
|
|
25
|
+
raise TypeError("n must be a whole number")
|
|
26
|
+
if n < 1:
|
|
27
|
+
raise ValueError("n must be at least 1")
|
|
28
|
+
|
|
29
|
+
if isinstance(sides, bool) or not isinstance(sides, int):
|
|
30
|
+
raise TypeError("sides must be a whole number")
|
|
31
|
+
if sides < 2:
|
|
32
|
+
raise ValueError("a die needs at least 2 sides")
|
|
33
|
+
|
|
34
|
+
# a private random generator, so seeding here never affects other code
|
|
35
|
+
rng = random.Random(seed)
|
|
36
|
+
|
|
37
|
+
results = []
|
|
38
|
+
for _ in range(n):
|
|
39
|
+
results.append(rng.randint(1, sides))
|
|
40
|
+
return results
|
pyDeciders/eight_ball.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""A fortune-telling ball for the indecisive."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
POSITIVE = (
|
|
6
|
+
"Absolutely, go for it.",
|
|
7
|
+
"The stars say yes.",
|
|
8
|
+
"Without a shadow of a doubt.",
|
|
9
|
+
"Yes, and you knew that already.",
|
|
10
|
+
"Signs point to a cheerful yes.",
|
|
11
|
+
)
|
|
12
|
+
UNSURE = (
|
|
13
|
+
"Hmm, the ball is buffering. Ask again.",
|
|
14
|
+
"Too foggy to tell right now.",
|
|
15
|
+
"Ask me after a snack.",
|
|
16
|
+
"The answer is hiding. Try later.",
|
|
17
|
+
"Maybe. Maybe not. Maybe so.",
|
|
18
|
+
)
|
|
19
|
+
NEGATIVE = (
|
|
20
|
+
"Not a chance.",
|
|
21
|
+
"The stars say no.",
|
|
22
|
+
"I would not bet on it.",
|
|
23
|
+
"Nope, not today.",
|
|
24
|
+
"All signs point to no.",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
ANSWERS = POSITIVE + UNSURE + NEGATIVE
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def magic_8_ball(question, seed=None):
|
|
31
|
+
"""Ask the ball a yes-or-no question and get its answer.
|
|
32
|
+
|
|
33
|
+
Args:
|
|
34
|
+
question: the question to ask, e.g. "Should I order pizza?". Must be a
|
|
35
|
+
string with at least one non-space character.
|
|
36
|
+
seed: optional number that makes the result repeatable. The same seed
|
|
37
|
+
always gives the same answer.
|
|
38
|
+
|
|
39
|
+
Returns:
|
|
40
|
+
One answer string from ANSWERS.
|
|
41
|
+
|
|
42
|
+
Raises:
|
|
43
|
+
TypeError: if question is not a string.
|
|
44
|
+
ValueError: if question is empty or only whitespace.
|
|
45
|
+
"""
|
|
46
|
+
if not isinstance(question, str):
|
|
47
|
+
raise TypeError("question must be a string")
|
|
48
|
+
if not question.strip():
|
|
49
|
+
raise ValueError("question must not be empty")
|
|
50
|
+
|
|
51
|
+
# a private random generator, so seeding here never affects other code
|
|
52
|
+
rng = random.Random(seed)
|
|
53
|
+
|
|
54
|
+
return rng.choice(ANSWERS)
|
pyDeciders/pick.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Picking one option for the indecisive."""
|
|
2
|
+
|
|
3
|
+
import random
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def pick_one(options, seed=None):
|
|
7
|
+
"""Pick one option at random, each with an equal chance.
|
|
8
|
+
|
|
9
|
+
Args:
|
|
10
|
+
options: a list or tuple of choices, e.g. ["pizza", "tacos", "sushi"].
|
|
11
|
+
Must contain at least one item.
|
|
12
|
+
seed: optional number that makes the result repeatable. The same seed
|
|
13
|
+
always gives the same pick.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
One item from options.
|
|
17
|
+
|
|
18
|
+
Raises:
|
|
19
|
+
TypeError: if options is not a list or tuple (a single string counts as
|
|
20
|
+
wrong, since it would pick one letter).
|
|
21
|
+
ValueError: if options is empty.
|
|
22
|
+
"""
|
|
23
|
+
if not isinstance(options, (list, tuple)):
|
|
24
|
+
raise TypeError("options must be a list or tuple of choices")
|
|
25
|
+
if len(options) == 0:
|
|
26
|
+
raise ValueError("options must contain at least one choice")
|
|
27
|
+
|
|
28
|
+
# a private random generator, so seeding here never affects other code
|
|
29
|
+
rng = random.Random(seed)
|
|
30
|
+
|
|
31
|
+
return rng.choice(options)
|
pyDeciders/wheel.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import math
|
|
2
|
+
import random
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def spin_wheel(options, weights=None, seed=None):
|
|
6
|
+
if not isinstance(options, (list, tuple)):
|
|
7
|
+
raise TypeError("options must be a list or tuple")
|
|
8
|
+
if not options:
|
|
9
|
+
raise ValueError("options must not be empty")
|
|
10
|
+
|
|
11
|
+
if weights is not None:
|
|
12
|
+
if not isinstance(weights, (list, tuple)):
|
|
13
|
+
raise TypeError("weights must be a list or tuple")
|
|
14
|
+
if len(weights) != len(options):
|
|
15
|
+
raise ValueError("weights must have one value per option")
|
|
16
|
+
for weight in weights:
|
|
17
|
+
if isinstance(weight, bool) or not isinstance(weight, (int, float)):
|
|
18
|
+
raise TypeError("each weight must be a number")
|
|
19
|
+
if not math.isfinite(weight) or weight < 0:
|
|
20
|
+
raise ValueError("each weight must be a real number and least 0")
|
|
21
|
+
if sum(weights) == 0:
|
|
22
|
+
raise ValueError("at least one weight must be above 0")
|
|
23
|
+
|
|
24
|
+
rng = random.Random(seed)
|
|
25
|
+
return rng.choices(options, weights=weights, k=1)[0]
|