diff --git a/soundchanger/change.py b/soundchanger/change.py index 9877731..6e5ed75 100644 --- a/soundchanger/change.py +++ b/soundchanger/change.py @@ -1,13 +1,60 @@ import re +import argparse + + +__version__ = '0.1.0' + + +def main(): + """ + CLI entry point of the program. + """ + + parser = argparse.ArgumentParser(prog="soundchanger") + parser.add_argument( + "changes", + help="Sound change to be applied. Multiple sound changes should be separated by a space." + ) + parser.add_argument( + "strings", + help="Word that the sound change should be applied to. Multiple words should be separated by a space." + ) + parser.add_argument( + "-C", "--categories", + default={}, + help="Categories to be used in the sound change." + ) + parser.add_argument( + "-i", "--ignore-errors", + action="store_false", + help="Categories to be used in the sound change." + ) + parser.add_argument( + "-z", "--zero-characters", + default='∅-', + help="Characters that should be empty strings in the changed words." + ) + parser.add_argument( + "-v", "--version", + action="version", + version=f"%(prog)s {__version__}" + ) + args = parser.parse_args() + print(apply( + args.changes.split(), + args.strings.split(), + args.categories, + args.ignore_errors, + args.zero_characters + )) def apply( - changes, - strings, - apply=True, - categories={}, - ignore_errors=True, - zero_characters=['∅']): + changes, + strings, + categories={}, + ignore_errors=True, + zero_characters='∅-'): """ Applies a sound change or a list of sound changes to a string or a list of given strings. @@ -16,22 +63,19 @@ def apply( If the input value is of type str, the output will also be of type str. Options: - - apply (default: True) - Whether or not the changes should be applied. - categories (default: {}) Which categories will be detected. For vowels it would be {'V'='aeiou'} or {'V'=['a', 'e', 'i', 'o', 'u']}) + - ignore_errors (default: True) If this option is set to `True`, any erroneous sound change will be skipped. - If set to `False`, a ValueError will be raised. - - zero_characters (default: ['∅']) + If set to `False`, a ValueError will be raised instead. + + - zero_characters (default: '∅-') These characters will be removed in the changed words. For example, `apply('h>∅', 'aha')` will return 'aa', not 'a∅a'. """ - if not apply: - return strings - if isinstance(changes, str): changes = [changes] @@ -42,10 +86,10 @@ def apply( strings = strings.copy() for change in changes: - if validate_change(change, ignore_errors=ignore_errors) == False: + if is_valid_change(change, ignore_errors=ignore_errors) == False: continue - change = convert_change_to_regex(change, categories=categories, zero_characters=zero_characters) + change = reformat_change_to_regex(change, categories=categories, zero_characters=zero_characters) original, change_to, before, after = split_change(change) pattern = f"({before})({original})({after})" @@ -60,8 +104,15 @@ def apply( return strings -def validate_change(change, ignore_errors): - valid = re.search(r'^[^>_/]+?>[^>_/]*?(:?/[^>_/]*?_+[^>_/]*)?$', change) +def is_valid_change(change, ignore_errors=True): + """ + Returns `True` if the change is valid. + If the change is not valid, it returns `False` + unless `ignore_errors` is set to `False`, + in which case it raises a ValueError. + """ + + valid = re.search(r'^[^>_/]+?>[^>_/]*?/?(:?[^>_/]*?_+[^>_/]*)?$', change) if ignore_errors: return valid != None if not valid: @@ -69,7 +120,14 @@ def validate_change(change, ignore_errors): return True -def convert_change_to_regex(change, categories, zero_characters): +def reformat_change_to_regex(change, categories={}, zero_characters='∅-'): + """ + Reformats a sound change for use in regular expressions. + Replaces mentions of categories with their actual values + and removes all zero characters. + Returns the reformatted change. + """ + # Prepare change for regex for k, v in {' ': '', '{': '(', '}': ')', ',': '|'}.items(): change = change.replace(k, v) @@ -89,15 +147,33 @@ def convert_change_to_regex(change, categories, zero_characters): def split_change(change): + """ + Splits a valid change into four components. + + Syntax: + original > change_to / before __ after + ╰────── Change ──────╯ ╰─ Environment ─╯ + + Returns a tuple of the four components: + (original, change_to, before, after) + """ + if '/' in change: change, environment = change.split('/') else: environment = '_' - # Collaplse multiple underscores + # Collapse multiple underscores environment = re.sub('_+', '_', environment) original, change_to = change.split('>') - before, after = environment.split('_') + if '_' in environment: + before, after = environment.split('_') + else: + before = after = '' return (original, change_to, before, after) + + +if __name__ == '__main__': + main() diff --git a/tests/test_change.py b/tests/test_apply.py similarity index 100% rename from tests/test_change.py rename to tests/test_apply.py diff --git a/tests/test_change_errors.py b/tests/test_apply_errors.py similarity index 100% rename from tests/test_change_errors.py rename to tests/test_apply_errors.py diff --git a/tests/test_is_valid_change.py b/tests/test_is_valid_change.py new file mode 100644 index 0000000..aff6b1a --- /dev/null +++ b/tests/test_is_valid_change.py @@ -0,0 +1,32 @@ +from soundchanger.change import is_valid_change +import pytest + +def test_valid_change(): + assert is_valid_change('a>b/c_d') == True + assert is_valid_change('a>b/c__d') == True + assert is_valid_change('a>b/c_') == True + assert is_valid_change('a>b/_d') == True + assert is_valid_change('a>b') == True + assert is_valid_change('a>b/_') == True + assert is_valid_change('a>b/') == True + +def test_invalid_change(): + assert is_valid_change('>') == False + assert is_valid_change('>b') == False + assert is_valid_change('a>b/a') == False + assert is_valid_change('>/_') == False + assert is_valid_change('a>b//c_d') == False + +def test_raises_value_error(): + with pytest.raises(ValueError): + is_valid_change('>', ignore_errors=False) + with pytest.raises(ValueError): + is_valid_change('>', ignore_errors=False) + with pytest.raises(ValueError): + is_valid_change('>b', ignore_errors=False) + with pytest.raises(ValueError): + is_valid_change('a>b/a', ignore_errors=False) + with pytest.raises(ValueError): + is_valid_change('>/_', ignore_errors=False) + with pytest.raises(ValueError): + is_valid_change('a>b//c_d', ignore_errors=False) diff --git a/tests/test_reformat_change_to_regex.py b/tests/test_reformat_change_to_regex.py new file mode 100644 index 0000000..9e8590d --- /dev/null +++ b/tests/test_reformat_change_to_regex.py @@ -0,0 +1,21 @@ +from soundchanger.change import reformat_change_to_regex + + +def test_replace_spaces(): + assert reformat_change_to_regex(' a > b / c _ d ') == 'a>b/c_d' + +def test_replace_brackets_and_commas(): + assert reformat_change_to_regex('a>b/{#,a}_') == 'a>b/(#|a)_' + +def test_replace_categories(): + assert reformat_change_to_regex('a>b/V_', + categories={'V': 'aiu'} + ) == 'a>b/(a|i|u)_' + +def test_replace_categories_in_brackets(): + assert reformat_change_to_regex('a>b/{#,V}_', + categories={'V': 'aiu'} + ) == 'a>b/(#|(a|i|u))_' + +def test_replace_zero_characters(): + assert reformat_change_to_regex('a>∅') == 'a>' diff --git a/tests/test_split_change.py b/tests/test_split_change.py new file mode 100644 index 0000000..3e735d3 --- /dev/null +++ b/tests/test_split_change.py @@ -0,0 +1,34 @@ +from soundchanger.change import split_change +import pytest + + +def test_simple_change(): + assert split_change('a>b') == ('a', 'b', '', '') + +def test_change_with_slash(): + assert split_change('a>b/') == ('a', 'b', '', '') + +def test_change_with_environment(): + assert split_change('a>b/c_d') == ('a', 'b', 'c', 'd') + +def test_change_with_environment_double_underscore(): + assert split_change('a>b/c__d') == ('a', 'b', 'c', 'd') + +def test_change_with_environment_before(): + assert split_change('a>b/#_') == ('a', 'b', '#', '') + +def test_change_with_environment_after(): + assert split_change('a>b/_#') == ('a', 'b', '', '#') + +def test_change_with_empty_environment(): + assert split_change('a>b/_') == ('a', 'b', '', '') + +def test_change_to_nothing(): + assert split_change('a>') == ('a', '', '', '') + +def test_change_to_zero(): + assert split_change('a>-') == ('a', '-', '', '') + +def test_invalid_input(): + with pytest.raises(ValueError): + assert split_change('')