Initial setup of CLI
This commit is contained in:
parent
2f9f8a421f
commit
3c22594fd1
88
README.md
Normal file
88
README.md
Normal file
|
@ -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
|
||||
...
|
||||
|
|
@ -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']
|
||||
|
|
39
rolltable/cli.py
Normal file
39
rolltable/cli.py
Normal file
|
@ -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()
|
|
@ -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__':
|
||||
|
|
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
87
tests/test_tables.py
Normal file
87
tests/test_tables.py
Normal file
|
@ -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)
|
Loading…
Reference in New Issue
Block a user