From 3c22594fd1c328dc1470335fb8c2fc8918d0a7e8 Mon Sep 17 00:00:00 2001 From: evilchili Date: Sat, 30 Jul 2022 14:20:26 -0700 Subject: [PATCH] Initial setup of CLI --- README.md | 88 ++++++++++++++ pyproject.toml | 10 +- rolltable/cli.py | 39 ++++++ rolltable/tables.py | 279 +++++++++++++++++-------------------------- tests/__init__.py | 0 tests/test_tables.py | 87 ++++++++++++++ 6 files changed, 331 insertions(+), 172 deletions(-) create mode 100644 README.md create mode 100644 rolltable/cli.py create mode 100644 tests/__init__.py create mode 100644 tests/test_tables.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..6330d70 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ + Generate a roll table using weighted distributions of random options. + Given source.yaml containing options such as: + + option1: + - key1: description + - key2: description + ... + ... + + Generate a random table: + + >>> print(RollTable(path='source.yaml')) + d1 option6 key3 description + d2 option2 key2 description + d3 option3 key4 description + ... + + You can customize the frequency distribution, headers, and table size by + defining metadata in your source file. + + Using Metadata: + + By default options are given uniform distribution and random keys will be + selected from each option with equal probability. This behaviour can be + changed by adding an optional metadata section to the source file: + + metadata: + frequenceis: + default: + option1: 0.5 + option2: 0.1 + option3: 0.3 + option4: 0.1 + + This will guarantee that random keys from option1 are selected 50% of the + time, from option 2 10% of the time, and so forth. Frequencies should add + up to 1.0. + + If the metadata section includes 'frequencies', The 'default' distribution + must be defined. Additional optional distributions may also be defined, if + you want to provide alternatives for specific use cases. + + metadata: + frequenceis: + default: + option1: 0.5 + option2: 0.1 + option3: 0.3 + option4: 0.1 + inverted: + option1: 0.1 + option2: 0.3 + option3: 0.1 + option4: 0.5 + + A specific frequency distribution can be specifed by passing the 'frequency' + parameter at instantiation: + + >>> t = RollTable('source.yaml', frequency='inverted') + + The metadata section can also override the default size of die to use for + the table (a d20). For example, this creates a 100-row table: + + metadata: + die: 100 + + This too can be overridden at instantiation: + + >>> t = RollTable('source.yaml', die=64) + + Finally, headers for your table columns can also be defined in metadata: + + metadata: + headers: + - Roll + - Frequency + - Description + - Effect + + This will yield output similar to: + + >>> print(RollTable(path='source.yaml')) + Roll Category Name Effect + d1 option6 key3 description + d2 option2 key2 description + d3 option3 key4 description + ... + diff --git a/pyproject.toml b/pyproject.toml index 8edcb45..e0612bd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,7 +9,15 @@ packages = [ ] [tool.poetry.dependencies] -python = '^3.6' +python = "^3.7" +typer = "latest" +rich = "latest" +pyyaml = "latest" + + +[tool.poetry.scripts] +roll-table = "rolltable.cli:app" + [build-system] requires = ['poetry-core~=1.0'] diff --git a/rolltable/cli.py b/rolltable/cli.py new file mode 100644 index 0000000..87894ad --- /dev/null +++ b/rolltable/cli.py @@ -0,0 +1,39 @@ +from rolltable import tables +import typer +from rich import print +from rich.table import Table + + +app = typer.Typer() + + +@app.command("roll-table") +def create( + source: str = typer.Argument( + ..., + help="Path to the yaml-formatted source file."), + frequency: str = typer.Option( + 'default', + help='use the specified frequency from the source file'), + die: int = typer.Option( + 20, + help='The size of the die for which to create a table'), + collapse: bool = typer.Option( + True, + help='If True, collapse multiple die values with the same option.') +): + """ + CLI for creating roll tables. + """ + with open(source, 'r') as src: + rt = tables.RollTable(source=src, frequency=frequency, die=die, + collapsed=collapse) + rt.load_source() + table = Table(*rt.rows[0]) + for row in rt.rows[1:]: + table.add_row(*row) + print(table) + + +if __name__ == '__main__': + app() diff --git a/rolltable/tables.py b/rolltable/tables.py index a488c10..14f2a89 100644 --- a/rolltable/tables.py +++ b/rolltable/tables.py @@ -1,143 +1,135 @@ import yaml import random from collections.abc import Generator -from typing import Optional, Mapping, List +from typing import Optional, Mapping, List, IO class RollTable: """ Generate a roll table using weighted distributions of random options. - Given source.yaml containing options such as: - option1: - - key1: description - - key2: description - ... - ... + Instance Attributes: - Generate a random table: + data - The parsed source data, minus any metadata + die - the size of the die for which to create a table (default: 20) + frequencies - frequency distribution applied when selecting random values + headers - array of column headers (default: do not print headers) + (default: uniform across all options) + rows - An array of table rows derived from the values + values - An array of randomly-selected values for each die roll - >>> print(RollTable(path='source.yaml')) - d1 option6 key3 description - d2 option2 key2 description - d3 option3 key4 description - ... + Instance Methods: - You can customize the frequency distribution, headers, and table size by - defining metadata in your source file. - - Using Metadata: - - By default options are given uniform distribution and random keys will be - selected from each option with equal probability. This behaviour can be - changed by adding an optional metadata section to the source file: - - metadata: - frequenceis: - default: - option1: 0.5 - option2: 0.1 - option3: 0.3 - option4: 0.1 - - This will guarantee that random keys from option1 are selected 50% of the - time, from option 2 10% of the time, and so forth. Frequencies should add - up to 1.0. - - If the metadata section includes 'frequencies', The 'default' distribution - must be defined. Additional optional distributions may also be defined, if - you want to provide alternatives for specific use cases. - - metadata: - frequenceis: - default: - option1: 0.5 - option2: 0.1 - option3: 0.3 - option4: 0.1 - inverted: - option1: 0.1 - option2: 0.3 - option3: 0.1 - option4: 0.5 - - A specific frequency distribution can be specifed by passing the 'frequency' - parameter at instantiation: - - >>> t = RollTable('source.yaml', frequency='inverted') - - The metadata section can also override the default size of die to use for - the table (a d20). For example, this creates a 100-row table: - - metadata: - die: 100 - - This too can be overridden at instantiation: - - >>> t = RollTable('source.yaml', die=64) - - Finally, headers for your table columns can also be defined in metadata: - - metadata: - headers: - - Roll - - Frequency - - Description - - Effect - - This will yield output similar to: - - >>> print(RollTable(path='source.yaml')) - Roll Category Name Effect - d1 option6 key3 description - d2 option2 key2 description - d3 option3 key4 description - ... + load_source - Read and parse the source. Will be called automatically when necessary. """ - def __init__(self, path: str, frequency: str = 'default', - die: Optional[int] = None, collapsed: bool = True): + def __init__(self, source: IO, frequency: str = 'default', + die: Optional[int] = 20, collapsed: bool = True): """ Initialize a RollTable instance. Args: - path - the path to the source file + source - an IO object to read source from frequency - the name of the frequency distribution to use; must be defined in the source file's metadata. die - specify a die size collapsed - If True, collapse multiple die values with the same options into a single line. """ - self._path = path self._frequency = frequency self._die = die self._collapsed = collapsed - self._metadata = None - self._source = None + self._headers = None + self._frequencies = None + self._source = source + self._data = None self._values = None + self._rows = None - def _load_source(self) -> None: + @property + def frequencies(self): + if not self._data: + self.load_source() + return self._frequencies + + @property + def data(self) -> Mapping: + if not self._data: + self.load_source() + return self._data + + @property + def die(self) -> int: + return self._die + + @property + def headers(self) -> List: + if not self._data: + self.load_source() + return self._headers + + @property + def values(self) -> List: + if not self._values: + weights = [] + options = [] + for (option, weight) in self.frequencies.items(): + weights.append(weight) + options.append(option) + freqs = random.choices(options, weights=weights, k=self.die) + self._values = [] + for option in freqs: + self._values += [(option, random.choice(self.data[option]))] + if hasattr(self._values[0][1], 'keys'): + self._values = sorted(self._values, key=lambda val: list(val[1].keys())[0]) + else: + self._values = sorted(self._values) + return self._values + + @property + def rows(self) -> List: + if not self._rows: + rows = [] + if self.headers: + rows.append(self.headers) + if self._collapsed: + for line in self._collapsed_rows(): + rows.append(line) + else: + for (i, item) in enumerate(self.values): + (cat, option) = item + if hasattr(option, 'items'): + (k, v) = list(option.items())[0] + rows.append([f'd{i+1}', cat, k, v]) + else: + rows.append([f'd{i+1}', cat, option]) + self._rows = rows + return self._rows + + def load_source(self) -> None: """ - Cache the yaml source and parsed or generated the metadata. + Cache the yaml source and the parsed or generated metadata. """ - if self._source: + if self._data: return - with open(self._path, 'r') as source: - self._source = yaml.safe_load(source) - def _defaults(): - num_keys = len(self._source.keys()) - default_freq = num_keys/100 - return { - 'headers': [''] * num_keys, - 'die': self._die, - 'frequencies': { - 'default': [(k, default_freq) for k in self._source.keys()] - } - } - self._metadata = self._source.pop('metadata', _defaults()) + self._data = yaml.safe_load(self._source) + metadata = self._data.pop('metadata', {}) - def _collapsed_lines(self) -> Generator[list]: + num_keys = len(self._data.keys()) + default_freq = num_keys / 100 + + if 'headers' in metadata: + self._headers = metadata['headers'] + + frequencies = { + 'default': dict([(k, default_freq) for k in self._data.keys()]) + } + if 'frequencies' in metadata: + frequencies.update(**metadata['frequencies']) + self._frequencies = frequencies[self._frequency] + + def _collapsed_rows(self) -> Generator[list]: """ Generate an array of column values for each row of the table but sort the values and squash multiple rows with the same values into one, @@ -152,7 +144,11 @@ class RollTable: """ def collapsed(last_val, offset, val, i): (cat, option) = last_val - (k, v) = list(*option.items()) + if hasattr(option, 'items'): + (k, v) = list(*option.items()) + else: + k = option + v = '' if offset + 1 == i: return [f'd{i}', cat, k, v] else: @@ -171,72 +167,13 @@ class RollTable: offset = i yield collapsed(last_val, offset, val, i+1) - @property - def freqtable(self): - return self.metadata['frequencies'][self._frequency] - - @property - def source(self) -> Mapping: + def __repr__(self) -> str: """ - The parsed source data + Return the rows as a single string. """ - if not self._source: - self._load_source() - return self._source - - @property - def metadata(self) -> Mapping: - """ - The parsed or generated metadata - """ - if not self._metadata: - self._load_source() - return self._metadata - - @property - def values(self) -> List: - """ - Randomly pick values from the source data following the frequency - distrubtion of the options. - """ - if not self._values: - weights = [] - options = [] - for (option, weight) in self.freqtable.items(): - weights.append(weight) - options.append(option) - freqs = random.choices(options, weights=weights, - k=self._die or self.metadata['die']) - self._values = [] - for option in freqs: - self._values += [(option, random.choice(self.source[option]))] - self._values = sorted(self._values, - key=lambda val: list(val[1].keys())[0]) - return self._values - - @property - def lines(self) -> Generator[List]: - """ - Yield a list of table rows suitable for formatting as output. - """ - yield self.metadata['headers'] - - if self._collapsed: - for line in self._collapsed_lines(): - yield line - else: - for (i, item) in enumerate(self.values): - (cat, option) = item - (k, v) = list(option.items())[0] - yield [f'd{i+1}', cat, k, v] - - def __str__(self) -> str: - """ - Return the lines as a single string. - """ - return "\n".join([ - '{:10s}\t{:8s}\t{:20s}\t{:s}'.format(*line) for line in self.lines - ]) + rows = list(self.rows) + str_format = '\t'.join(['{:s}'] * len(rows[0])) + return "\n".join([str_format.format(*row) for row in rows]) if __name__ == '__main__': diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_tables.py b/tests/test_tables.py new file mode 100644 index 0000000..051eb12 --- /dev/null +++ b/tests/test_tables.py @@ -0,0 +1,87 @@ +from rolltable import tables + +fixture_metadata = """ +metadata: + headers: + - Header 1 + - Header 2 + - Header 3 + die: 10 + frequencies: + default: + Option 1: 0.3 + Option 2: 0.5 + Option 3: 0.2 + nondefault: + Option 1: 0.0 + Option 2: 0.1 + Option 3: 0.9 +""" + +fixture_source = """ +Option 1: + - choice 1: description 1 + - choice 2: description 2 + - choice 3: description 3 +Option 2: + - choice 1: description 4 + - choice 2: description 5 + - choice 3: description 6 +Option 3: + - choice 1: description 7 + - choice 2: description 8 + - choice 3: description 9 +""" + +fixture_one_choice = """ +option 1: + - choice 1: description 1 +""" + +fixture_repeated_choices = """ +option 1: + - choice 1: description 1 + - choice 1: description 1 + - choice 1: description 1 +""" + +fixture_no_descriptions = """ +option 1: + - choice 1 +""" + + +def test_table_end_to_end(): + assert str(tables.RollTable(fixture_source)) + + +def test_table_end_to_end_with_metadata(): + assert str(tables.RollTable(fixture_metadata + fixture_source)) + + +def test_table_frequency(): + t = tables.RollTable(fixture_metadata + fixture_source, frequency='nondefault') + assert t.frequencies['Option 1'] == 0.0 + assert t.frequencies['Option 2'] == 0.1 + assert t.frequencies['Option 3'] == 0.9 + + +def test_one_option(): + t = tables.RollTable(fixture_one_choice, die=1) + assert t.values == [('option 1', {'choice 1': 'description 1'})] + + +def test_collapsed(): + t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=True) + assert len(list(t.rows)) == 1 + + +def test_not_collapsed(): + t = tables.RollTable(fixture_repeated_choices, die=6, collapsed=False) + assert len(list(t.rows)) == 6 + + +def test_no_descriptions(): + t = tables.RollTable(fixture_no_descriptions, die=1) + assert 'd1' in str(t) + assert 'option 1' in str(t)