Initial setup of dmsh

This commit is contained in:
evilchili 2023-07-03 14:14:03 -07:00
parent b70e416a3f
commit cbba29012b
7 changed files with 575 additions and 9 deletions

View File

@ -30,6 +30,7 @@ dnd-npcs = { git = "https://github.com/evilchili/dnd-npcs", branch = 'main'
elethis-cipher= { git = "https://github.com/evilchili/elethis-cipher", branch = 'main' }
#dnd-rolltable = { git = "https://github.com/evilchili/dnd-rolltable", branch = 'main' }
dnd-rolltable = { file = "../../dnd-rolltable/dist/dnd_rolltable-1.1.9-py3-none-any.whl" }
prompt-toolkit = "^3.0.38"
[tool.poetry.scripts]
site = "site_tools.cli:app"

View File

@ -1,3 +1,4 @@
import asyncio
import click
import os
import shutil
@ -15,8 +16,11 @@ from livereload.watcher import INotifyWatcher
from pathlib import Path
from pelican import main as pelican_main
from time import sleep
from typing_extensions import Annotated
from collections import defaultdict
from site_tools.content_manager import create
from site_tools.shell.interactive_shell import InteractiveShell
from rolltable.tables import RollTable
@ -39,6 +43,8 @@ CONFIG = {
'import_path': 'imports',
# where new asseets will be made available
'production_host': 'deadsands.froghat.club',
# where to find roll table sources
'table_sources_path': 'sources',
}
app = typer.Typer()
@ -53,6 +59,15 @@ class ContentType(str, Enum):
page = 'page'
class Die(str, Enum):
d100 = '100'
d20 = '20'
d12 = '12'
d10 = '10'
d6 = '6'
d4 = '4'
def pelican_run(cmd: list = [], publish=False) -> None:
settings = CONFIG['settings_publish' if publish else 'settings_base']
pelican_main(['-s', settings] + cmd)
@ -165,16 +180,24 @@ def restock(source: str = typer.Argument(
...,
help='The source file for the store.'
),
frequency: str = typer.Option(
frequency: str = Annotated[
str,
typer.Option(
'default',
help='use the specified frequency from the source file'),
die: int = typer.Option(
help='use the specified frequency from the source file'
)
],
die: Die = typer.Option(
20,
help='The size of the die for which to create a table'),
template_dir: str = typer.Argument(
help='The size of the die for which to create a table'
),
template_dir: str = Annotated[
str,
typer.Argument(
CONFIG['templates_path'],
help="Override the default location for markdown templates.",
)
],
) -> None:
rt = RollTable(
@ -240,5 +263,25 @@ def new(
category, template or content_type.value))
@app.command()
def dmsh():
import termios, sys
session = defaultdict(dict)
prompt = InteractiveShell(
[
"[title]DM's Shell.[/title]",
'dmsh'
], config=CONFIG, session=session
)
# ensure the terminal is restored on exit.
old_attrs = termios.tcgetattr(sys.stdin)
try:
asyncio.run(prompt.start())
finally:
termios.tcsetattr(sys.stdin, termios.TCSANOW, old_attrs)
if __name__ == '__main__':
app()

View File

@ -0,0 +1,170 @@
import os
from configparser import ConfigParser
from pathlib import Path
from textwrap import dedent
from typing import Union, List
import rich.repr
from rich.console import Console as _Console
from rich.markdown import Markdown
from rich.theme import Theme
from rich.table import Table, Column
from prompt_toolkit import PromptSession
from prompt_toolkit.patch_stdout import patch_stdout
from prompt_toolkit.formatted_text import ANSI
from prompt_toolkit.styles import Style
from prompt_toolkit.output import ColorDepth
BASE_STYLE = {
'help': 'cyan',
'bright': 'white',
'repr.str': 'dim',
'repr.brace': 'dim',
'repr.url': 'blue',
'table.header': 'white',
'toolbar.fg': '#888888',
'toolbar.bg': '#111111',
'toolbar.bold': '#FFFFFF',
}
def console_theme(theme_name: Union[str, None] = None) -> dict:
"""
Return a console theme as a dictionary.
Args:
theme_name (str):
"""
cfg = ConfigParser()
cfg.read_dict({'styles': BASE_STYLE})
if theme_name:
theme_path = theme_name if theme_name else os.environ.get('DEFAULT_THEME', 'blue_train')
cfg.read(Theme(
Path(theme_path) / Path('console.cfg')
))
return cfg['styles']
@rich.repr.auto
class Console(_Console):
"""
SYNOPSIS
Subclasses a rich.console.Console to provide an instance with a
reconfigured themes, and convenience methods and attributes.
USAGE
Console([ARGS])
ARGS
theme The name of a theme to load. Defaults to DEFAULT_THEME.
EXAMPLES
Console().print("Can I kick it?")
>>> Can I kick it?
INSTANCE ATTRIBUTES
theme The current theme
"""
def __init__(self, *args, **kwargs):
self._console_theme = console_theme(kwargs.get('theme', None))
self._overflow = 'ellipsis'
kwargs['theme'] = Theme(self._console_theme, inherit=False)
super().__init__(*args, **kwargs)
self._session = PromptSession()
@property
def theme(self) -> Theme:
return self._console_theme
def prompt(self, lines: List, **kwargs) -> str:
"""
Print a list of lines, using the final line as a prompt.
Example:
Console().prompt(["Can I kick it?", "[Y/n] ")
>>> Can I kick it?
[Y/n]>
"""
prompt_style = Style.from_dict({
# 'bottom-toolbar': f"{self.theme['toolbar.fg']} bg:{self.theme['toolbar.bg']}",
# 'toolbar-bold': f"{self.theme['toolbar.bold']}"
})
for line in lines[:-1]:
super().print(line)
with self.capture() as capture:
super().print(f"[prompt bold]{lines[-1]}>[/] ", end='')
text = ANSI(capture.get())
# This is required to intercept key bindings and not mess up the
# prompt. Both the prompt and bottom_toolbar params must be functions
# for this to correctly regenerate the prompt after the interrupt.
with patch_stdout(raw=True):
return self._session.prompt(
lambda: text,
style=prompt_style,
color_depth=ColorDepth.TRUE_COLOR,
**kwargs)
def mdprint(self, txt: str, **kwargs) -> None:
"""
Like print(), but support markdown. Text will be dedented.
"""
self.print(Markdown(dedent(txt), justify='left'), **kwargs)
def print(self, txt: str, **kwargs) -> None:
"""
Print text to the console, possibly truncated with an ellipsis.
"""
super().print(txt, overflow=self._overflow, **kwargs)
def debug(self, txt: str, **kwargs) -> None:
"""
Print text to the console with the current theme's debug style applied, if debugging is enabled.
"""
if os.environ.get('DEBUG', None):
self.print(dedent(txt), style='debug')
def error(self, txt: str, **kwargs) -> None:
"""
Print text to the console with the current theme's error style applied.
"""
self.print(dedent(txt), style='error')
def table(self, *cols: List[Column], **params) -> None:
"""
Print a rich table to the console with theme elements and styles applied.
parameters and keyword arguments are passed to rich.table.Table.
"""
background_style = f"on {self.theme['background']}"
params.update(
header_style=background_style,
title_style=background_style,
border_style=background_style,
row_styles=[background_style],
caption_style=background_style,
style=background_style,
)
params['min_width'] = 80
width = os.environ.get('CONSOLE_WIDTH', 'auto')
if width == 'expand':
params['expand'] = True
elif width != 'auto':
params['width'] = int(width)
return Table(*cols, **params)

View File

@ -0,0 +1,35 @@
from prompt_toolkit import PromptSession
from prompt_toolkit.completion import NestedCompleter
from prompt_toolkit.completion import FuzzyWordCompleter
from site_tools.cli import app
from enum import EnumMeta
from inspect import signature
from rich import print
def dmsh():
session = PromptSession()
def cmd2dict(cmd):
sig = signature(cmd)
if not sig.parameters:
return None
cmds = {}
for (k, v) in list(sig.parameters.items()):
print(v, dir(v))
if v.annotation.__class__ == EnumMeta:
cmds[k] = FuzzyWordCompleter([e.value for e in v.annotation])
else:
cmds[k] = None
return cmds
commands = dict(
site=dict((c.callback.__name__, cmd2dict(c.callback)) for c in app.registered_commands)
)
print(commands)
completer = NestedCompleter.from_nested_dict(commands)
text = session.prompt("DM> ", completer=completer, complete_while_typing=True, enable_history_search=False)
words = text.split()
print(f"You said {words}")

View File

@ -0,0 +1 @@
from .base import BasePrompt

View File

@ -0,0 +1,125 @@
import functools
from collections import namedtuple, defaultdict
from prompt_toolkit.completion import NestedCompleter
from site_tools.console import Console
from textwrap import dedent
COMMANDS = defaultdict(dict)
Command = namedtuple('Commmand', 'prompt,handler,usage,completer')
def register_command(handler, usage, completer=None):
prompt = handler.__qualname__.split('.', -1)[0]
cmd = handler.__name__
if cmd not in COMMANDS[prompt]:
COMMANDS[prompt][cmd] = Command(
prompt=prompt,
handler=handler,
usage=usage,
completer=completer,
)
def command(usage, completer=None, binding=None):
def decorator(func):
register_command(func, usage, completer)
@functools.wraps(func)
def wrapper(*args, **kwargs):
return func(*args, **kwargs)
return wrapper
return decorator
class BasePrompt(NestedCompleter):
def __init__(self, console=None):
super(BasePrompt, self).__init__(self._nested_completer_map())
self._prompt = ''
self._autocomplete_values = []
self._console = None
self._theme = None
def _nested_completer_map(self):
return dict(
(cmd_name, cmd.completer) for (cmd_name, cmd) in COMMANDS[self.__class__.__name__].items()
)
def _get_help(self, cmd=None):
try:
return dedent(COMMANDS[self.__class__.__name__][cmd].usage)
except KeyError:
return self.usage
def default_completer(self, document, complete_event):
raise NotImplementedError(f"Implement the 'default_completer' method of {self.__class__.__name__}")
@property
def usage(self):
text = dedent("""
[title]dmsh[/title]
Available commands are listed below. Try 'help COMMAND' for detailed help.
[title]COMMANDS[/title]
""")
for (name, cmd) in sorted(self.commands.items()):
text += f" [b]{name:10s}[/b] {cmd.handler.__doc__.strip()}\n"
return text
@property
def commands(self):
return COMMANDS[self.__class__.__name__]
@property
def console(self):
if not self._console:
self._console = Console(color_system='truecolor')
return self._console
@property
def prompt(self):
return self._prompt
@property
def autocomplete_values(self):
return self._autocomplete_values
@property
def toolbar(self):
return None
@property
def key_bindings(self):
return None
def help(self, parts):
attr = None
if parts:
attr = parts[0]
self.console.print(self._get_help(attr))
return True
def process(self, cmd, *parts):
if cmd in self.commands:
return self.commands[cmd].handler(self, parts)
self.console.error(f"Command {cmd} not understood.")
def start(self, cmd=None):
while True:
if not cmd:
cmd = self.console.prompt(
self.prompt,
completer=self,
bottom_toolbar=self.toolbar,
key_bindings=self.key_bindings)
if cmd:
cmd, *parts = cmd.split()
self.process(cmd, *parts)
cmd = None

View File

@ -0,0 +1,191 @@
from site_tools.shell.base import BasePrompt, command
from rolltable.tables import RollTable
from rich.table import Table
from pathlib import Path
from prompt_toolkit.completion import WordCompleter
from prompt_toolkit.key_binding import KeyBindings
from prompt_toolkit.application import get_app
bindings = KeyBindings()
class InteractiveShell(BasePrompt):
def __init__(self, prompt=[], config={}, session={}):
super().__init__()
self._prompt = prompt
self._config = config
self._wmt = ""
self._subshells = {}
self._register_subshells()
self._register_keybindings()
self._session = session
def _register_keybindings(self):
@bindings.add('c-q')
@bindings.add('c-d')
def quit(event):
self.quit()
@bindings.add('c-h')
def help(event):
self.help()
@bindings.add('c-w')
def wmt(event):
self.wmt()
def _register_subshells(self):
for subclass in BasePrompt.__subclasses__():
if subclass.__name__ == self.__class__.__name__:
continue
self._subshells[subclass.__name__] = subclass(parent=self)
@property
def key_bindings(self):
return bindings
@property
def toolbar(self):
return [
('class:bold', ' DMSH '),
('', " [H]elp "),
('', " [W]mt "),
('', " [Q]uit "),
]
@property
def session(self):
return self._session
@property
def autocomplete_values(self):
return list(self.commands.keys())
def default_completer(self, document, complete_event): # pragma: no cover
word = document.current_line_before_cursor
raise Exception(word)
def process(self, cmd, *parts):
if cmd in self.commands:
return self.commands[cmd].handler(self, parts)
return "Unknown Command; try help."
@command(usage="""
[title]QUIT[/title]
The [b]quit[/b] command exits dmsh.
[title]USAGE[/title]
[link]> quit|^D|<ENTER>[/link]
""")
def quit(self, *parts):
"""
Quit dmsh.
"""
get_app().exit()
raise SystemExit("Okay BYEEEE")
@command(usage="""
[title]HELP FOR THE HELP LORD[/title]
The [b]help[/b] command will print usage information for whatever you're currently
doing. You can also ask for help on any command currently available.
[title]USAGE[/title]
[link]> help [COMMAND][/link]
""")
def help(self, *parts):
"""
Display the help message.
"""
super().help(parts)
return True
@command(usage="""
[title]INCREMENT DATE[/title]
[b]id[/b] Increments the calendar date by one day.
[title]USAGE[/title]
[link]id[/link]
""")
def id(self, *parts):
"""
Increment the date by one day.
"""
raise NotImplementedError()
@command(usage="""
[title]LOCATION[/title]
[b]loc[/b] sets the party's location to the specified region of the Sahwat Desert.
[title]USAGE[/title]
[link]loc LOCATION[/link]
""",
completer=WordCompleter([
"The Blooming Wastes",
"Dust River Canyon",
"Gopher Gulch",
"Calamity Ridge"
]))
def loc(self, *parts):
"""
Move the party to a new region of the Sahwat Desert.
"""
if parts:
self.session['location'] = (' '.join(parts))
self.console.print(f"The party is in {self.session['location']}.")
@command(usage="""
[title]OVERLAND TRAVEL[/title]
[b]ot[/b]
[title]USAGE[/title]
[link]ot in[/link]
""")
def ot(self, *parts):
"""
Increment the date by one day and record
"""
raise NotImplementedError()
@command(usage="""
[title]WILD MAGIC TABLE[/title]
[b]wmt[/b] Generates a d20 wild magic surge roll table. The table will be cached for the session.
[title]USAGE[/title]
[link]> wmt[/link]
[title]CLI[/title]
[link]roll-table \\
sources/sahwat_magic_table.yaml \\
--frequency default --die 20[/link]
""")
def wmt(self, *parts, source='sahwat_magic_table.yaml'):
"""
Generate a Wild Magic Table for resolving spell effects.
"""
if not self._wmt:
rt = RollTable(
[Path(f"{self._config['table_sources_path']}/{source}").read_text()],
frequency='default',
die=20,
)
table = Table(*rt.expanded_rows[0])
for row in rt.expanded_rows[1:]:
table.add_row(*row)
self._wmt = table
self.console.print(self._wmt)