0

I'm currently working on a CLI abstraction layer, which abstracts CLI programs as classes in Python. Such CLI programs offer a structured way to enable and configure CLI parameters. It helps checking for faulty inputs and generated properly escaped arguments (e.g. adding double quotes).

Note: The following example is using Git, while in my target application, it will be commercial tools, that don't offer any Python API or similar.

Basic Ideas:

  • An abstraction of tool Git declares a Git class, which derives from class Program.
    This parent class implements common methods to all programs.
  • CLI options are represented as nested class definitions on the Git class.
  • Nested classes are marked with a class-based decorator CLIOption derived from Attribute
    (see https://github.com/pyTooling/pyAttributes for more details)
  • CLI options can be enabled / modified via indexed syntax.
  • An instance of Git is used to enabled / configure CLI parameters and helps to assemble a list of correctly encoded strings that can be used e.g. in subprocess.Popen(...)
tool = Git()
tool[tool.FlagVersion] = True

print(tool.ToArgumentList())

Some Python Code:

from pyAttributes import Attribute

class CLIOption(Attribute): ...   # see pyAttributes for more details

class Argument:
  _name: ClassVar[str]

  def __init_subclass__(cls, *args, name: str = "", **kwargs):
    super().__init_subclass__(*args, **kwargs)
    cls._name = name

class FlagArgument(Argument): ...

class CommandArgument(Argument): ...

class Program:
  __cliOptions__: Dict[Type[Argument], Optional[Argument]]

  def __init_subclass__(cls, *args, **kwargs):
    """Hook into subclass creation to register all marked CLI options in ``__cliOptions__``.
    super().__init_subclass__(*args, **kwargs)

    # get all marked options and 
    cls.__cliOptions__ = {}
    for option in CLIOption.GetClasses():
      cls.__cliOptions__[option] = None

class Git(Program):
  @CLIOption()
  class FlagVersion(FlagArgument, name="--version"): ...

  @CLIOption()
  class FlagHelp(FlagArgument, name="--help"): ...

  @CLIOption()
  class CmdCommit(CommandArgument, name="commit"): ...

Observations:

  • As @martineau pointed out in a comment, the CLIOption decorator has no access to the outer scope. So the scope can't be annotated to the nested classes.
  • The nested classes are used because of some nice effects in Python not demonstrated here. Also to keep their scope local to a program. Imagine there might be multiple programs offering a FlagVersion flag. Some as -v, but others as --version.

Primary Questions:

  1. How can I check if class FlagVersion is a nested class of class Git?

What I investigated so far:

  • There is no helper function to achieve this goal like functions isinstance(...) or issubclass(...) are offering.
  • While root-level classes have a __module__ reference to the outer scope, nested classes have no "pointer" to the next outer scope.
  • Actually, nested classes have the same __module__ values.
    Which makes sense.
  • A class' __qualname__ includes the names of parent classes.
    Unfortunately this is a string like Git.FlagVersion

So I see a possible "ugly" solution using __qualname__ and string operations to check if a class is nested and if it's nested in a certain outer scope.

Algorithm:

  • Assemble fully qualified name from __module__ and __qualname__.
  • Check element by element from left to right for matches.

This gets even more complicated if one nested class is defined in a parent class and another nested class is defined in a derived class. Then I also need to look into MRO ... oOo

Secondary Questions:

  1. Is there a better way than using string operations?
  2. Shouldn't Pythons data model offer a better way to get this information?
Paebbels
  • 15,573
  • 13
  • 70
  • 139
  • 2
    Since you're decorating these nested classes with `@CLIOption()` anyway, why not write a custom decorator which calls `CLIOption` but also tags the class somehow (e.g. set `cls._is_nested = True`)? – kaya3 Dec 24 '21 at 10:30
  • Nested classes aren't terribly useful in Python — they get no special access to the class containing them. At best they are a way to put a class in the namespace of the one containing them — but doing that also make accessing them more work. – martineau Dec 24 '21 at 10:30
  • Bit ugly but you could see if the nested class is contained in the containing classes vars? `nested_class in vars(containing_class).values()` – Iain Shelvington Dec 24 '21 at 10:32
  • 1
    @kaya3: One might be able to set a flag to some constant value like `cls._is_nested = True`, but generally speaking a decorator can't determine what the outer class of the nested class is. – martineau Dec 24 '21 at 10:45
  • @martineau yes it's used for keeping the scope local. Imaging there might be multiple programs abstracted like this. All of them might have a `FlagVersion` CLI option. Some as `-v` others as `--version`. – Paebbels Dec 24 '21 at 11:02
  • @Paebbels: I've simply been trying to make the point that implementing this with nested classes in Python isn't a good approach. – martineau Dec 24 '21 at 11:15

2 Answers2

1

Because I don’t understand English very well, I understand by translating is that you need to find out how to get the embedded class decorated by CLIOption in the subclass of Program (Git here). If so, the following methods may help you.

I read some codes of some pyAttributes

from pyAttributes import Attribute

class Program(object):
    __cliOptions__: Dict[Type[Argument], Optional[Argument]]

    def __init_subclass__(cls, *args, **kwargs):
        cls.__cliOptions__ = {}
        for obj in cls.__dict__.values():
            if hasattr(obj, Attribute.__AttributesMemberName__):
                print(obj)
        # for option in CLIOption.GetClasses():
        #     cls.__cliOptions__[option] = None


class Git(Program):
    a = 1
    b = 2

    @CLIOption()
    class FlagVersion(FlagArgument, name="--version"):
        ...

    @CLIOption()
    class FlagHelp(FlagArgument, name="--help"):
        ...

Of course, the above can’t work directly. Later I found that there was of course an error in the Attribute._AppendAttribute method, as follows, I modified it


class CLIOption(Attribute):
    ...  # see pyAttributes for more details

    @staticmethod
    def _AppendAttribute(func: Callable, attribute: 'Attribute') -> None:
        # inherit attributes and prepend myself or create a new attributes list
        if Attribute.__AttributesMemberName__ in func.__dict__:
            func.__dict__[Attribute.__AttributesMemberName__].insert(0, attribute)
        else:
            # The original func.__setattr__(Attribute.__AttributesMemberName__, [attribute]) has an error
            # Because __setattr__ of class FlagVersion is object.__setattr__

            setattr(func, Attribute.__AttributesMemberName__, [attribute])
            # or object.__setattr__(func, Attribute.__AttributesMemberName__, [attribute])

mxp-xc
  • 456
  • 2
  • 6
  • I also found the problem with `__setattr__` vs. `setattr(...)`. This will be fixed in a new release of pyAttributes (written by me). From comments above, I also followed the approach of iterating `cls.__dict__`. Checking via `hasattr` is a smart move to reduce the number if processed elements in the iteration. I have pushed my solution called [isnestedclass(cls, scope)](https://github.com/pyTooling/pyTooling/blob/dev/pyTooling/Common/__init__.py?ts=2#L59) to GitHub. I'll think about using either `CLIOptions.GetClasses(scope=cls)` or your direct iteration and checking `__dict__` for attributes. – Paebbels Dec 25 '21 at 17:23
0

Following the proposed approaches by iterating __dict__ works quite good.

So this was the first solution developed based on the given ideas:

def isnestedclass(cls: Type, scope: Type) -> bool:
    for memberName in scope.__dict__:
        member = getattr(scope, memberName)
        if type(member) is type:
            if cls is member:
                return True

    return False

That solution doesn't work on members inherited from parent classes. So I extended it with searching through the inheritance graph via mro().

This is my current and final solution for a isnestedclass helper function.

def isnestedclass(cls: Type, scope: Type) -> bool:
    for mroClass in scope.mro():
        for memberName in mroClass.__dict__:
            member = getattr(mroClass, memberName)
            if type(member) is type:
                if cls is member:
                    return True

    return False

The function is available within the pyTooling package.

Paebbels
  • 15,573
  • 13
  • 70
  • 139