From c18aa29c581be2129bce85c8c5679bab01e60608 Mon Sep 17 00:00:00 2001 From: Emile Clark-Boman Date: Sun, 22 Jun 2025 00:26:54 +1000 Subject: [PATCH] got distracted and made a repl framework....... --- bcrypter/cli/cmd.py | 29 +++++-- bcrypter/cli/opt.py | 176 ++++++++++++++++++++++++++++++++++++++--- bcrypter/exceptions.py | 20 +++++ bcrypter/lib/format.py | 11 +++ bcrypter/lib/other.py | 8 ++ 5 files changed, 227 insertions(+), 17 deletions(-) create mode 100644 bcrypter/lib/other.py diff --git a/bcrypter/cli/cmd.py b/bcrypter/cli/cmd.py index 9fee5e9..7402da6 100644 --- a/bcrypter/cli/cmd.py +++ b/bcrypter/cli/cmd.py @@ -1,7 +1,10 @@ +from itertools import chain, tee + from bcrypter.lib.result import Result +from bcrypter.cli.opt import Overlap class Command: - NAME = '[Abstract]Command' + NAME = '' # intentionally left empty (invalid name) ARGS = [] FLAGS = [] OPTIONS = [] @@ -21,9 +24,22 @@ class Command: (ie no duplicate names or parsing ambiguity) ''' @classmethod - def _is_well_defined(cls: Command) -> Result[None]: - raise NotImplementedException('Command.is_consistent()') - + def _is_well_defined(cls: Command) -> Result[list[Overlap]]: + overlaps: list[Overlap] = [] + opts = chain(cls.FLAGS, cls.OPTIONS) + # Detect duplicate names and duplicate short/longforms + for optX in opts: + overlap = Overlap.empty(optX) + other_opts = tee(opts) # duplicate the opts iterator + for optY in other_opts: + overlap += Overlap.of(optX, optY) + # check if overlap occurred, add to list of all overlaps + if not overlap.is_empty(): + overlaps.append(overlap) + if overlaps: + return Result.fail(overlaps) + return Result.succeed([]) + ''' Attempt to match an arg to its flag or option NOTE: _match_arg() assumes _is_well_defined() == True @@ -37,7 +53,10 @@ class Command: class Builtin(Command): - self.NAME = '[Abstract]Builtin' + NAME = '' # intentionally left empty (invalid name) + ARGS = [] + FLAGS = [] + OPTIONS = [] def __init__(self, repl_builtins: list[Builtin], repl_cmds: list[Command]) -> None: diff --git a/bcrypter/cli/opt.py b/bcrypter/cli/opt.py index a0eb0f3..338f06c 100644 --- a/bcrypter/cli/opt.py +++ b/bcrypter/cli/opt.py @@ -1,21 +1,173 @@ from enum import Enum +from dataclass import dataclass -class OptType(Enum): - AbstractOpt # only used by Opt +from bcrypter.lib.other import tryget +from bcrypter.exceptions import ( + BadOptNameError, + InvalidOptError, + DisjointOverlapError, +) + +''' +Overlap dataclass is used to check if two Opts are incompatible +ie same name, same shortform, same longform +''' +@dataclass +class Overlap: + master: Opt # the Opt we measure overlap against + name: list[Opt] + shortform: list[Opt] + longform: list[Opt] + + ''' + Create an empty Overlap for a master + ''' + @classmethod + def empty(cls: Overlap, A: Opt) -> Overlap: + return cls(A, [], [], []) + + ''' + Calculate the overlap of two objects inheriting from Opt + ''' + @classmethod + def of(cls: Overlap, A: Opt, B: Opt) -> Overlap: + # "equal to and not None" + eqnn = lambda x, y: x is not None and x == y + return cls(A, + [B] if eqnn(A.name, B.name) else [], + [B] if eqnn(A.shortform, B.shortform) else [], + [B] if eqnn(A.longform, B.longform) else []) + + def __add__(self, O: Overlap) -> Overlap: + if self.master != O.master: + raise DisjointOverlapError() + return Overlap(master, + self.name + O.name, + self.shortform + O.shortform, + self.longform + O.longform) + + def is_empty(self) -> bool: + return not (self.name or self.shortform or self.longform): + +class ParamType(Enum): + AbstractParam # only used by Param + AbstractOpt # only used by Opt + Arg Flag Option -class Opt: - _TYPE: OptType = OptType.AbstractOpt - def __init__(self, *args) -> None: - pass +class Param: + _TYPE: ParamType = ParamType.AbstractParam + def __init__(self, name: str, description: str) -> None: + self.name = name + self.description = description + +class Opt(Param): + _TYPE: ParamType = ParamType.AbstractOpt + __FAIL_MSG = 'Invalid Opt' + def __init__(self, *args, name: str = '', description: str = '') -> None: + # ensure non-empty name kwarg provided + if not name: + raise BadOptNameError() + super().__init__(name, description) + # result.value is a list of error messages (if failed) + self.result: Result[None|list[str]] = Result.succeed(None) + self.shortform: str | None = None + self.longform: str | None = None + self._parse_args(args) + + ''' + Parse provided *args as short/longform + NOTE: sets self.result as fail with error message list as value + ''' + def _parse_args(self, args: list[str]) -> None: + # store all short/long/unknown forms although multiple arent allowed + # used to provide better errors (displaying ALL duplicates) + shortforms = [] + longforms = [] + unknowns = [] + for arg in args: + if Opt.is_longform(arg): + longforms.append(arg) + elif Opt.is_shortform(arg): + shortforms.append(arg) + else: + unknowns.append(arg) + self.shortform = tryget(shortforms, 0) # default: None + self.longform = tryget(longforms, 0) # default: None + # check if any duplicate arg forms occurred + errs = [] + if len(shortforms) > 1: + errs.append(Opt._fail_duplicate_shortsforms(shortforms)) + if len(longforms) > 1: + errs.append(Opt._fail_duplicate_longforms(longforms)) + if unknowns: + errs.append(Opt._fail_unknown_form(unknowns)) + # ensure at least one short/longform given + if not (shortforms or longforms or unknowns): + raise InvalidOptError() + # set Opt.result according to derived errors + if errs: + if self.result.is_err(): + self.result.value.extend(errs) + else: + self.result = Result.fail(Opt.__FAIL_MSG, value=errs) + + ''' + Opt.is_shortform and Opt.is_longform check whether an + arg is in the form ie `-v`, or `--verbose` respectively. + NOTE: Always check longform before shortform, ie anything + NOTE: with is_longform will also be is_shortform (im lazy) + ''' + @staticmethod + def is_shortform(arg: str) -> bool: + return arg.startswith('-') + + @staticmethod + def is_longform(arg: str) -> bool: + return arg.startswith('--') + + ''' + ========================================================= + Methods for formatting+returning error messages as a way + of logging the outcome of longform/shortform/etc parsing. + ========================================================= + ''' + @staticmethod + def _fail_bad_forms(form_type: str, forms: list) -> str: + forms = ', '.join[f'\"{f}\"' for f in forms] + return f'Opt \"{self.name}\" got {form_type}: {forms}' + + @staticmethod + def _fail_duplicate_shortforms(args: list[str]) -> str: + return Opt._fail_bad_forms('duplicate shortforms', args) + + @staticmethod + def _fail_duplicate_longforms(args: list[str]) -> str: + return Opt._fail_bad_forms('duplicate longforms', args) + + @staticmethod + def _fail_unknown_forms(args: [str]) -> str: + form_type = 'unknown arg' + if len(args) > 1: + form_type += 's' # make plural + return Opt._fail_bad_forms(form_type, args) + + # @staticmethod + # def _fail_overlap(form_type: str, form: str, name1: str, name2: str) -> str: + # return f'Duplicate {form_type} \"{form}\" for Opts \"{name1}\" & \"{name2}\"' + +class Arg(Param): + _TYPE: ParamType = ParamType.Arg + def __init__(self, name: str = '', description: str = '') -> None: + super().__init__(name=name, description=description) class Flag(Opt): - _TYPE: OptType = OptType.Flag - def __init__(self, *args) -> None: - super().__init__(*args) + _TYPE: ParamType = ParamType.Flag + def __init__(self, *args, name: str = '', description: str = '') -> None: + super().__init__(name=name, description=description) class Option(Opt): - _TYPE: OptType = OptType.Option - def __init__(self, *args) -> None: - super().__init__(*args) + _TYPE: ParamType = ParamType.Option + def __init__(self, *args, name: str = '', description: str = '') -> None: + super().__init__(name=name, description=description) diff --git a/bcrypter/exceptions.py b/bcrypter/exceptions.py index 49f6fff..0827d9b 100644 --- a/bcrypter/exceptions.py +++ b/bcrypter/exceptions.py @@ -1,2 +1,22 @@ +''' +======================================================== +Exceptions for CLI / REPL Commands and Param/Opt Objects +======================================================== +''' +class BadOptNameError(Exception): + __MSG = 'Opt.__init__() requires non-empty kwarg `name: str = ...`' + def __init__(self) -> None: + super().__init__(BadOptNameError.__MSG) + +class InvalidOptError(Exception): + __MSG = 'Opt.__init__() requires at least 1 short/longform' + def __init__(self) -> None: + super().__init__(InvalidOptError.__MSG) + +class DisjointOverlapError(Exception): + __MSG = 'Overlap.__add__() requires shared master, got disjoint' + def __init__(self) -> None: + super().__init__(DisjointOverlapError.__MSG) + class CmdDeclarationError(Exception): pass diff --git a/bcrypter/lib/format.py b/bcrypter/lib/format.py index 1a5f608..a0d0c98 100644 --- a/bcrypter/lib/format.py +++ b/bcrypter/lib/format.py @@ -15,3 +15,14 @@ Left pad an integer's binary representation with zeros def lpadbin(x: int, n: int) -> str: return lpad(bin(x)[2:], n, pad='0') +''' +Extends the standard lstrip string method, +allowing the max: int kwarg to be supplied. +''' +def lstrip(s: str, prefix: str, max: int = -1) -> str: + l = len(prefix) + c = 0 + while s.startswith(prefix) and c < max: + s = s[l:] + c += 1 + return s diff --git a/bcrypter/lib/other.py b/bcrypter/lib/other.py new file mode 100644 index 0000000..72a3cb6 --- /dev/null +++ b/bcrypter/lib/other.py @@ -0,0 +1,8 @@ +''' +Implements a safe way of indexing a list. +''' +def tryget(L: list[Any], i: int, default: Any | None = None) -> Any: + try: + return L[i] + except IndexError: + return default