Compare commits

..

No commits in common. "fd5af59c599418cadfa52b7c9f6c5b7737b8cb99" and "ffc39641bec23215b22750e4ac66497df246c5a6" have entirely different histories.

9 changed files with 120 additions and 149 deletions

@ -1,17 +1,17 @@
app_skellington app_skellington
=============== ===============
Application framework for Python, features include: Application framework for Python, features include:
- Pain-free multi-level command menu: Expose public class methods as commands available to user. * Pain-free multi-level command menu: Expose public class methods as commands available to user.
- Simple to define services and automatic dependency injection based on name (with custom invocation as an option). \*WIP * Simple to define services and automatic dependency injection based on name (with custom invocation as an option). *WIP
- INI-style config and and validation (provided through ConfigObj). * INI-style config and and validation (provided through ConfigObj).
- Colored logging (provided through colorlog) * Colored logging (provided through colorlog)
- Works on Linux, Windows, and Mac. * Works on Linux, Windows, and Mac.
Principles: Principles:
- Lend to creating beautiful, easy to read and understand code in the application. * Lend to creating beautiful, easy to read and understand code in the application.
- Minimize coupling of applications to this framework. * Minimize coupling of applications to this framework.
- Compatable with Linux, Windows, and Mac. Try to be compatible as possible otherwise. * Compatable with Linux, Windows, and Mac. Try to be compatible as possible otherwise.
- Try to be compatible with alternate Python runtimes such as PyPy and older python environments. \*WIP * Try to be compatible with alternate Python runtimes such as PyPy and older python environments. *WIP
Application Configuration Application Configuration
------------------------- -------------------------
@ -23,8 +23,8 @@ application which is built on app_skellington. The format is multi-level .ini sy
Reference the ConfigObj documentation for config.ini and config.spec Reference the ConfigObj documentation for config.ini and config.spec
format. See: format. See:
- https://configobj.readthedocs.io/en/latest/configobj.html#the-config-file-format * https://configobj.readthedocs.io/en/latest/configobj.html#the-config-file-format
- https://configobj.readthedocs.io/en/latest/configobj.html#validation * https://configobj.readthedocs.io/en/latest/configobj.html#validation
Config files (config.ini) are created if they don't exist. The Config files (config.ini) are created if they don't exist. The
file always contains the full specification of parameters; i.e. even default file always contains the full specification of parameters; i.e. even default
@ -38,9 +38,9 @@ Linux:
Windows: Windows:
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini C:\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\config.ini
C:\\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log C:\Users\\\<user>\\\<app_name\>\\Local\\\<app_name\>\\Logs\\\<app_name\>.log
Application configuration can be overridden ad-hoc through the --config <filename> Application configuration can be overridden ad-hoc through the --config <filename>
argument. argument.
@ -50,26 +50,19 @@ Debug - Turn on Logging
Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns Set 'APPSKELLINGTON_ENABLE_LOGGING' environment variable to any value which turns
on AppSkellington-level logging. For example, on AppSkellington-level logging. For example,
APPSKELLINGTON_DEBUG=1 <executable> APP_SKELLINGTON_DEBUG=1 <executable>
or or
export APPSKELLINGTON_DEBUG=1 export APP_SKELLINGTON_DEBUG=1
<executable> <executable>
Tests Tests
----- -----
Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory. Tests are a WIP. Recommendation is to run 'pytest' in the 'tests' directory.
License
-------
I'm releasing this software under one of the most permissive
licenses, the MIT software license. This applies to this source repository
and all files within it. Alternatively, you are permitted you
to use any of this under the GPL to the fullest legal extent allowed.
Notes Notes
----- -----
See official website: https://zavage-software.com See official website: https://zavage-software.com
Please report bugs, improvements, or feedback! Please report bugs, improvements, or feedback! <contact>

@ -1,6 +1,9 @@
import logging import logging
import sys import sys
APP_CONFIG_FILENAME = 'config.ini' # Relative to user directory on machine
APP_CONFIGSPEC_FILENAME = 'config.spec' # Relative to module source directory
from .app_container import * from .app_container import *
from .cfg import * from .cfg import *
from .cli import * from .cli import *

