From 18224b1ea75e7ea518acbb6317aad9a79177f360 Mon Sep 17 00:00:00 2001 From: mat ess Date: Sun, 13 Aug 2023 14:54:11 -0400 Subject: [PATCH] Update cli --- flake.nix | 3 +- pyproject.toml | 2 +- roll/cli/__init__.py | 98 +++++++++++++++++++++++++----------- roll/cli/roll_param.py | 14 ++++-- tests/cli/cli_test.py | 35 +++++++++++++ tests/cli/roll_param_test.py | 38 ++++++++++++++ 6 files changed, 156 insertions(+), 34 deletions(-) create mode 100644 tests/cli/cli_test.py create mode 100644 tests/cli/roll_param_test.py diff --git a/flake.nix b/flake.nix index d07b199..c384b5a 100644 --- a/flake.nix +++ b/flake.nix @@ -26,10 +26,9 @@ pre-commit.settings.hooks = { autoflake.enable = true; black.enable = true; - mypy.enable = true; + # mypy.enable = true; ruff.enable = true; }; - pre-commit.settings.settings.mypy.binPath = "${pkgs.python311Packages.mypy}/bin/mypy"; packages.default = package; devShells.default = pkgs.mkShell { shellHook = '' diff --git a/pyproject.toml b/pyproject.toml index 254c9c5..4459bd6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ python = ["3.11"] [tool.hatch.envs.typing] extra-dependencies = ["mypy"] [tool.hatch.envs.typing.scripts] -check = "mypy --install-types {args:roll tests}" +check = "mypy --install-types --non-interactive {args:roll tests}" [tool.hatch.envs.lint] detached = true diff --git a/roll/cli/__init__.py b/roll/cli/__init__.py index f6e9286..b49341d 100644 --- a/roll/cli/__init__.py +++ b/roll/cli/__init__.py @@ -1,50 +1,92 @@ +from typing import Literal + import click from roll.__about__ import __version__ from roll.cli.roll_param import ROLL from roll.roll import Roll +from roll.throw import Throw + +CONTEXT_SETTINGS = {"help_option_names": ["-h", "--help"]} -@click.group( - context_settings={"help_option_names": ["-h", "--help"]}, - invoke_without_command=True, -) +@click.group(context_settings=CONTEXT_SETTINGS, invoke_without_command=True) @click.version_option(version=__version__, prog_name="roll") -@click.argument("rolls", nargs=-1, type=ROLL) -def roll(rolls: list[Roll]): - """Throw each roll specified in ROLLS and print the results. +@click.argument("roll", type=ROLL, default="1d20") +def cli(roll: Roll | Literal["advantage"] | Literal["disadvantage"]) -> None: + """throw a ROLL and print the results - Rolls are specified as + ROLL is specified as DdS[(+|-)M] - where D = # of dice, S = sides per die, and M = optional modifier. + where D = # of dice, S = sides per die, and M = optional modifier - Example usage: + if no ROLL is provided, a 1d20 is thrown + + instead of a ROLL specifier, you can use a substring of "advantage" + or "disadvantage" to throw 2d20 and take the appropriate result + + example usage: \b - $ roll 2d20+3 3d6-1 + $ roll 2d20+3 rolling 2d20+3: 1: | 2 2: | 13 mod: +3 total: 18 \b - rolling 3d6-1: - 1: | 3 - 2: | 3 - 3: | 1 - mod: -1 - total: 6 - + $ roll + rolling 1d20: + 1: | 11 + total: 11 + \b + $ roll advantage + rolling 2d20 with advantage: + 1: | 13 + 1: | 2 + total: 13 + \b + $ roll dis + rolling 2d20 with disadvantage: + 1: | 1 + 2: | 19 + total: 1 + critical miss! """ - for roll in rolls: - click.echo() - click.echo(f"rolling {roll.to_str()}:") - throw = roll.throw() - for i, result in enumerate(throw.results): - click.echo(f"{i + 1}:\t| {result: >3}") - if roll.modifier: - mod = roll.modifier_str() - click.echo(f"mod:\t {mod: >4}") - click.echo(f"total:\t {throw.total: >3}") + if isinstance(roll, str): + throw = _advantage(roll) + else: + throw = _throw(roll) + click.echo(f"total:\t{throw.total: >5}") + if throw.is_critical_hit: # pragma: no cover (random) + click.echo("critical hit!") + elif throw.is_critical_miss: # pragma: no cover (random) + click.echo("critical miss!") + + +def _advantage(advantage: str) -> Throw: + """print the results of an advantage or disadvantage roll""" + click.echo(f"throwing 2d20 with {advantage}:") + roll = Roll() + fst = roll.throw() + snd = roll.throw() + click.echo(f"1:\t|{fst.total: >4}") + click.echo(f"2:\t|{snd.total: >4}") + if advantage == "advantage": + throw = fst if fst.total > snd.total else snd + else: + throw = fst if fst.total < snd.total else snd + return throw + + +def _throw(roll: Roll) -> Throw: + """print the results of a roll""" + click.echo(f"throwing {roll.to_str()}:") + throw = roll.throw() + for i, result in enumerate(throw.results, start=1): + click.echo(f"{i}:\t|{result: >4}") + if roll.modifier is not None: + click.echo(f"mod:\t{roll.modifier_str(): >5}") + return throw diff --git a/roll/cli/roll_param.py b/roll/cli/roll_param.py index 8db7699..e7d53fa 100644 --- a/roll/cli/roll_param.py +++ b/roll/cli/roll_param.py @@ -1,3 +1,5 @@ +from typing import Literal + import click from roll.roll import Roll @@ -6,14 +8,20 @@ from roll.roll import Roll class RollParam(click.ParamType): name = "roll" - def convert(self, value: str | Roll, param: click.Parameter | None, ctx: click.Context | None) -> Roll: - """Parse a Roll from a command line string.""" + def convert( + self, value: str | Roll, param: click.Parameter | None, ctx: click.Context | None + ) -> Roll | Literal["advantage"] | Literal["disadvantage"]: + """parse a Roll from a command line string""" if isinstance(value, Roll): return value + elif "advantage".startswith(value): + return "advantage" + elif "disadvantage".startswith(value): + return "disadvantage" try: return Roll.from_str(value) except Exception as e: - self.fail(f"invalid roll: {value!r}, caused by {e}", param, ctx) + self.fail(f"{e}", param, ctx) ROLL = RollParam() diff --git a/tests/cli/cli_test.py b/tests/cli/cli_test.py new file mode 100644 index 0000000..72b365f --- /dev/null +++ b/tests/cli/cli_test.py @@ -0,0 +1,35 @@ +import pytest +from click.testing import CliRunner + +from roll.cli import cli +from roll.roll import Roll + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def test_cli_smoke(runner: CliRunner): + result = runner.invoke(cli, []) + assert result.exit_code == 0 + assert result.output.startswith("throwing 1d20:\n1:\t|") + assert "total" in result.output + + +@pytest.mark.parametrize("value", ["2d20", "4d6-2", "9d100+3"]) +def test_cli_roll(runner: CliRunner, value: str): + result = runner.invoke(cli, [value]) + roll = Roll.from_str(value) + assert result.exit_code == 0 + assert result.output.startswith(f"throwing {value}:\n1:\t|") + assert len(result.output.splitlines()) >= roll.dice_count + 2 + assert "total" in result.output + + +@pytest.mark.parametrize("advantage", ["advantage", "disadvantage"]) +def test_cli_advantage(runner: CliRunner, advantage: str): + result = runner.invoke(cli, [advantage]) + assert result.exit_code == 0 + assert result.output.startswith(f"throwing 2d20 with {advantage}:\n1:\t|") + assert "total" in result.output diff --git a/tests/cli/roll_param_test.py b/tests/cli/roll_param_test.py new file mode 100644 index 0000000..2dc8ab5 --- /dev/null +++ b/tests/cli/roll_param_test.py @@ -0,0 +1,38 @@ +import click +import pytest + +from roll.cli.roll_param import RollParam +from roll.roll import Roll + + +def test_roll_param_passthrough(): + roll = Roll() + result = RollParam().convert(roll, None, None) + assert result == roll + + +@pytest.mark.parametrize("value", ["1d20", "2d20+3", "4d6-3"]) +def test_roll_param_valid(value): + result = RollParam().convert(value, None, None) + assert isinstance(result, Roll) + assert result.to_str() == value + + +@pytest.mark.parametrize("value", ["advantage", "disadvantage"]) +def test_roll_param_advantage(value): + result = RollParam().convert(value, None, None) + assert result == value + + +@pytest.mark.parametrize("value", ["d90", "0d", "d-1", "aba", "000", "1d1d1d"]) +def test_roll_param_invalid(value): + with pytest.raises(click.exceptions.BadParameter) as e: + RollParam().convert(value, None, None) + assert value in e.value.message + + +@pytest.mark.parametrize("value", ["0d0", "0d20", "1d0", "1d1"]) +def test_roll_param_invalid_properties(value): + with pytest.raises(click.exceptions.BadParameter) as e: + RollParam().convert(value, None, None) + assert "sides" in e.value.message or "dice" in e.value.message