Initial import with the args kata.

This commit is contained in:
David Soulayrol 2023-09-28 22:29:09 +02:00
commit f6beb1c197
6 changed files with 271 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
.DS_Store
.idea
*.log
tmp/
*.py[cod]
*.egg
build
htmlcov
__pycache__

19
LICENSE Normal file
View file

@ -0,0 +1,19 @@
Copyright 2023 David Soulayrol.
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.

26
args/README.md Normal file
View file

@ -0,0 +1,26 @@
# A Simple and stupid Args Parser.
The subject was found here: https://codingdojo.org/kata/Args/
This implementation requires no dependencies (but probably comes with some bugs).
## Defining the pattern
An arguments pattern is defined by a comma-separated expressions with the following format:
FLAG:NAME:TYPE[*]
- `FLAG` must be one character only, and is the token that should be encountered on the line to parse.
- `NAME` is the argument name, and will be the name of the available matching property once the command line was parsed.
- `TYPE` can be *b* for *boolean*, *d* for an integer (think C), *s* for a string. If the format is followed by a star, then the argument should be a comma-separated list of values with the given type.
The tests are full of samples.
## Known limitations
- Pattern parsing is fragile, as it misses many tests on the input.
- Boolean arrays were not considered.
## tests
Unit tests for **pytest** and siblingss are available in the `tests` directory. Because of laziness and lack of time, they only examine the parse function as a black box.

108
args/args.py Normal file
View file

@ -0,0 +1,108 @@
# A Simple and stupid Args Parser.
# Copyright 2023 David Soulayrol.
#
# 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:
class ArgParser:
def __init__(self, name, reducer, default_value, init_value=None):
self.__name = name
self.__reducer = reducer
self.__init_value = init_value
self.__value = default_value
@property
def name(self):
return self.__name
@property
def reducer(self):
return self.__reducer
@property
def value(self):
return self.__value
def init(self):
self.__value = self.__init_value
def parse_value(self, value):
if self.__reducer is None:
raise PatternException()
self.__value = self.__reducer(value)
class Namespace:
def __init__(self, values):
print(values)
self.__dict__.update(dict(map(lambda v: (v.name, v.value), values)))
def boolean(value):
raise ValueError(value)
def create_list(reducer):
def list_reducer(value):
return [reducer(v) for v in value.split(',')]
return list_reducer
def parse_pattern(pattern):
# Format is one_letter_flag:name:format
# with format : b | d | s [*]
parsers = {}
reducers = {
'b': (boolean, False, True),
'd': (int, 0, 0),
's': (str, '', '')
}
for desc in pattern.replace(' ', '').split(','):
flag, name, format = desc.split(':')
if '-' + flag in parsers:
raise ValueError('"%s" is defined twice' % flag)
try:
fn, default_value, init_value = reducers[format[0]]
if (format.endswith('*')):
fn = create_list(fn)
default_value = []
parsers['-' + flag] = ArgParser(name, fn, default_value, init_value)
except KeyError as e:
raise ValueError('Unknown flag type "%s"' % format[0])
return parsers
def parse(pattern, args):
current_parser = None
parsers = parse_pattern(pattern)
for arg in args:
if arg in parsers:
# This is a new flag. Update context.
current_parser = parsers[arg]
current_parser.init()
else:
# This is a value.
if current_parser is None:
raise ValueError('Unexpected "%s"' % arg)
current_parser.parse_value(arg)
current_parser = None
return Namespace(parsers.values())

7
args/tests/context.py Normal file
View file

@ -0,0 +1,7 @@
# A nice trick found in https://docs.python-guide.org/writing/structure/
import os
import sys
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
import args

101
args/tests/test_args.py Normal file
View file

@ -0,0 +1,101 @@
import pytest
from context import args
def test_invalid_pattern():
with pytest.raises(ValueError):
values = args.parse('F:flag:z', [])
def test_pattern_with_double_definition():
with pytest.raises(ValueError):
values = args.parse('F:flag:b,F:flag:b', [])
def test_unepected_value():
with pytest.raises(ValueError):
values = args.parse('F:flag:b', ['F'])
def test_boolean_default():
values = args.parse('F:flag:b', [])
assert len(values.__dict__) == 1
assert values.flag is False
def test_boolean():
values = args.parse('F:flag:b', ['-F'])
assert len(values.__dict__) == 1
assert values.flag is True
def test_boolean_unexpected_value():
with pytest.raises(ValueError):
values = args.parse('F:flag:b', ['-F', 'True'])
def test_integer_default():
values = args.parse('I:integer:d', [])
assert len(values.__dict__) == 1
assert values.integer == 0
def test_integer():
values = args.parse('I:integer:d', ['-I', '512'])
assert len(values.__dict__) == 1
assert values.integer == 512
def test_integer_negative_value():
values = args.parse('I:integer:d', ['-I', '-42'])
assert len(values.__dict__) == 1
assert values.integer == -42
def test_integer_array_default():
values = args.parse('a:iarray:d*', [])
assert len(values.__dict__) == 1
assert type(values.iarray) is list and len(values.iarray) == 0
def test_integer_array():
values = args.parse('z:iarray:s*', ['-z', '-1,47,-42,0,1'])
assert len(values.__dict__) == 1
assert type(values.iarray) is list and len(values.iarray) == 5
len([i for i, j in zip(values.iarray, [-1, 47, -42, 0, 1]) if i == j]) == 5
def test_string_default():
values = args.parse('S:string:s', [])
assert len(values.__dict__) == 1
assert values.string == ''
def test_string():
values = args.parse('S:string:s', ['-S', 'some_string'])
assert len(values.__dict__) == 1
assert values.string == 'some_string'
def test_string_empty():
values = args.parse('S:string:s', ['-S', ''])
assert len(values.__dict__) == 1
assert values.string == ''
def test_string_array_default():
values = args.parse('A:array:s*', [])
assert len(values.__dict__) == 1
assert type(values.array) is list and len(values.array) == 0
def test_string_array():
values = args.parse('A:array:s*', ['-A', 'a,b,c,long'])
assert len(values.__dict__) == 1
assert type(values.array) is list and len(values.array) == 4
len([i for i, j in zip(values.array, ['a', 'b', 'c', 'long']) if i == j]) == 4
def test_full():
values = args.parse('l:logging:b,p:port:d,d:directory:s', ['-l', '-p', '8080', '-d', '/usr/logs'])
assert len(values.__dict__) == 3
assert values.directory == '/usr/logs'
assert values.logging is True
assert values.port == 8080