@ -10,21 +10,21 @@ def check_env_has_dependencies(libnames):
try: try:
__import__(libname) __import__(libname)
except ModuleNotFoundError as ex: except ModuleNotFoundError as ex:
print('Missing third-party library: ', ex, file=sys.stderr) print('missing third-part library: ', ex, file=sys.stderr)
rc = False rc = False
return rc return rc
if not check_env_has_dependencies(libnames): if not check_env_has_dependencies(libnames):
print('Unable to load program without installed dependencies', file=sys.stderr) print('refusing to load program without installed dependencies', file=sys.stderr)
raise ImportError('python environment needs third-party dependencies installed') raise ImportError('python environment needs third-party dependencies installed')
# Logger for before the application and logging config is loaded # Logger for before the application and logging config is loaded
# - used to log before logging is configured # - used to log before logging is configured
_log_fmt = '%(levelname)-7s:%(message)s' _log_fmt = '%(levelname)-7s:%(message)s'
_logger_name = 'skell' _logger_name = 'app_skellington'
_bootstrap_logger = logging.getLogger(_logger_name) _bootstrap_logger = logging.getLogger(_logger_name)
# NOTE(MG) Logger monkey-patch:
# This is done twice: once when app_skellington # NOTE(MG) This is done twice: once when app_skellington
# module is imported via _bootstrap.py and again if logging # module is imported via _bootstrap.py and again if logging
# configuration is reloaded. This catches APPSKELLINGTON_DEBUG # configuration is reloaded. This catches APPSKELLINGTON_DEBUG
# environment variable the first time, as app_skellington module # environment variable the first time, as app_skellington module

@ -12,12 +12,20 @@ from . import _util
from . import cli from . import cli
from . import cfg from . import cfg
import logging DEFAULT_APP_NAME = 'python-app'
DEFAULT_APP_AUTHOR = 'John Doe'
# These two variables affect the directory paths for
# config files and logging. # OPTIONAL: classes can sub-class from this?
DEFAULT_APP_NAME = '' class Components:
DEFAULT_APP_AUTHOR = '' def inject_dependencies_based_on_names_in_args(self):
pass
def inject_dependency(self, name):
pass
def register_dependency(self, service, name):
pass
class ApplicationContext: class ApplicationContext:
""" """
@ -36,9 +44,6 @@ class ApplicationContainer:
object instances for services, passes off to the cli to determine what to object instances for services, passes off to the cli to determine what to
do, and then injects any necessary dependencies (e.g. database module) do, and then injects any necessary dependencies (e.g. database module)
and kicks off the functionality requested in the cli. and kicks off the functionality requested in the cli.
Override appname and appauthor arguments to direct config and log
directories.
""" """
def __init__( def __init__(
self, self,
@ -46,35 +51,27 @@ class ApplicationContainer:
configini_filepath=None, configini_filepath=None,
*args, **kwargs *args, **kwargs
): ):
# Instantiate root application context (container for globals)
# if configspec_filepath is None:
# configspec_filepath = self._get_configspec_filepath()
self.appname = kwargs.get('appname') or DEFAULT_APP_NAME self.appname = kwargs.get('appname') or DEFAULT_APP_NAME
self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR self.appauthor = kwargs.get('appauthor') or DEFAULT_APP_AUTHOR
# Instantiate application context which contains
# global state, configuration, loggers, and runtime args.
self._dependencies = {} self._dependencies = {}
config = cfg.Config(configspec_filepath, configini_filepath) config = cfg.Config(configspec_filepath, configini_filepath)
logger = log.LoggingLayer(self.appname, self.appauthor) logger = log.LoggingLayer(self.appname, self.appauthor)
# Try and load logging configuration if provided
log_config = config.get('logging')
if log_config is not None:
logger.configure_logging(log_config)
else:
logger.configure_logging()
# added here, is this okay to do twice?
logger.configure_logging()
self.ctx = ApplicationContext(config, logger) self.ctx = ApplicationContext(config, logger)
# Reference to root_app avail. in context
self.ctx.root_app = self
# Reference to context service avail. in root_app
self['ctx'] = lambda: self.ctx self['ctx'] = lambda: self.ctx
self.cli = cli.CommandTree() # Command-line interface self.cli = cli.CommandTree() # Command-line interface
# Run methods if subclass implemented them:
if callable(getattr(self, '_cli_options', None)): if callable(getattr(self, '_cli_options', None)):
self._cli_options() self._cli_options()
if callable(getattr(self, '_services', None)): if callable(getattr(self, '_services', None)):
@ -96,7 +93,7 @@ class ApplicationContainer:
Returns a factory of a service or dependency. The factory is a function Returns a factory of a service or dependency. The factory is a function
that is called to return an instance of the service object. that is called to return an instance of the service object.
app['datalayer'] => returns the made-up "datalayer" service. app_container['netezza'] => returns the netezza service instance
""" """
try: try:
service_factory = self._dependencies[service_name] # Retrieve factory function service_factory = self._dependencies[service_name] # Retrieve factory function
@ -104,6 +101,7 @@ class ApplicationContainer:
except KeyError as ex: except KeyError as ex:
msg = 'failed to inject service: {}'.format(service_name) msg = 'failed to inject service: {}'.format(service_name)
_bootstrap_logger.critical(msg) _bootstrap_logger.critical(msg)
_util.eprint(msg)
raise ServiceNotFound raise ServiceNotFound
def __setitem__(self, service_name, value): def __setitem__(self, service_name, value):
@ -131,9 +129,7 @@ class ApplicationContainer:
dependencies.append(self[dep_name]) dependencies.append(self[dep_name])
return model_constructor(*dependencies) return model_constructor(*dependencies)
def _get_config_filepath( def _get_config_filepath(self, app_name, app_author, config_filename='config.ini'):
self, app_name, app_author, config_filename='config.ini'
):
""" """
Attempt to find config.ini in the user's config directory. Attempt to find config.ini in the user's config directory.

