Skip to content

Commit

Permalink
PoC of REPL implementation using click-repl
Browse files Browse the repository at this point in the history
  • Loading branch information
s4n-cz committed Nov 5, 2024
1 parent 494b7f2 commit c0be110
Show file tree
Hide file tree
Showing 5 changed files with 82 additions and 94 deletions.
26 changes: 26 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,32 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
```


# click-repl

```
Copyright (c) 2014-2015 Markus Unterwaditzer & contributors.
Copyright (c) 2016-2026 Asif Saif Uddin & contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do
so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```


## cryptography

```
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ dependencies = [
"annotated-types~=0.7.0",
"argon2-cffi~=23.1.0",
"click~=8.1.7",
"click-repl~=0.3.0",
"cryptography~=43.0.1",
"humanize~=4.11.0",
"jinja2~=3.1.4",
Expand Down
3 changes: 2 additions & 1 deletion sereto/cli/aliases.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
cli_aliases: dict[str, str] = {
# "uc": "user-config",
# Resolve ambiguous command prefixes
"c": "config",
}
5 changes: 2 additions & 3 deletions sereto/cli/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,10 +88,9 @@ def ls(settings: Settings) -> None:


@cli.command()
@load_settings
def repl(settings: Settings) -> None:
def repl() -> None:
"""Start an interactive shell (REPL) for SeReTo."""
sereto_repl(cli=cli, settings=settings)
sereto_repl(cli=cli)


@cli.command()
Expand Down
141 changes: 51 additions & 90 deletions sereto/cli/commands.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
import os
import readline
from pathlib import Path
from types import TracebackType
from typing import Self
from typing import Literal

import click
from click import Group, get_app_dir
from pydantic import Field, TypeAdapter, ValidationError, validate_call
from click_repl import repl # type: ignore[import-untyped]
from prompt_toolkit.history import FileHistory
from prompt_toolkit.styles import Style
from pydantic import Field, validate_call
from rich import box
from rich.markup import escape
from rich.table import Table

from sereto.cli.utils import Console
from sereto.exceptions import SeretoPathError, SeretoValueError
from sereto.models.base import SeretoBaseModel
from sereto.models.project import Project
from sereto.models.settings import Settings
from sereto.settings import load_settings
from sereto.singleton import Singleton
from sereto.types import TypeProjectId

__all__ = ["sereto_ls", "sereto_repl"]
Expand Down Expand Up @@ -43,7 +45,7 @@ def sereto_ls(settings: Settings) -> None:
Console().print(table, justify="center")


class WorkingDir(SeretoBaseModel):
class WorkingDir(metaclass=Singleton):
"""Helper class for REPL implementing the `cd` command.
Attributes:
Expand Down Expand Up @@ -75,37 +77,7 @@ def go_back(self) -> None:
self.change(self.old_cwd)


class REPLHistory(SeretoBaseModel):
"""Context manager to handle the command history in the REPL.
Attributes:
history_file_path: The path to the history file.
"""

history_file: Path = Field(default=Path(get_app_dir(app_name="sereto")) / ".sereto_history")

def __enter__(self) -> Self:
"""Load the command history from the previous sessions."""
# Enable auto-saving of the history
readline.set_auto_history(True)

# Enable tab completion
readline.parse_and_bind("tab: complete")

# Load the history from the file
if self.history_file.is_file():
readline.read_history_file(self.history_file)

return self

def __exit__(
self, exc_type: type[BaseException] | None, exc_value: BaseException | None, traceback: TracebackType | None
) -> None:
"""Save the command history to a file for future sessions."""
readline.write_history_file(self.history_file)


def _get_repl_prompt() -> str:
def _get_repl_prompt() -> list[tuple[str, str]]:
"""Get the prompt for the Read-Eval-Print Loop (REPL).
Returns:
Expand All @@ -119,14 +91,26 @@ def _get_repl_prompt() -> str:
project = Project.load_from()
project_id = project.config.at_version(project.config.last_version()).id

# Define the prompt
base_prompt = "sereto > "
return f"({project_id}) {base_prompt}" if project_id else base_prompt
project_id_segment = [
("class:bracket", "("),
("class:project_id", f"{project_id}"),
("class:bracket", ") "),
]
sereto_segment = [("class:sereto", "sereto")]
gt_segment = [("class:gt", " > ")]

if project_id is None:
return sereto_segment + gt_segment
else:
return project_id_segment + sereto_segment + gt_segment


@click.command(name="cd")
@click.argument("project_id", type=str)
@load_settings
@validate_call
def _change_repl_dir(settings: Settings, cmd: str, wd: WorkingDir) -> None:
"""Change the current working directory in the Read-Eval-Print Loop (REPL).
def repl_cd(settings: Settings, project_id: TypeProjectId | Literal["-"]) -> None:
"""Switch the active project in the REPL.
Args:
settings: The Settings object.
Expand All @@ -137,70 +121,47 @@ def _change_repl_dir(settings: Settings, cmd: str, wd: WorkingDir) -> None:
SeretoValueError: If the report ID is invalid.
SeretoPathError: If the report's path does not exist.
"""
if len(cmd) < (prefix_len := len("cd ")):
raise SeretoValueError(f"Invalid command '{cmd}'. Use 'cd ID' to change active project.")

user_input = cmd[prefix_len:].strip()
wd = WorkingDir()

# `cd -` ... Go back to the previous working directory
if user_input == "-":
if project_id == "-":
wd.go_back()
return

# Extract the report ID from the user input
try:
ta: TypeAdapter[TypeProjectId] = TypeAdapter(TypeProjectId) # hack for mypy
report_id = ta.validate_python(user_input)
except ValidationError as e:
raise SeretoValueError(f"Invalid report ID. {e.errors()[0]['msg']}") from e

# Check if the report's location exists
# TODO: Should we iterate over all reports and read the config to get the correct path?
report_path = settings.reports_path / report_id
report_path = settings.reports_path / project_id
if not Project.is_project_dir(report_path):
raise SeretoPathError(f"Report '{report_id}' does not exist. Use 'ls' to list reports.")
raise SeretoPathError(f"Report '{project_id}' does not exist. Use 'ls' to list reports.")

# Change the current working directory to the new location
wd.change(report_path)


def sereto_repl(cli: Group, settings: Settings) -> None:
def sereto_repl(cli: Group) -> None:
"""Start an interactive Read-Eval-Print Loop (REPL) session.
Args:
cli: The main CLI group.
"""
Console().log("Starting interactive mode. Type 'exit' to quit and 'cd ID' to change active project.")

prompt = _get_repl_prompt()
wd = WorkingDir()

with REPLHistory():
while True:
try:
# TODO navigating the history (up/down keys) breaks the rich's prompt, no colors for now
# cmd = Console().input(prompt).strip()

# Get user input
cmd = input(prompt).strip()

match cmd:
case "exit" | "quit":
break
case "help" | "h" | "?":
cli.main(prog_name="sereto", args="--help", standalone_mode=False)
case s if s.startswith("cd "):
_change_repl_dir(settings=settings, cmd=cmd, wd=wd)
prompt = _get_repl_prompt()
case s if len(s) > 0:
cli.main(prog_name="sereto", args=cmd.split(), standalone_mode=False)
case _:
continue
except (KeyboardInterrupt, EOFError):
# Allow graceful exit with Ctrl+C or Ctrl+D
Console().log("Exiting interactive mode.")
break
except SystemExit:
pass # Click raises SystemExit on success
except Exception as e:
Console().log(f"[red]Error:[/red] {escape(str(e))}")
# Add REPL specific commands
cli.add_command(repl_cd)

# Define the prompt style
prompt_style = Style.from_dict(
{
"sereto": "#02a0f0 bold",
"bracket": "#8a8a8a",
"project_id": "#00ff00",
"gt": "#8a8a8a bold",
}
)

prompt_kwargs = {
"message": _get_repl_prompt,
"history": FileHistory(Path(get_app_dir(app_name="sereto")) / ".sereto_history"),
"style": prompt_style,
}
repl(click.get_current_context(), prompt_kwargs=prompt_kwargs)

0 comments on commit c0be110

Please sign in to comment.