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 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"]
@@ -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
@@ -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]