@ -18,11 +18,6 @@ class Config:
""" """
Structure to store application runtime configuration. Also contains Structure to store application runtime configuration. Also contains
functionality to load configuration from local site file. functionality to load configuration from local site file.
Provide config.spec - specification file which defines allowed parameters and types.
Provide config.ini - configuration instance which contains values for any
configuration arguments.
""" """
DEFAULT_CAPABILITIES = { DEFAULT_CAPABILITIES = {
@ -82,9 +77,15 @@ class Config:
@configspec_filepath.setter @configspec_filepath.setter
def configspec_filepath(self, filepath): def configspec_filepath(self, filepath):
# Check if exists as file (at least seems to):
# if not _util.does_file_exist(filepath):
# _bootstrap_logger.error(
# 'failed to set config.spec: file not found '
# '(%s)', filepath)
# raise Exception
if filepath is None: if filepath is None:
_bootstrap_logger.debug( _bootstrap_logger.debug(
'cfg - Clearing configspec' 'cfg - clearing configspec'
) )
self._configspec_filepath = None self._configspec_filepath = None
self._configspec_data = None self._configspec_data = None
@ -99,17 +100,17 @@ class Config:
self._configspec_data = data self._configspec_data = data
self._has_changed_internally = True self._has_changed_internally = True
_bootstrap_logger.debug( _bootstrap_logger.debug(
'cfg - Set configspec and read contents: %s', 'cfg - set configspec and read contents: %s',
filepath filepath
) )
self.load_config() self.load_config()
return return
except OSError as ex: except OSError as ex:
_bootstrap_logger.critical( _bootstrap_logger.critical(
'cfg - Failed to find config.spec: file not found (%s)', 'cfg - failed to find config.spec: file not found (%s)',
filepath filepath
) )
raise OSError('Failed to read provided config.spec file') raise OSError('failed to read provided config.spec file')
self.load_config() self.load_config()
@ -151,19 +152,6 @@ class Config:
""" """
self._config_obj[key] = value self._config_obj[key] = value
def get(self, key, default=None):
"""
Attempt to retrieve configuration item, otherwise return default
provided value.
Similar to Dictionary.get()
"""
try:
v = self.__getitem__(key)
return v
except KeyError as ex:
return default
def load_config( def load_config(
self, configspec_filepath=None, configini_filepath=None self, configspec_filepath=None, configini_filepath=None
): ):
@ -228,7 +216,7 @@ class Config:
_bootstrap_logger.info('cfg - Validating config file against spec') _bootstrap_logger.info('cfg - Validating config file against spec')
val = validate.Validator() val = validate.Validator()
assert isinstance(self._config_obj, configobj.ConfigObj), 'expecting configobj.ConfigObj, received %s' % type(self._config_obj) assert isinstance(self._config_obj, configobj.ConfigObj), 'expecting configobj.ConfigObj, received %s' % type(self._config_obj)
# NOTE(MG) copy arg below instructs configobj to use defaults from spec file # NOTE(MG) copy below instructs configobj to use defaults from spec file
test_results = self._config_obj.validate( test_results = self._config_obj.validate(
val, copy=True, preserve_errors=True val, copy=True, preserve_errors=True
) )

@ -80,9 +80,9 @@ class CommandTree:
Creates a root-level submenu with no entries. SubMenu node is Creates a root-level submenu with no entries. SubMenu node is
returned which can have submenus and commands attached to it. returned which can have submenus and commands attached to it.
""" """
# NOTE(MG) Fix for Python>=3.7, # NOTE(MG) Fix below strategizes whether to pass in 'required'
# argparse.ArgumentParser added 'required' argument. # paremter to ArgumentParser.add_subparsers()
# Must also be written into SubMenu.create_submenu. # which was added in in Python3.7.
func_args = { func_args = {
'dest': param_name, 'dest': param_name,
'metavar': param_name, 'metavar': param_name,
@ -90,12 +90,11 @@ class CommandTree:
} }
if ( if (
sys.version_info.major == 3 sys.version_info.major == 3
and sys.version_info.minor < 7 and sys.version_info.minor <= 6
): ):
if is_required: if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7') _bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required'] del func_args['required']
# END fix for Python<3.7
# Creates an argument as a slot in the underlying argparse. # Creates an argument as a slot in the underlying argparse.
subparsers = self.root_parser.add_subparsers( subparsers = self.root_parser.add_subparsers(
@ -175,6 +174,13 @@ class CommandTree:
key, key,
help=helptext) help=helptext)
# # Wrapper function that instantiates an object and runs a method
# # on-demand. The object is created, injected with necessary
# # dependencies or services, and the method is invoked.
# def func(*args, **kwargs):
# obj = constructor()
# return cls_method(obj, *args, **kwargs)
# Build the CommandEntry structure # Build the CommandEntry structure
cmd = CommandEntry() cmd = CommandEntry()
cmd.argparse_node = self.root_parser cmd.argparse_node = self.root_parser
@ -191,6 +197,11 @@ class CommandTree:
self._single_command = cmd self._single_command = cmd
self._entries = None self._entries = None
# def _validate(self):
# pass
# # TODO(MG):
# # subparser can not be empty, needs to have parsers attached
def parse(self, args=None): def parse(self, args=None):
if args is None: if args is None:
args = sys.argv[1:] args = sys.argv[1:]
@ -226,15 +237,16 @@ class CommandTree:
return return
if args is False and unk is False: if args is False and unk is False:
_bootstrap_logger.error('cli - Failed parsing args.') _bootstrap_logger.error('failed parsing args')
return False return False
_bootstrap_logger.info('cli - Received args from shell: %s', args) _bootstrap_logger.info('cli - received args from shell: %s', args)
args = vars(args) args = vars(args)
cmd = self._lookup_command(args) cmd = self._lookup_command(args)
if cmd is None: if cmd is None:
_bootstrap_logger.critical('cli - Failed to find command.') print('cmd is None')
_bootstrap_logger.error('cli - failed to find command')
return False return False
return self._invoke_command(cmd, args) return self._invoke_command(cmd, args)
@ -271,6 +283,7 @@ class CommandTree:
lookup = submenu.entries.get(val) lookup = submenu.entries.get(val)
_bootstrap_logger.debug('cli - lookup, entries[{}] = {}'.format(val, lookup)) _bootstrap_logger.debug('cli - lookup, entries[{}] = {}'.format(val, lookup))
# print(submenu.entries)
# pop value # pop value
del args[argparse_param] del args[argparse_param]
@ -341,14 +354,16 @@ class SubMenu:
execute the command function. execute the command function.
""" """
if inspect.isfunction(func): if inspect.isfunction(func):
# print('func is function')
pass pass
elif inspect.ismethod(func): elif inspect.ismethod(func):
pass pass
# print('func is method')
else: else:
raise Exception('bad value passed in for function') raise Exception('bad value passed in for function')
if not cmd_name: if not cmd_name:
# TODO(MG) Safer sanitation # safe try/except
cmd_name = func.__name__ cmd_name = func.__name__
if func_signature is None: if func_signature is None:
@ -371,7 +386,7 @@ class SubMenu:
child_node = self.subparsers_obj.add_parser( child_node = self.subparsers_obj.add_parser(
cmd_name, # Note: cmd_name here will be the VALUE cmd_name, # Note: cmd_name here will be the VALUE
# passed into the argparse arg VARIABLE NAME # passed into the argparse arg VARIABLE NAME
# created when the SubMenu/argparse.add_subparsers() # created when the SubMenu/argparse.addZ_subparsers()
# was created. # was created.
help=help_text, help=help_text,
description=description_text description=description_text
@ -402,6 +417,13 @@ class SubMenu:
help=helptext help=helptext
) )
# # Wrapper function that instantiates an object and runs a method
# # on-demand. The object is created, injected with necessary
# # dependencies or services, and the method is invoked.
# def func(*args, **kwargs):
# obj = constructor()
# return cls_method(obj, *args, **kwargs)
# Build the CommandEntry structure # Build the CommandEntry structure
cmd = CommandEntry() cmd = CommandEntry()
cmd.argparse_node = child_node cmd.argparse_node = child_node
@ -447,29 +469,12 @@ class SubMenu:
help='sub-submenu help', help='sub-submenu help',
description='sub-sub description') description='sub-sub description')
# NOTE(MG) Fix for Python>=3.7,
# argparse.ArgumentParser added 'required' argument.
# Must also be written into CommandTree.init_submenu
func_args = {
'dest': var_name,
'metavar': var_name,
'required': is_required
}
if (
sys.version_info.major == 3
and sys.version_info.minor < 7
):
if is_required:
_bootstrap_logger.warn('Unable to enforce required submenu: Requires >= Python 3.7')
del func_args['required']
# END fix for Python<3.7
# Turn entry into a submenu of it's own: # Turn entry into a submenu of it's own:
# type = _SubParsersAction # type = _SubParsersAction
subp_node = entry_node.add_subparsers( subp_node = entry_node.add_subparsers(
**func_args dest = var_name,
) metavar = var_name,
required = is_required)
submenu = SubMenu( submenu = SubMenu(
self.parent, self.parent,

@ -1,4 +1,4 @@
from ._bootstrap import _bootstrap_logger, _logger_name from ._bootstrap import _bootstrap_logger
from . import _util from . import _util
import appdirs import appdirs
@ -22,6 +22,7 @@ DEFAULT_LOG_SETTINGS = {
'level': 'debug', 'level': 'debug',
'formatter': 'colored' 'formatter': 'colored'
} }
}, },
'loggers': { 'loggers': {
@ -38,11 +39,9 @@ DEFAULT_LOG_SETTINGS = {
} }
class LoggingLayer: class LoggingLayer:
def __init__( def __init__(self, appname, appauthor, config=None):
self, appname=None, appauthor=None self.appname = appname
): self.appauthor = appauthor
self.appname = appname or ''
self.appauthor = appauthor or ''
self.loggers = {} self.loggers = {}
def __getitem__(self, k): def __getitem__(self, k):
@ -77,6 +76,7 @@ class LoggingLayer:
self.transform_config(config_dict) self.transform_config(config_dict)
try: try:
# TODO(MG) switch to pretty-print, as it'd be more human readable
_bootstrap_logger.debug('log - Log configuration: %s', config_dict) _bootstrap_logger.debug('log - Log configuration: %s', config_dict)
logging.config.dictConfig(config_dict) logging.config.dictConfig(config_dict)
_bootstrap_logger.debug('log - Configured all logging') _bootstrap_logger.debug('log - Configured all logging')
@ -106,16 +106,13 @@ class LoggingLayer:
d = config_dict['loggers'][logger] d = config_dict['loggers'][logger]
self._convert_str_to_loglevel(d, 'level') self._convert_str_to_loglevel(d, 'level')
# Replace 'root' logger with '', logging module convention for root handler
# Implementation note: # Note: '' is disallowed in ConfigObj (hence the reason for this replacement)
# app_skellington expects root logger configuration to be under 'root'
# instead of '' (python spec) because '' is not a valid name in ConfigObj.
try: try:
if config_dict['loggers'].get('root') is not None: config_dict['loggers'][''] = config_dict['loggers']['root']
config_dict['loggers'][''] = config_dict['loggers']['root'] del config_dict['loggers']['root']
del config_dict['loggers']['root']
except Exception as ex: except Exception as ex:
_bootstrap_logger.warn('was not able to find and patch root logger configuration from arguments') _bootstrap.logger.warn('internal failure patching root logger')
# Evaluate the full filepath of the file handler # Evaluate the full filepath of the file handler
@ -133,20 +130,19 @@ class LoggingLayer:
config_dict['handlers']['file']['filename'] = log_filepath config_dict['handlers']['file']['filename'] = log_filepath
def _add_own_logconfig(self, config_dict): def _add_own_logconfig(self, config_dict):
# NOTE(MG) Monkey-patch logger # NOTE(MG) This is done twice: once when app_skellington
# This is done twice: once when app_skellington
# module is imported again if logging configuration is # module is imported again if logging configuration is
# reloaded. This catches APPSKELLINGTON_DEBUG environment # reloaded. This catches APPSKELLINGTON_DEBUG environment
# variable the second time, when it's being reloaded as a # variable the second time, when it's being reloaded as a
# logging configuration is read from config file. # logging configuration is read from config file.
# See _bootstrap.py # See _bootstrap.py
if os.environ.get('APPSKELLINGTON_DEBUG', None): if os.environ.get('APPSKELLINGTON_DEBUG', None):
if _logger_name not in config_dict['loggers']: if 'app_skellington' not in config_dict['loggers']:
config_dict['loggers'][_logger_name] = { config_dict['loggers']['app_skellington'] = {
'level': 'debug', 'propagate': 'false' 'level': 'debug', 'propagate': 'false'
} }
else: else:
config_dict['loggers'][_logger_name]['level'] = 'debug' config_dict['loggers']['app_skellington']['level'] = 'debug'
def _convert_str_to_loglevel(self, dict_, key): def _convert_str_to_loglevel(self, dict_, key):
""" """

@ -12,35 +12,24 @@
# #
# de-installation: # de-installation:
# #
# $ pip uninstall app_skellington # $ pip uninstall <app>
from setuptools import setup from setuptools import setup
import os
__project__ = 'app_skellington' __project__ = 'app_skellington'
__version__ = '0.1.1' __version__ = '0.1.0'
__description__ = 'A high-powered command line menu framework.'
long_description = __description__
readme_filepath = os.path.join(
os.path.abspath(os.path.dirname(__file__)),
'README.md'
)
with open(readme_filepath, encoding='utf-8') as fp:
long_description = fp.read()
setup( setup(
name = __project__, name = __project__,
version = __version__, version = __version__,
description = 'A high-powered command line menu framework.', description = 'A high-powered 2-level CLI framework',
long_description = long_description, author = 'Mathew Guest',
author = 'Mathew Guest', author_email = 't3h.zavage@gmail.com',
author_email = 't3h.zavage@gmail.com', url = 'https://git-mirror.zavage-software.com/Mirror/app_skellington',
url = 'https://git-mirror.zavage.net/Mirror/app_skellington', license = 'MIT',
license = 'MIT',
python_requires = '>=3', python_requires = '>=3',
classifiers = [ classifiers = [
'Development Status :: 3 - Alpha', 'Development Status :: 3 - Alpha',
@ -71,5 +60,6 @@ setup(
packages = ( packages = (
'app_skellington', 'app_skellington',
), ),
) )

@ -3,6 +3,6 @@ from app_skellington.cli import CommandTree
class TestCli_e2e: class TestCli_e2e:
def test_null_constructor_works(self): def test_null_constructor_works(self):
x = CommandTree() x = CommandTree()
assert True == True assert True == False