From a9be5c87f4357e8625fdd5c49ddaa4c647bc549b Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Sat, 29 Nov 2025 17:31:15 -0300 Subject: [PATCH 01/11] Small refactoration of a testing file --- testing/tests/api/test_commanding.py | 113 ++++++++++++++------------- 1 file changed, 57 insertions(+), 56 deletions(-) diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index 3bb5ee526..448ae4df5 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -7,93 +7,93 @@ def test_docstring(): @cmd.new_command - def func1(): + def func(): """docstring""" - assert func1.__doc__ == "docstring" + assert func.__doc__ == "docstring" -@cmd.new_command -def func2(a: bool, b: bool): - assert a - assert not b def test_bool(capsys): - cmd.do("func2 yes, 0") + @cmd.new_command + def func(a: bool, b: bool): + assert a + assert not b + cmd.do("func yes, 0") out, err = capsys.readouterr() assert out == '' and err == '' -@cmd.new_command -def func3( - nullable_point: Tuple[float, float, float], - my_var: Union[int, float] = 10, - my_foo: Union[int, float] = 10.0, - extended_calculation: bool = True, - old_style: Any = "Old behavior" -): - assert nullable_point == (1., 2., 3.) - assert extended_calculation - assert isinstance(my_var, int) - assert isinstance(my_foo, float) - assert old_style == "Old behavior" - + def test_generic(capsys): - cmd.do("func3 nullable_point=1 2 3, my_foo=11.0") + @cmd.new_command + def func( + nullable_point: Tuple[float, float, float], + my_var: Union[int, float] = 10, + my_foo: Union[int, float] = 10.0, + extended_calculation: bool = True, + old_style: Any = "Old behavior" + ): + assert nullable_point == (1., 2., 3.) + assert extended_calculation + assert isinstance(my_var, int) + assert isinstance(my_foo, float) + assert old_style == "Old behavior" + cmd.do("func nullable_point=1 2 3, my_foo=11.0") out, err = capsys.readouterr() assert out + err == '' -@cmd.new_command -def func4(dirname: Path = Path('.')): - assert dirname.exists() - def test_path(capsys): - cmd.do('func4 ..') - cmd.do('func4') + @cmd.new_command + def func(dirname: Path = Path('.')): + assert dirname.exists() + cmd.do('func ..') + cmd.do('func') out, err = capsys.readouterr() assert out + err == '' -@cmd.new_command -def func5(old_style: Any): - assert old_style is RuntimeError -func5(RuntimeError) + @mark.skip("This function does not works as expected") def test_any(capsys): - - cmd.do("func5 RuntimeError") + @cmd.new_command + def func(old_style: Any): + assert old_style is RuntimeError + func(RuntimeError) + cmd.do("func RuntimeError") out, err = capsys.readouterr() assert 'AssertionError' not in out+err -@cmd.new_command -def func6(a: List): - assert a[1] == "2" - -@cmd.new_command -def func7(a: List[int]): - assert a[1] == 2 - def test_list(capsys): - cmd.do("func6 1 2 3") + @cmd.new_command + def func(a: List): + assert a[1] == "2" + + cmd.do("func 1 2 3") out, err = capsys.readouterr() assert out + err == '' - cmd.do("func7 1 2 3") + @cmd.new_command + def func(a: List[int]): + assert a[1] == 2 + + cmd.do("func 1 2 3") out, err = capsys.readouterr() assert out + err == '' -@cmd.new_command -def func8(a: Tuple[str, int]): - assert a == ("fooo", 42) - def test_tuple(capsys): - cmd.do("func8 fooo 42") + @cmd.new_command + def func(a: Tuple[str, int]): + assert a == ("fooo", 42) + + cmd.do("func fooo 42") out, err = capsys.readouterr() assert out + err == '' -@cmd.new_command -def func10(a: str="sele"): - assert a == "sele" def test_default(capsys): - cmd.do('func10') + @cmd.new_command + def func(a: str="sele"): + assert a == "sele" + + cmd.do('func') out, err = capsys.readouterr() assert out + err == '' @@ -106,9 +106,10 @@ def test_str_enum(capsys): class E(StrEnum): A = "a" @cmd.new_command - def func11(e: E): + def func(e: E): assert e == E.A assert isinstance(e, E) - cmd.do('func11 a') + cmd.do('func a') out, err = capsys.readouterr() - assert out + err == '' \ No newline at end of file + assert out + err == '' + From cb778191d1a127180751cefca96434f118b817a6 Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Sat, 29 Nov 2025 17:34:25 -0300 Subject: [PATCH 02/11] Add support to quiet argument on new_command --- modules/pymol/commanding.py | 17 ++++++++++------- testing/tests/api/test_commanding.py | 7 +++++++ 2 files changed, 17 insertions(+), 7 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 6b4e3d074..3bc8ef247 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -689,18 +689,21 @@ def inner(*args, **kwargs): # It was called from command line or pml script, so parse arguments if caller == _parser_filename: kwargs = {**kwargs, **dict(zip(args2_, args))} + # special _self argument kwargs.pop("_self", None) new_kwargs = {} for var, type in funcs.items(): if var in kwargs: value = kwargs[var] - new_kwargs[var] = _into_types(type, value) - final_kwargs = {} - for k, v in kwargs_.items(): - final_kwargs[k] = v - for k, v in new_kwargs.items(): - if k not in final_kwargs: - final_kwargs[k] = v + # special 'quiet' argument + if var == 'quiet' and isinstance(value, int): + new_kwargs[var] = bool(value) + else: + new_kwargs[var] = _into_types(type, value) + final_kwargs = { + **kwargs_, + **new_kwargs + } return function(**final_kwargs) # It was called from Python, so pass the arguments as is diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index 448ae4df5..fc4f81076 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -113,3 +113,10 @@ def func(e: E): out, err = capsys.readouterr() assert out + err == '' +def test_quiet(capsys): + @cmd.new_command + def func(quiet: bool=True): + assert not quiet + cmd.do('func') + out, err = capsys.readouterr() + assert out + err == '' \ No newline at end of file From 16b7fc617586ec3e43a738aaf4114a50899b368b Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Sat, 29 Nov 2025 17:42:32 -0300 Subject: [PATCH 03/11] Fix error on typed lists --- modules/pymol/commanding.py | 10 ++++++---- testing/tests/api/test_commanding.py | 26 +++++++++++++++++--------- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 3bc8ef247..99b9df5f7 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -641,11 +641,13 @@ def _into_types(type, value): elif issubclass(list, origin): args = get_args(type) - if len(args) > 0: + if len(args) == 1: f = args[0] - else: - f = lambda x: x - return [f(i) for i in shlex.split(value)] + return [ + _into_types(f, a) + for a in shlex.split(value) + ] + return shlex.split(value) elif issubclass(type, Enum): if value in type: diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index fc4f81076..c8e081fc2 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -1,9 +1,8 @@ -from pytest import mark -from pymol import cmd import sys +from pytest import mark from typing import List, Union, Any, Tuple from pathlib import Path - +from pymol import cmd def test_docstring(): @cmd.new_command @@ -40,6 +39,7 @@ def func( out, err = capsys.readouterr() assert out + err == '' + def test_path(capsys): @cmd.new_command def func(dirname: Path = Path('.')): @@ -50,7 +50,6 @@ def func(dirname: Path = Path('.')): assert out + err == '' - @mark.skip("This function does not works as expected") def test_any(capsys): @cmd.new_command @@ -61,11 +60,11 @@ def func(old_style: Any): out, err = capsys.readouterr() assert 'AssertionError' not in out+err + def test_list(capsys): @cmd.new_command def func(a: List): assert a[1] == "2" - cmd.do("func 1 2 3") out, err = capsys.readouterr() assert out + err == '' @@ -73,17 +72,24 @@ def func(a: List): @cmd.new_command def func(a: List[int]): assert a[1] == 2 - cmd.do("func 1 2 3") out, err = capsys.readouterr() assert out + err == '' + @cmd.new_command + def func(a: List[bool]): + assert a.pop(0) == False + assert a.pop(0) == True + cmd.do("func 0 yes") + out, err = capsys.readouterr() + assert out + err == '' + + def test_tuple(capsys): @cmd.new_command def func(a: Tuple[str, int]): - assert a == ("fooo", 42) - - cmd.do("func fooo 42") + assert a == ("fooo a", 42) + cmd.do("func 'fooo a' 42") out, err = capsys.readouterr() assert out + err == '' @@ -97,6 +103,7 @@ def func(a: str="sele"): out, err = capsys.readouterr() assert out + err == '' + @mark.skipif( sys.version_info < (3, 11), reason="Requires StrEnum of Python 3.11+" @@ -113,6 +120,7 @@ def func(e: E): out, err = capsys.readouterr() assert out + err == '' + def test_quiet(capsys): @cmd.new_command def func(quiet: bool=True): From 6aa080cd0d50feb448637ba3e134c61d15687633 Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Sat, 29 Nov 2025 19:46:43 -0300 Subject: [PATCH 04/11] 20x speedup with ChatGPT tip --- modules/pymol/commanding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 99b9df5f7..9c1b5ba9e 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -687,7 +687,7 @@ def new_command(name, function=None, _self=cmd): # Inner function that will be callable every time the command is executed @wraps(function) def inner(*args, **kwargs): - caller = traceback.extract_stack(limit=2)[0].filename + caller = sys._getframe(1).f_code.co_filename # It was called from command line or pml script, so parse arguments if caller == _parser_filename: kwargs = {**kwargs, **dict(zip(args2_, args))} From 876f17472a204a560b437390bf4fedd5bb986c9c Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Sun, 30 Nov 2025 00:53:42 -0300 Subject: [PATCH 05/11] Better error messages and add support to Enum and UnionType --- modules/pymol/commanding.py | 151 +++++++++++++++++++-------- testing/tests/api/test_commanding.py | 31 +++++- 2 files changed, 133 insertions(+), 49 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 9c1b5ba9e..f4d68cbd1 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -24,16 +24,16 @@ from io import FileIO as file import inspect - import glob import shlex - import tokenize import builtins - from io import BytesIO from enum import Enum + if sys.version_info >= (3, 11): + from enum import StrEnum from functools import wraps from pathlib import Path from textwrap import dedent - from typing import Tuple, Iterable, get_args, Optional, Union, Any, NewType, List, get_origin + from typing import get_args, Union, Any, get_origin + from types import UnionType import re import os @@ -602,63 +602,121 @@ def get_state_list(states_str): output = get_state_list(states) states_list = sorted(set(map(int, output))) return _cmd.delete_states(_self._COb, name, states_list) + + + class ArgumentParsingError(ValueError): + "Error on argument parsing." - def _into_types(type, value): - if repr(type) == 'typing.Any': + def __init__(self, arg_name, message): + message = dedent(message).strip() + if arg_name: + s = f"Failed at parsing '{arg_name}'. {message}" + else: + s = message + super().__init__(s) + + + def _into_types(var, type, value): + + # Untyped string + if type == Any: return value + + # Boolean flags elif type is bool: if isinstance(value, bool): return value - if value.lower() in ["yes", "1", "true", "on", "y"]: + trues = ["yes", "1", "true", "on", "y"] + falses = ["no", "0", "false", "off", "n"] + if value.lower() in trues: return True - elif value.lower() in ["no", "0", "false", "off", "n"]: + elif value.lower() in falses: return False else: - raise pymol.CmdException(f"Invalid boolean value: {value}") + raise ArgumentParsingError( + var, + f"Can't parse {value!r} as bool." + f" Supported true values are {', '.join(trues)}." + f" Supported false values are {', '.join(falses)}." + ) - elif isinstance(type, builtins.type): - return type(value) - - if origin := get_origin(type): - if not repr(origin).startswith('typing.') and issubclass(origin, tuple): - args = get_args(type) - new_values = [] - for i, new_value in enumerate(shlex.split(value)): - new_values.append(_into_types(args[i], new_value)) - return tuple(new_values) + # Types from typing module + elif origin := get_origin(type): + + if origin in {Union, UnionType}: + funcs = get_args(type) + for func in funcs: + try: + return _into_types(None, func, value) + except: + continue + raise ArgumentParsingError( + var, + f"Can't parse {value!r} into {type}." + f" The parser tried each union type and none was suitable." + ) - elif origin == Union: - args = get_args(type) - found = False - for i, arg in enumerate(args): + elif issubclass(origin, tuple): + funcs = get_args(type) + if funcs: + values = shlex.split(value) + if len(funcs) > 0 and len(funcs) != len(values): + raise ArgumentParsingError( + var, + f"Can't parse {value!r} into {type}." + f" The number of tuple arguments are incorrect." + ) try: - found = True - return _into_types(arg, value) + return tuple(_into_types(None, f, v) for f, v in zip(funcs, values)) except: - found = False - if not found: - raise pymol.CmdException(f"Union was not able to cast {value}") - - elif issubclass(list, origin): - args = get_args(type) - if len(args) == 1: - f = args[0] - return [ - _into_types(f, a) - for a in shlex.split(value) - ] + raise ArgumentParsingError( + var, + f"Can't parse {value!r} into {type}." + f" One or more tuple values are of incorrect types." + ) + else: + return tuple(shlex.split(value)) + + elif issubclass(origin, list): + funcs = get_args(type) + if len(funcs) == 1: + func = funcs[0] + return [_into_types(None, func, a) for a in shlex.split(value)] return shlex.split(value) - elif issubclass(type, Enum): - if value in type: + elif sys.version_info >= (3, 11) and issubclass(type, StrEnum): + try: return type(value) - else: - raise pymol.CmdException(f"Invalid value for enum {type.__name__}: {value}") + except: + names = [e.value for e in list(type)] + raise ArgumentParsingError( + var, + f"Invalid value for {type.__name__}." + f" Accepted values are {', '.join(names)}." + ) + + # Specific types must go before other generic types + # isinstance(type, builtins.type) comes after + elif issubclass(type, Enum): + value = type.__members__.get(value) + if value is None: + raise ArgumentParsingError( + var, + f"Invalid value for {type.__name__}." + f" Accepted values are {', '.join(type.__members__)}." + ) + return value - elif isinstance(type, str): - return str(value) - - raise pymol.CmdException(f"Unsupported argument type annotation {type}") + # Generic types must accept str as single argument to __init__(s) + elif isinstance(type, builtins.type): + try: + return type(value) + except Exception as exc: + raise ArgumentParsingError( + var, + f"Invalid value {value!r} for custom type {type.__name__}." + f" The type must accept str as the solo argument to __init__(s)." + ) from exc def new_command(name, function=None, _self=cmd): @@ -701,7 +759,7 @@ def inner(*args, **kwargs): if var == 'quiet' and isinstance(value, int): new_kwargs[var] = bool(value) else: - new_kwargs[var] = _into_types(type, value) + new_kwargs[var] = _into_types(var, type, value) final_kwargs = { **kwargs_, **new_kwargs @@ -719,6 +777,7 @@ def inner(*args, **kwargs): inner.func = inner.__wrapped__ return inner + def extend(name, function=None, _self=cmd): ''' diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index c8e081fc2..a74a114b9 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -1,8 +1,11 @@ import sys from pytest import mark -from typing import List, Union, Any, Tuple +from typing import List, Union, Any, Tuple, Optional from pathlib import Path + from pymol import cmd +from pymol.commanding import ArgumentParsingError + def test_docstring(): @cmd.new_command @@ -26,7 +29,8 @@ def test_generic(capsys): def func( nullable_point: Tuple[float, float, float], my_var: Union[int, float] = 10, - my_foo: Union[int, float] = 10.0, + my_foo: int | float = 10.0, + null_ptr: Optional[bool] = None, extended_calculation: bool = True, old_style: Any = "Old behavior" ): @@ -34,6 +38,7 @@ def func( assert extended_calculation assert isinstance(my_var, int) assert isinstance(my_foo, float) + assert null_ptr is None assert old_style == "Old behavior" cmd.do("func nullable_point=1 2 3, my_foo=11.0") out, err = capsys.readouterr() @@ -104,6 +109,20 @@ def func(a: str="sele"): assert out + err == '' +def test_enum(capsys): + from enum import Enum + class E(Enum): + A = 1 + B = 2 + @cmd.new_command + def func(e: E): + assert e == E.A + assert isinstance(e, E) + cmd.do('func A') + out, err = capsys.readouterr() + assert out + err == '' + + @mark.skipif( sys.version_info < (3, 11), reason="Requires StrEnum of Python 3.11+" @@ -112,6 +131,7 @@ def test_str_enum(capsys): from enum import StrEnum class E(StrEnum): A = "a" + B = "b" @cmd.new_command def func(e: E): assert e == E.A @@ -127,4 +147,9 @@ def func(quiet: bool=True): assert not quiet cmd.do('func') out, err = capsys.readouterr() - assert out + err == '' \ No newline at end of file + assert out + err == '' + + +def test_argument_error(): + err = ArgumentParsingError('my_var', "Short error message.") + assert str(err) == "Failed at parsing 'my_var'. Short error message." \ No newline at end of file From b4615339286f21acaee63fa746b631180328d1b6 Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Fri, 5 Dec 2025 20:00:03 -0300 Subject: [PATCH 06/11] Call the right function. --- modules/pymol/commanding.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index f4d68cbd1..e45de29a6 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -770,7 +770,9 @@ def inner(*args, **kwargs): else: return function(*args, **kwargs) - _self.keyword[name] = [inner, 0,0,',',parsing.STRICT] + _self.keyword[name] = [inner, 0, 0, ',', parsing.STRICT] + _self.kwhash.append(name) + _self.help_sc.append(name) # Accessor to the original function so bypass the stack extraction. # The purpose is optimization (loops, for instance). From 1e6859e30f164ece01de7839144d1cf461fb2b6f Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Fri, 5 Dec 2025 20:02:29 -0300 Subject: [PATCH 07/11] Remove wrapping spaces. --- modules/pymol/commanding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index e45de29a6..488016513 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -726,7 +726,7 @@ def new_command(name, function=None, _self=cmd): # docstring text, if present, should be dedented if function.__doc__ is not None: - function.__doc__ = dedent(function.__doc__) + function.__doc__ = dedent(function.__doc__).strip() # Analysing arguments spec = inspect.getfullargspec(function) From 49bcc7192405e1365ac8477f340426ee7497c172 Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Fri, 5 Dec 2025 20:02:42 -0300 Subject: [PATCH 08/11] Better test coverage. --- testing/tests/api/test_commanding.py | 41 ++++++++++++++++++++++++---- 1 file changed, 36 insertions(+), 5 deletions(-) diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index a74a114b9..e1d8049bb 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -10,18 +10,21 @@ def test_docstring(): @cmd.new_command def func(): - """docstring""" - assert func.__doc__ == "docstring" + """ + docstring + a + """ + assert func.__doc__ == "docstring\na" def test_bool(capsys): @cmd.new_command def func(a: bool, b: bool): - assert a + assert a is True assert not b cmd.do("func yes, 0") out, err = capsys.readouterr() - assert out == '' and err == '' + assert out + err == '' def test_generic(capsys): @@ -152,4 +155,32 @@ def func(quiet: bool=True): def test_argument_error(): err = ArgumentParsingError('my_var', "Short error message.") - assert str(err) == "Failed at parsing 'my_var'. Short error message." \ No newline at end of file + assert str(err) == "Failed at parsing 'my_var'. Short error message." + +def test_call_error(capsys): + @cmd.new_command + def func( + my_var: Union[int, float] = 10, + my_foo: int | float = 10.0, + null_ptr: Optional[bool] = None, + extended_calculation: bool = True, + old_style: Any = "Old behavior" + ): + assert extended_calculation + assert isinstance(my_var, int) + assert isinstance(my_foo, float) + assert null_ptr is None + assert old_style == "Old behavior" + + cmd.do("func my_foo=a") + out, err = capsys.readouterr() + assert "Failed at parsing 'my_foo'." in (out + err) + + cmd.do("func extended_calculation=a") + out, err = capsys.readouterr() + assert ( + "Failed at parsing 'extended_calculation'." + " Can't parse 'a' as bool." + " Supported true values are yes, 1, true, on, y." + " Supported false values are no, 0, false, off, n." + ) in (out + err) \ No newline at end of file From b37bb0c7e3ab10f01bc86ad86b32f1c353a14c81 Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Thu, 25 Dec 2025 19:24:32 -0300 Subject: [PATCH 09/11] Add support to PEP 563 (from __future__ import annotations) --- modules/pymol/commanding.py | 41 +++++++++++++++++++++---------------- 1 file changed, 23 insertions(+), 18 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 488016513..e783682b9 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -32,7 +32,7 @@ from functools import wraps from pathlib import Path from textwrap import dedent - from typing import get_args, Union, Any, get_origin + from typing import get_args, Union, Any, get_origin, get_type_hints from types import UnionType import re @@ -728,40 +728,45 @@ def new_command(name, function=None, _self=cmd): if function.__doc__ is not None: function.__doc__ = dedent(function.__doc__).strip() - # Analysing arguments - spec = inspect.getfullargspec(function) - kwargs_ = {} - args_ = spec.args[:] - defaults = list(spec.defaults or []) - - args2_ = args_[:] - while args_ and defaults: - kwargs_[args_.pop(-1)] = defaults.pop(-1) - - funcs = {} - for idx, (var, func) in enumerate(spec.annotations.items()): - funcs[var] = func + # Resolve strings into real class objects (PEP 563). + try: + resolved_hints = get_type_hints( + function, + globalns=sys.modules[function.__module__].__dict__ + ) + except Exception: + resolved_hints = function.__annotations__ + # Analysing arguments + sign = inspect.signature(function) + # Inner function that will be callable every time the command is executed @wraps(function) def inner(*args, **kwargs): caller = sys._getframe(1).f_code.co_filename # It was called from command line or pml script, so parse arguments if caller == _parser_filename: - kwargs = {**kwargs, **dict(zip(args2_, args))} # special _self argument kwargs.pop("_self", None) new_kwargs = {} - for var, type in funcs.items(): + for var, param in sign.parameters.items(): if var in kwargs: value = kwargs[var] # special 'quiet' argument if var == 'quiet' and isinstance(value, int): new_kwargs[var] = bool(value) else: - new_kwargs[var] = _into_types(var, type, value) + actual_type = resolved_hints.get(var, param.annotation) + new_kwargs[var] = _into_types(var, actual_type, value) + else: + if param.default is sign.empty: + raise RuntimeError(f"Unknow variable '{var}'.") + defaults = { + k: v.default for k, v in sign.parameters.items() + if v.default is not sign.empty + } final_kwargs = { - **kwargs_, + **defaults, **new_kwargs } return function(**final_kwargs) From 6064a2411b9bc3454a8b53847036f15045b6f8cf Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Mon, 23 Mar 2026 15:46:31 -0300 Subject: [PATCH 10/11] Fix Exception testing --- testing/tests/api/test_commanding.py | 33 +++++++++++++++++----------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/testing/tests/api/test_commanding.py b/testing/tests/api/test_commanding.py index e1d8049bb..49db98812 100644 --- a/testing/tests/api/test_commanding.py +++ b/testing/tests/api/test_commanding.py @@ -157,6 +157,7 @@ def test_argument_error(): err = ArgumentParsingError('my_var', "Short error message.") assert str(err) == "Failed at parsing 'my_var'. Short error message." + def test_call_error(capsys): @cmd.new_command def func( @@ -166,21 +167,27 @@ def func( extended_calculation: bool = True, old_style: Any = "Old behavior" ): - assert extended_calculation assert isinstance(my_var, int) assert isinstance(my_foo, float) assert null_ptr is None + assert extended_calculation assert old_style == "Old behavior" - cmd.do("func my_foo=a") - out, err = capsys.readouterr() - assert "Failed at parsing 'my_foo'." in (out + err) - - cmd.do("func extended_calculation=a") - out, err = capsys.readouterr() - assert ( - "Failed at parsing 'extended_calculation'." - " Can't parse 'a' as bool." - " Supported true values are yes, 1, true, on, y." - " Supported false values are no, 0, false, off, n." - ) in (out + err) \ No newline at end of file + try: + cmd._pymol.invocation.options.exit_on_error = 0 + + cmd.do("func my_foo=a") + out, err = capsys.readouterr() + assert "Failed at parsing 'my_foo'." in (out + err) + + cmd.do("func extended_calculation=a") + out, err = capsys.readouterr() + assert ( + "Failed at parsing 'extended_calculation'." + " Can't parse 'a' as bool." + " Supported true values are yes, 1, true, on, y." + " Supported false values are no, 0, false, off, n." + ) in (out + err) + + finally: + cmd._pymol.invocation.options.exit_on_error = 1 From 55f23152a0bc95842f20936b2e4ce88b59a59cfe Mon Sep 17 00:00:00 2001 From: Pedro Sousa Lacerda Date: Mon, 23 Mar 2026 17:43:08 -0300 Subject: [PATCH 11/11] Fixed incorrect exception type --- modules/pymol/commanding.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index e783682b9..1b44f0727 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -760,7 +760,7 @@ def inner(*args, **kwargs): new_kwargs[var] = _into_types(var, actual_type, value) else: if param.default is sign.empty: - raise RuntimeError(f"Unknow variable '{var}'.") + raise ArgumentParsingError(f"Unknow argument '{var}'.") defaults = { k: v.default for k, v in sign.parameters.items() if v.default is not sign.empty