From dee65f06dea14ef8744570cee217856b7569e0f0 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Mon, 5 Mar 2018 13:45:15 -0600 Subject: [PATCH 1/5] update examples to match new API convention --- examples/submodules.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/examples/submodules.py b/examples/submodules.py index 1869b59..2c2b5e0 100644 --- a/examples/submodules.py +++ b/examples/submodules.py @@ -41,6 +41,7 @@ import argutil +@argutil.callable() def this(opts): if opts.config: print('config:', opts.config) @@ -51,6 +52,7 @@ def this(opts): print('foo set') +@argutil.callable() def that(opts): if opts.config: print('config:', opts.config) @@ -59,10 +61,6 @@ def that(opts): print('then:', opts.then) -env = { - 'this': this, - 'that': that -} -parser = argutil.get_parser(env=env) +parser = argutil.get_parser() opts = parser.parse_args() opts.func(opts) From 097698d21bf2a43e87f7352190ade47e3015d07c Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Tue, 6 Mar 2018 12:22:16 -0600 Subject: [PATCH 2/5] -add jsonschema validation for definitions file -add example for template usage -add tests for submodules and templates --- CHANGELOG.rst | 6 + argutil/__init__.py | 1 + argutil/argutil.py | 196 ++++++++++++++++----------------- argutil/commandline.schema | 103 +++++++++++++++++ examples/commandline.json | 50 +++++++++ examples/templates.py | 36 ++++++ requirements.txt | 1 + tests/defaults_file_test.py | 2 +- tests/definitions_file_test.py | 34 +++++- tests/get_parser_test.py | 69 +++++++++++- tests/helper.py | 14 +++ tests/module_creation_test.py | 5 +- tests/submodules_test.py | 193 ++++++++++++++++++++++++++++++++ tests/template_test.py | 150 +++++++++++++++++++++++++ 14 files changed, 745 insertions(+), 115 deletions(-) create mode 100644 argutil/commandline.schema create mode 100644 examples/templates.py create mode 100644 tests/submodules_test.py create mode 100644 tests/template_test.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 9b2e0f0..34af236 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,5 +1,11 @@ Changelog ========= +v1.1.5 +------ +- Add jsonschema validation for definitions file +- Add example for template usage +- Add tests for submodules and templates + v1.1.4 ------ - Add callable() decorator to replace env parameter in get_parser diff --git a/argutil/__init__.py b/argutil/__init__.py index 83b3243..a3e555d 100644 --- a/argutil/__init__.py +++ b/argutil/__init__.py @@ -5,6 +5,7 @@ get_parser, callable, ParserDefinition, + GLOBAL_ENV, VERSION, ) from .working_directory import WorkingDirectory, pushd diff --git a/argutil/argutil.py b/argutil/argutil.py index 7d69bd3..f95570b 100644 --- a/argutil/argutil.py +++ b/argutil/argutil.py @@ -18,8 +18,9 @@ from .deepcopy import deepcopy from .primitives import primitives import logging +import jsonschema -VERSION = '1.1.4' +VERSION = '1.1.5' logger = logging.getLogger('argutil') logger.setLevel(logging.ERROR) @@ -42,6 +43,37 @@ def get_file(**kwargs): return inspect.stack()[stackdepth].filename +def load(json_file, mode='a'): + if mode in 'ar': + if os.path.isfile(json_file): + with open(json_file, 'r') as f: + return json.load(f) + elif mode == 'r': + raise FileNotFoundError('file could not be read: ' + json_file) + if mode in 'wca': + return {} + raise ValueError('Unknown file mode "{}"'.format(mode)) + + +def save(json_data, json_file): + with open(json_file, 'w') as f: + f.write(json.dumps(json_data, indent=2)) + + +def validate(json_data_or_file): + if isinstance(json_data_or_file, dict): + json_data = json_data_or_file + elif ( + isinstance(json_data_or_file, str) and + os.path.isfile(json_data_or_file) + ): + json_data = load(json_data_or_file, 'r') + jsonschema.validate(json_data, commandline_schema) + return json_data + + +with WorkingDirectory(__file__): + commandline_schema = load('commandline.schema') GLOBAL_ENV = {} @@ -73,13 +105,12 @@ def create( argutil_dir = os.path.dirname(argutil_path) template_path = os.path.join(argutil_dir, defaults.TEMPLATE_FILE) shutil.copy2(template_path, filepath) + with WorkingDirectory(filepath): + definitions_file = os.path.abspath(definitions_file) + defaults_file = os.path.abspath(defaults_file) if not os.path.isfile(definitions_file): save({'modules': {}}, definitions_file) - json_data = load(definitions_file) - if 'modules' not in json_data: - raise KeyError( - '{} does not contain key "modules"'.format(definitions_file) - ) + json_data = validate(definitions_file) if module in json_data['modules']: if fail_if_exists: raise KeyError('module already defined') @@ -104,8 +135,9 @@ def __init__( filepath = os.path.abspath(filepath) self.filepath = filepath self.module = get_module(filepath) - self.definitions_file = definitions_file - self.defaults_file = defaults_file + with WorkingDirectory(filepath): + self.definitions_file = os.path.abspath(definitions_file) + self.defaults_file = os.path.abspath(defaults_file) self.env = env or {} def callable(self, name=None): @@ -114,30 +146,27 @@ def decorator(function): return function return decorator - def load(self, json_file, mode='a'): - with WorkingDirectory(self.filepath): - return load(json_file, mode) - - def save(self, json_data, json_file): - with WorkingDirectory(self.filepath): - return save(json_data, json_file) + def delete(self): + json_data = load(self.definitions_file) + del json_data['modules'][self.module] + save(json_data, self.definitions_file) + json_data = load(self.defaults_file) + if self.module in json_data: + del json_data[self.module] + save(json_data, self.defaults_file) def add_example( self, usage, description='', ): - if not isinstance(usage, str): - raise ValueError('usage must be a string!') - if not isinstance(description, str): - raise ValueError('description must be a string!') - json_data = self.load(self.definitions_file) + json_data = load(self.definitions_file) example = { 'usage': usage, 'description': description } json_data['modules'][self.module]['examples'].append(example) - self.save(json_data, self.definitions_file) + save(validate(json_data), self.definitions_file) def add_argument( self, @@ -145,7 +174,7 @@ def add_argument( short=None, **kwargs ): - json_data = self.load(self.definitions_file) + json_data = load(self.definitions_file) arg = {} if short is not None: arg['short'] = short @@ -174,10 +203,10 @@ def add_argument( arg['help'] = help json_data['modules'][self.module]['args'].append(arg) - self.save(json_data, self.definitions_file) + save(validate(json_data), self.definitions_file) def set_defaults(self, **kwargs): - json_data = self.load(self.defaults_file) + json_data = load(self.defaults_file) if self.module not in json_data: json_data[self.module] = {} module = json_data[self.module] @@ -191,10 +220,10 @@ def set_defaults(self, **kwargs): m = m[k_parent] k = k[index + 1:] m[k] = v - self.save(json_data, self.defaults_file) + save(json_data, self.defaults_file) def get_defaults(self): - json_data = self.load(self.defaults_file) + json_data = load(self.defaults_file) return json_data.get(self.module, {}) def config(self, configs=None): @@ -222,77 +251,49 @@ def config(self, configs=None): return configs def get_parser(self, env=None): - with WorkingDirectory(self.filepath): - if not os.path.isfile(self.definitions_file): - logger.error( - 'Argument definition file "{}" not found!'.format( - self.definitions_file - ) + if not os.path.isfile(self.definitions_file): + logger.error( + 'Argument definition file "{}" not found!'.format( + self.definitions_file ) - exit(1) - if env is None: - env = {} - - json_data = load(self.definitions_file) + ) + exit(1) + if env is None: + env = {} - if 'modules' not in json_data: - return ArgumentParser( - epilog='{} does not contain any modules'.format( - self.definitions_file - ) - ) - json_data = json_data['modules'] - if self.module not in json_data: - return ArgumentParser( - epilog='No entry for {} in {}'.format( - self.module, - self.definitions_file - ) + json_data = validate(self.definitions_file)['modules'] + if self.module not in json_data: + raise KeyError( + 'No entry for {} in {}'.format( + self.module, + self.definitions_file ) - json_data = json_data[self.module] + ) + json_data = json_data[self.module] - if os.path.isfile(self.defaults_file): - defaults = load(self.defaults_file) - if self.module in defaults: - defaults = defaults[self.module] - else: - defaults = {} + if os.path.isfile(self.defaults_file): + defaults = load(self.defaults_file) + if self.module in defaults: + defaults = defaults[self.module] else: defaults = {} - env = dict(env) - for k, v in GLOBAL_ENV.items(): - env[k] = v - for k, v in self.env.items(): - env[k] = v - - return __build_parser__( - self.module, - json_data, - defaults=defaults, - env=env - ) - - -def load(json_file, mode='a'): - if mode in 'ar': - if os.path.isfile(json_file): - with open(json_file, 'r') as f: - return json.load(f) - elif mode == 'r': - raise FileNotFoundError('file could not be read: ' + json_file) - if mode in 'wca': - return {} - raise ValueError('Unknown file mode "{}"'.format(mode)) - + else: + defaults = {} + env = dict(env) + for k, v in GLOBAL_ENV.items(): + env[k] = v + for k, v in self.env.items(): + env[k] = v -def save(json_data, json_file): - with open(json_file, 'w') as f: - f.write(json.dumps(json_data, indent=2)) + return __build_parser__( + self.module, + json_data, + defaults=defaults, + env=env + ) -def __split_any__(text, delimiters=None): - if delimiters is None: - delimiters = [] +def __split_any__(text, delimiters): parts = [text] for delim in delimiters: parts = [p for ps in parts for p in ps.split(delim)] @@ -319,9 +320,7 @@ def __parse_value__(value): return v -def __add_argument_to_parser__(parser, param, env=None): - if env is None: - env = {} +def __add_argument_to_parser__(parser, param, env): param = dict(param) if 'help' in param: if param['help'] is None: @@ -339,8 +338,6 @@ def __add_argument_to_parser__(parser, param, env=None): param['type'] = primitives[func] except KeyError: param['type'] = globals()[func] - if 'long' not in param: - raise ValueError('args must contain "long" key') long_form = param['long'] del param['long'] if 'short' in param: @@ -357,12 +354,8 @@ def __add_example_to_parser__(parserArgs, example): parserArgs['epilog'] += '\n {:<44}{}'.format(*(example.values())) -def __build_parser__(name, definition, defaults=None, env=None, +def __build_parser__(name, definition, defaults, env, subparsers=None, templates=None, parents=None): - if defaults is None: - defaults = {} - if env is None: - env = {} if templates is None: templates = {} if parents is None: @@ -373,7 +366,7 @@ def __build_parser__(name, definition, defaults=None, env=None, if 'template' in definition: template_name = definition['template'] if template_name not in templates: - raise ValueError('unknown template ' + template_name) + raise KeyError('unknown template ' + template_name) template = templates[template_name] if 'examples' in template: for example in template['examples']: @@ -396,7 +389,8 @@ def __build_parser__(name, definition, defaults=None, env=None, parser.set_defaults(func=env[name]) else: def usage(*args, **kwargs): - parser.parse_args(parents + [name, '-h']) + parser.print_help() + return 0 parser.set_defaults(func=usage) if 'args' in definition: for param in definition['args']: @@ -421,7 +415,7 @@ def usage(*args, **kwargs): if 'parent' in v: parent_name = v['parent'] if parent_name not in templates: - raise ValueError('unknown parent template ' + parent_name) + raise KeyError('unknown parent template ' + parent_name) new_v = deepcopy(templates[parent_name]) for k2, v2 in v.items(): if k2 not in new_v: diff --git a/argutil/commandline.schema b/argutil/commandline.schema new file mode 100644 index 0000000..db5c5f9 --- /dev/null +++ b/argutil/commandline.schema @@ -0,0 +1,103 @@ +{ + "definitions": { + "example": { + "type": "object", + "properties": { + "usage": {"type": "string"}, + "description": {"type": "string"} + } + }, + "arg": { + "type": "object", + "description": "Properties map to parameters in argparse.ArgumentParser.add_argument, with the following exceptions: long -> name, (short, long) -> flags", + "properties": { + "long": { "type": "string" }, + "short": { "type": "string" }, + "action": { "type": "string" }, + "nargs": { "type": "string" }, + "const": { "type": "string" }, + "default": {}, + "type": { "type": "string" }, + "choices": { + "type": "array", + "items": { "type": "string" } + }, + "required": { "type": "boolean" }, + "help": { + "oneOf": [ + { + "type": "array", + "items": { "type": "string" } + }, + { "type": "string" }, + { "type": "null"} + ] + }, + "metavar": { "type": "string" }, + "dest": { "type": "string" } + }, + "required": ["long"] + }, + "module": { + "type": "object", + "properties": { + "examples": { + "type": "array", + "items": { "$ref": "#/definitions/example" } + }, + "args": { + "type": "array", + "items": { "$ref": "#/definitions/arg" } + }, + "templates": { + "type": "object", + "patternProperties": { + "^.*$": { + "allOf": [ + { "$ref": "#/definitions/module" }, + { + "properties":{ + "parent": { "type": "string" } + } + } + ] + } + } + }, + "modules": { + "type": "object", + "patternProperties": { + "^.*$": { + "allOf": [ + { "$ref": "#/definitions/module" }, + { + "properties": { + "template": { "type": "string" }, + "aliases": { + "type": "array", + "items": { "type": "string" } + } + } + } + ] + } + } + } + } + } + }, + "type": "object", + "properties": { + "modules": { + "type": "object", + "patternProperties": { + "^.*$": { + "allOf": [ + {"$ref": "#/definitions/module" } + ] + } + } + } + }, + "required": ["modules"] +} diff --git a/examples/commandline.json b/examples/commandline.json index c97c466..27b5e39 100644 --- a/examples/commandline.json +++ b/examples/commandline.json @@ -93,6 +93,56 @@ ] } } + }, + "templates": { + "templates": { + "PARENT_TEMPLATE" : { + "examples": [ + { + "usage": "--foo", + "description": "example from parent template" + } + ], + "args": [ + { + "long": "--foo" + } + ] + }, + "CHILD_TEMPLATE" : { + "parent": "PARENT_TEMPLATE", + "examples": [ + { + "usage": "--bar", + "description": "example from child template" + } + ], + "args": [ + { + "long": "--bar" + } + ] + } + }, + "modules": { + "command": { + "template": "CHILD_TEMPLATE", + "examples": [ + { + "usage": "--baz", + "description": "example from command" + } + ], + "args": [ + { + "long": "--baz" + } + ] + }, + "other": { + "template": "PARENT_TEMPLATE" + } + } } } } diff --git a/examples/templates.py b/examples/templates.py new file mode 100644 index 0000000..9e2cd3e --- /dev/null +++ b/examples/templates.py @@ -0,0 +1,36 @@ +""" +$ python templates.py -h +usage: templates [-h] {command} ... + +positional arguments: + {command} + +optional arguments: + -h, --help show this help message and exit + +$ python templates.py command -h +usage: command [-h] [--baz BAZ] [--foo FOO] [--bar BAR] + +optional arguments: + -h, --help show this help message and exit + --baz BAZ + --foo FOO + --bar BAR + +examples: + --foo example from parent template + --bar example from child template + --baz example from command +""" +from __future__ import print_function +import argutil + + +@argutil.callable() +def command(opts): + print(opts.__dict__) + + +parser = argutil.get_parser() +opts = parser.parse_args() +opts.func(opts) diff --git a/requirements.txt b/requirements.txt index d82c43e..8187e78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,5 @@ pep8>=1.7,<1.7.99 pyflakes==1.6.0 ddt==1.1.1 coveralls>=1.2.0 +jsonschema>=2.6.0 -e . diff --git a/tests/defaults_file_test.py b/tests/defaults_file_test.py index f82cb6e..a2a1908 100644 --- a/tests/defaults_file_test.py +++ b/tests/defaults_file_test.py @@ -28,7 +28,7 @@ def test_set_defaults_nested(self): } } } - actual = parser_def.load(parser_def.defaults_file) + actual = argutil.load(parser_def.defaults_file) self.assertDictEqual(actual, expected) @tempdir() diff --git a/tests/definitions_file_test.py b/tests/definitions_file_test.py index c3b553a..697505d 100644 --- a/tests/definitions_file_test.py +++ b/tests/definitions_file_test.py @@ -3,6 +3,8 @@ import json import os import argutil +from argutil import ParserDefinition +from jsonschema import ValidationError try: @@ -93,12 +95,12 @@ def test_create_error_existing_module(self): filename = 'test_script.py' argutil.ParserDefinition.create(filename) with self.assertRaises(KeyError): - argutil.ParserDefinition.create(filename) + ParserDefinition.create(filename) @tempdir() def test_init_nonexistent_module(self): with self.assertRaises(SystemExit): - argutil.ParserDefinition('test_script.py').get_parser() + ParserDefinition('test_script.py').get_parser() @tempdir() def test_create_appends_to_existing_definitions_file(self): @@ -111,7 +113,7 @@ def test_create_appends_to_existing_definitions_file(self): } } self.create_json_file(DEFINITIONS_FILE, base_data) - argutil.ParserDefinition.create('test_script.py', DEFINITIONS_FILE) + ParserDefinition.create('test_script.py', DEFINITIONS_FILE) expected = { 'modules': { 'existing_module': { @@ -128,7 +130,7 @@ def test_create_appends_to_existing_definitions_file(self): @tempdir() def test_init_definitions_file_contains_correct_structure(self): - argutil.ParserDefinition.create('test_script.py', DEFINITIONS_FILE) + ParserDefinition.create('test_script.py', DEFINITIONS_FILE) expected = { 'modules': { 'test_script': { @@ -142,8 +144,28 @@ def test_init_definitions_file_contains_correct_structure(self): @tempdir() def test_init_error_on_bad_definitions_file(self): self.create_json_file(DEFINITIONS_FILE, {}) - with self.assertRaises(KeyError): - argutil.ParserDefinition.create('test_script.py', DEFINITIONS_FILE) + with self.assertRaises(ValidationError): + ParserDefinition.create('test_script.py', DEFINITIONS_FILE) + + @tempdir() + def test_delete_removes_definition(self): + parser_def = ParserDefinition.create('test_script.py') + json_data = argutil.load(parser_def.definitions_file) + self.assertIn(parser_def.module, json_data['modules']) + parser_def.delete() + json_data = argutil.load(parser_def.definitions_file) + self.assertNotIn(parser_def.module, json_data['modules']) + + @tempdir() + def test_delete_removes_defaults(self): + parser_def = ParserDefinition.create('test_script.py') + parser_def.add_argument('--foo') + parser_def.set_defaults(foo='bar') + json_data = argutil.load(parser_def.defaults_file) + self.assertIn(parser_def.module, json_data) + parser_def.delete() + json_data = argutil.load(parser_def.defaults_file) + self.assertNotIn(parser_def.module, json_data) if __name__ == '__main__': diff --git a/tests/get_parser_test.py b/tests/get_parser_test.py index 1428953..f83d829 100644 --- a/tests/get_parser_test.py +++ b/tests/get_parser_test.py @@ -2,6 +2,7 @@ from .helper import WD, tempdir import argutil from argutil import ParserDefinition +from jsonschema import ValidationError DEFINITIONS_FILE = argutil.defaults.DEFINITIONS_FILE DEFAULTS_FILE = argutil.defaults.DEFAULTS_FILE @@ -30,10 +31,10 @@ def setUpClass(cls): 'l_str': lambda s: str(s).lower(), } with argutil.WorkingDirectory(WD): - cls.parser_def = ParserDefinition.create(cls.filepath) + parser_def = ParserDefinition.create(cls.filepath) def add_arg(name, **kwargs): - return cls.parser_def.add_argument(name, **kwargs) + return parser_def.add_argument(name, **kwargs) add_arg('positional') add_arg('positional_list', nargs='*', type=int) add_arg('--flag', action='store_true', help='boolean flag option') @@ -45,10 +46,16 @@ def add_arg(name, **kwargs): default=[] ) add_arg('--env-type', type='l_str') - cls.parser_def.add_example( + parser_def.env['hex'] = lambda s: hex(int(s)) + add_arg('--def-env-type', type='hex') + argutil.GLOBAL_ENV['u_str'] = lambda s: s.upper() + add_arg('--global-env-type', type='u_str') + add_arg('--default-from-file') + parser_def.add_example( 'test --flag', 'positional w/ optional flag' ) - cls.parser = cls.parser_def.get_parser(cls.env) + parser_def.set_defaults(default_from_file=123) + cls.parser = parser_def.get_parser(cls.env) def get_opts(self, *args): with argutil.WorkingDirectory(WD): @@ -107,6 +114,58 @@ def test_env_type_option(self): 'abc' ) + def test_parser_definition_type_option(self): + self.assertIs( + self.get_opts('test').def_env_type, + None + ) + self.assertEqual( + self.get_opts( + 'test', '--def-env-type', '255' + ).def_env_type, + '0xff' + ) + + def test_global_env_type_option(self): + self.assertIs( + self.get_opts('test').global_env_type, + None + ) + self.assertEqual( + self.get_opts( + 'test', '--global-env-type', 'abc' + ).global_env_type, + 'ABC' + ) + + def test_default_from_file(self): + self.assertEqual(self.get_opts('test').default_from_file, 123) + + @tempdir(WD) + def test_module_not_in_defaults_file(self): + this_def = argutil.ParserDefinition.create('this_script') + this_def.add_argument('--foo') + this_def.set_defaults(foo='bar') + that_def = argutil.ParserDefinition.create('that_script') + that_def.add_argument('--foo') + that_parser = that_def.get_parser() + that_opts = that_parser.parse_args([]) + self.assertIs(that_opts.foo, None) + + @tempdir(WD) + def test_error_bad_definitions_file(self): + with open(DEFINITIONS_FILE, 'w') as f: + f.write('{"bad_def":{}}') + with self.assertRaises(ValidationError): + argutil.get_parser('bad_def.py') + + @tempdir(WD) + def test_error_non_existent_module(self): + with open(DEFINITIONS_FILE, 'w') as f: + f.write('{"modules":{}}') + with self.assertRaises(KeyError): + argutil.get_parser('bad_def.py') + @tempdir(WD) def test_error_unknown_type(self): filename = 'bad_module.py' @@ -132,7 +191,7 @@ def test_error_missing_long_key(self): } } argutil.save(json_data, DEFINITIONS_FILE) - with self.assertRaises(ValueError): + with self.assertRaises(ValidationError): parser_def.get_parser() diff --git a/tests/helper.py b/tests/helper.py index f616258..1562a38 100644 --- a/tests/helper.py +++ b/tests/helper.py @@ -2,6 +2,7 @@ import shutil from contextlib import contextmanager import os +from sys import stdout import argutil WD = os.path.abspath(os.path.join('.', 'tmp')) @@ -23,3 +24,16 @@ def wrapper(*args, **kwargs): function(*args, **kwargs) return wrapper return dec + + +@contextmanager +def record_stdout(buf): + old_write = stdout.write + + def new_write(s): + buf.append(s) + return len(s) + + setattr(stdout, 'write', new_write) + yield + setattr(stdout, 'write', old_write) diff --git a/tests/module_creation_test.py b/tests/module_creation_test.py index c77f964..b110b0a 100644 --- a/tests/module_creation_test.py +++ b/tests/module_creation_test.py @@ -2,6 +2,7 @@ from .helper import WD, TempWorkingDirectory from contextlib import contextmanager import argutil +from jsonschema import ValidationError DEFINITIONS_FILE = argutil.defaults.DEFINITIONS_FILE @@ -32,12 +33,12 @@ def test_add_example(self): parser_def.add_example('usage text', 'description text') def test_add_example_error_usage_not_str(self): - with self.assertRaises(ValueError): + with self.assertRaises(ValidationError): with self.assertModifiedModule() as parser_def: parser_def.add_example(123) def test_add_example_error_description_not_str(self): - with self.assertRaises(ValueError): + with self.assertRaises(ValidationError): with self.assertModifiedModule() as parser_def: parser_def.add_example('usage text', ['description']) diff --git a/tests/submodules_test.py b/tests/submodules_test.py new file mode 100644 index 0000000..fc0f27a --- /dev/null +++ b/tests/submodules_test.py @@ -0,0 +1,193 @@ +import unittest +from .helper import tempdir, record_stdout +from argutil import callable, get_parser, ParserDefinition +from argutil.defaults import DEFINITIONS_FILE +import json + + +class SubmoduleTest(unittest.TestCase): + @tempdir() + def test_simple_submodule(self): + json_data = { + 'modules': { + 'root': {'modules': {'command': {'args': [{'long': '--foo'}]}}} + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args(['command', '--foo', 'bar']) + self.assertEqual(opts.foo, 'bar') + + @tempdir() + def test_submodule_aliases(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'aliases': ['c'], + 'args': [{'long': '--foo'}] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args(['c', '--foo', 'bar']) + self.assertEqual(opts.foo, 'bar') + + @tempdir() + def test_submodule_default_handler_shows_usage(self): + json_data = { + 'modules': { + 'root': {'modules': {'command': {'args': [{'long': '--foo'}]}}} + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args(['command']) + buf = [] + with record_stdout(buf): + opts.func(opts) + actual = ''.join(buf) + expected = '\n'.join([ + 'usage: command [-h] [--foo FOO]', + '', + 'optional arguments:', + ' -h, --help show this help message and exit', + ' --foo FOO\n', + ]) + self.assertEqual(actual, expected) + + @tempdir() + def test_submodule_handler_env(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'args': [{'long': '--foo'}] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + + def get_opts(opts): + return 'get_opts called: foo={}'.format(opts.foo) + argparser = get_parser('root.py', env={'command': get_opts}) + opts = argparser.parse_args(['command', '--foo', 'bar']) + expected = 'get_opts called: foo=bar' + self.assertEqual(opts.func(opts), expected) + + @tempdir() + def test_submodule_handler_global_callable_decorator(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'args': [{'long': '--foo'}] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + + @callable('command') + def get_opts(opts): + return 'get_opts called: foo={}'.format(opts.foo) + argparser = get_parser('root.py') + opts = argparser.parse_args(['command', '--foo', 'bar']) + expected = 'get_opts called: foo=bar' + self.assertEqual(opts.func(opts), expected) + + @tempdir() + def test_submodule_handler_definition_callable_decorator(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'args': [{'long': '--foo'}] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + parser_def = ParserDefinition('root.py') + + @parser_def.callable('command') + def get_opts(opts): + return 'get_opts called: foo={}'.format(opts.foo) + argparser = parser_def.get_parser() + opts = argparser.parse_args(['command', '--foo', 'bar']) + expected = 'get_opts called: foo=bar' + self.assertEqual(opts.func(opts), expected) + + @tempdir() + def test_nested_submodules(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'args': [{'long': '--foo'}], + 'modules': { + 'subcommand': { + 'args': [{'long': '--bar'}] + } + } + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args([ + 'command', '--foo', 'bar', + 'subcommand', '--bar', 'baz' + ]) + self.assertEqual(opts.foo, 'bar') + self.assertEqual(opts.bar, 'baz') + + @tempdir() + def test_submodule_option_defaults(self): + json_data = { + 'modules': { + 'root': { + 'modules': { + 'command': { + 'args': [{'long': '--foo'}] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + parser_def = ParserDefinition('root.py') + parser = parser_def.get_parser() + opts = parser.parse_args(['command']) + self.assertIsNone(opts.foo) + + parser_def.set_defaults(**{'command.foo': 'bar'}) + parser = parser_def.get_parser() + opts = parser.parse_args(['command']) + self.assertEqual(opts.foo, 'bar') + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/template_test.py b/tests/template_test.py new file mode 100644 index 0000000..805091f --- /dev/null +++ b/tests/template_test.py @@ -0,0 +1,150 @@ +import unittest +from .helper import tempdir +from argutil import get_parser +from argutil.defaults import DEFINITIONS_FILE +import json + + +class TemplateTest(unittest.TestCase): + @tempdir() + def test_simple_template(self): + json_data = { + 'modules': { + 'root': { + 'args': [], + 'templates': {'BASIC': {'args': [{'long': '--foo'}]}}, + 'modules': {'command': {'template': 'BASIC'}} + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args(['command', '--foo', 'bar']) + self.assertEqual(opts.foo, 'bar') + + @tempdir() + def test_template_inheritance(self): + json_data = { + 'modules': { + 'root': { + 'args': [], + 'templates': { + 'PARENT': { + 'args': [{'long': '--foo'}] + }, + 'CHILD': { + 'parent': 'PARENT', + 'args': [{'long': '--bar'}] + } + }, + 'modules': { + 'command': { + 'args': [{'long': '--baz'}], + 'template': 'CHILD' + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + opts = argparser.parse_args([ + 'command', + '--foo', '1', + '--bar', '2', + '--baz', '3'] + ) + self.assertEqual(opts.foo, '1') + self.assertEqual(opts.bar, '2') + self.assertEqual(opts.baz, '3') + + @tempdir() + def test_template_examples(self): + json_data = { + 'modules': { + 'root': { + 'args': [], + 'templates': { + 'BASIC': { + 'examples': [ + { + 'usage': '--foo', + 'description': 'usage from template' + } + ] + }, + 'COMPLEX': { + 'parent': 'BASIC', + 'examples': [ + { + 'usage': '--bar', + 'description': 'usage from nested template' + } + ] + } + }, + 'modules': { + 'command': { + 'template': 'COMPLEX', + 'examples': [ + { + 'usage': '--baz', + 'description': 'usage from submodule' + } + ] + } + } + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + argparser = get_parser('root.py') + expected = ['examples:'] + for u, d in [ + ('--foo', 'usage from template'), + ('--bar', 'usage from nested template'), + ('--baz', 'usage from submodule'), + ]: + expected.append(' {:<44}{}'.format(u, d)) + self.assertEqual( + argparser._subparsers._group_actions[0].choices['command'].epilog, + '\n'.join(expected) + ) + + @tempdir() + def test_error_unknown_template(self): + json_data = { + 'modules': { + 'root': { + 'args': [], + 'modules': {'command': {'template': 'BASIC'}} + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + with self.assertRaises(KeyError): + get_parser('root.py') + + @tempdir() + def test_error_unknown_parent_template(self): + json_data = { + 'modules': { + 'root': { + 'args': [], + 'templates': {'MISSING_PARENT': {'parent': 'UNDEFINED'}}, + 'modules': {'command': {'template': 'MISSING_PARENT'}} + } + } + } + with open(DEFINITIONS_FILE, 'w') as f: + f.write(json.dumps(json_data)) + with self.assertRaises(KeyError): + get_parser('root.py') + + +if __name__ == '__main__': + unittest.main() From daff5e850df55f87fae6847040433ef03db740b9 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Tue, 6 Mar 2018 12:29:39 -0600 Subject: [PATCH 3/5] include schema in package --- MANIFEST.in | 1 + setup.py | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/MANIFEST.in b/MANIFEST.in index bb3ec5f..0d1a70b 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1 +1,2 @@ include README.md +include argutil/commandline.schema diff --git a/setup.py b/setup.py index 6068493..af5b361 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,5 @@ from setuptools import setup -setup() +setup( + include_package_data=True +) From 54538c93c37a67480a112f74d721c79128ac88c1 Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Tue, 6 Mar 2018 12:34:21 -0600 Subject: [PATCH 4/5] add jsonschema to package dependencies --- setup.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/setup.py b/setup.py index af5b361..c3c6a21 100644 --- a/setup.py +++ b/setup.py @@ -1,5 +1,8 @@ from setuptools import setup setup( + install_requires=[ + 'jsonschema' + ], include_package_data=True ) From 338fbb1091950dd7b75f075a87232f929c13b37f Mon Sep 17 00:00:00 2001 From: Corey McCandless Date: Tue, 6 Mar 2018 12:49:32 -0600 Subject: [PATCH 5/5] move version to setup.cfg --- VERSION | 1 + argutil/__init__.py | 1 - argutil/argutil.py | 36 +++++++++++++++++------------------- bin/get_version | 2 +- setup.cfg | 4 +++- setup.py | 7 +------ 6 files changed, 23 insertions(+), 28 deletions(-) create mode 100644 VERSION diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..e25d8d9 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +1.1.5 diff --git a/argutil/__init__.py b/argutil/__init__.py index a3e555d..08c2044 100644 --- a/argutil/__init__.py +++ b/argutil/__init__.py @@ -6,6 +6,5 @@ callable, ParserDefinition, GLOBAL_ENV, - VERSION, ) from .working_directory import WorkingDirectory, pushd diff --git a/argutil/argutil.py b/argutil/argutil.py index f95570b..e931aa3 100644 --- a/argutil/argutil.py +++ b/argutil/argutil.py @@ -20,8 +20,6 @@ import logging import jsonschema -VERSION = '1.1.5' - logger = logging.getLogger('argutil') logger.setLevel(logging.ERROR) @@ -228,17 +226,17 @@ def get_defaults(self): def config(self, configs=None): if configs: - defaults = {} + module_defaults = {} for k, v in [__split_any__(kv, '=:') for kv in configs]: if v[0] == '[' and v[-1] == ']': v = [__parse_value__(s) for s in v[1:-1].split(',')] else: v = __parse_value__(v) - defaults[k] = v - self.set_defaults(**defaults) + module_defaults[k] = v + self.set_defaults(**module_defaults) else: - defaults = self.get_defaults() - items = list(defaults.items()) + module_defaults = self.get_defaults() + items = list(module_defaults.items()) configs = [] while items: k, v = items.pop(0) @@ -272,13 +270,13 @@ def get_parser(self, env=None): json_data = json_data[self.module] if os.path.isfile(self.defaults_file): - defaults = load(self.defaults_file) - if self.module in defaults: - defaults = defaults[self.module] + module_defaults = load(self.defaults_file) + if self.module in module_defaults: + module_defaults = module_defaults[self.module] else: - defaults = {} + module_defaults = {} else: - defaults = {} + module_defaults = {} env = dict(env) for k, v in GLOBAL_ENV.items(): env[k] = v @@ -288,7 +286,7 @@ def get_parser(self, env=None): return __build_parser__( self.module, json_data, - defaults=defaults, + module_defaults=module_defaults, env=env ) @@ -354,7 +352,7 @@ def __add_example_to_parser__(parserArgs, example): parserArgs['epilog'] += '\n {:<44}{}'.format(*(example.values())) -def __build_parser__(name, definition, defaults, env, +def __build_parser__(name, definition, module_defaults, env, subparsers=None, templates=None, parents=None): if templates is None: templates = {} @@ -402,12 +400,12 @@ def usage(*args, **kwargs): __add_argument_to_parser__(parser, param, env) # Apply default values - for k, v in defaults.items(): + for k, v in module_defaults.items(): try: - defaults[k] = v.format(**env) + module_defaults[k] = v.format(**env) except AttributeError: continue - parser.set_defaults(**defaults) + parser.set_defaults(**module_defaults) if 'templates' in definition: templates = dict(templates) @@ -437,8 +435,8 @@ def usage(*args, **kwargs): if 'modules' in definition: subparsers = parser.add_subparsers(dest='command') for submodule_name, submodule in definition['modules'].items(): - if submodule_name in defaults: - sub_defaults = defaults[submodule_name] + if submodule_name in module_defaults: + sub_defaults = module_defaults[submodule_name] else: sub_defaults = {} __build_parser__( diff --git a/bin/get_version b/bin/get_version index c3d2030..e3414a5 100644 --- a/bin/get_version +++ b/bin/get_version @@ -1,2 +1,2 @@ #!/bin/sh -grep -oP "(?<=VERSION = ['\"])\d+\.\d+\.\d+" argutil/argutil.py +grep -oP "(?<=version = )\d+\.\d+\.\d+" setup.cfg diff --git a/setup.cfg b/setup.cfg index 00ba39d..219cfe8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,6 @@ [metadata] name = argutil -version = attr: argutil.VERSION +version = 1.1.5 description = Wrapper for argparse that uses JSON config files to define command line parsers long_description = file: README.rst, CHANGELOG.rst, LICENSE.rst author = cmccandless @@ -10,3 +10,5 @@ url = http://github.com/cmccandless/argutil [options] packages = argutil, tests +include_package_data = True +install_requires = jsonschema diff --git a/setup.py b/setup.py index c3c6a21..6068493 100644 --- a/setup.py +++ b/setup.py @@ -1,8 +1,3 @@ from setuptools import setup -setup( - install_requires=[ - 'jsonschema' - ], - include_package_data=True -) +setup()