From 095e96134dcfb1a378e8c463b870eebc2cccbaa2 Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Tue, 13 May 2025 18:21:04 -0300 Subject: [PATCH 01/11] Refactor @cmd.declare_command --- modules/pymol/commanding.py | 168 ++++++++++++++++++++++---------- testing/tests/api/commanding.py | 156 +++++++++++++++++------------ testing/tests/api/helping.py | 50 ---------- 3 files changed, 213 insertions(+), 161 deletions(-) delete mode 100644 testing/tests/api/helping.py diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index cee7c6a4e..4a8816a01 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -20,16 +20,19 @@ if True: import _thread as thread import urllib.request as urllib2 - from io import FileIO as file + from io import FileIO as file, BytesIO + import builtins import inspect import glob import shlex + import tokenize from enum import Enum from functools import wraps from pathlib import Path from textwrap import dedent - from typing import List + from typing import Tuple, Iterable, get_args, Optional, Union, Any, NewType, List, get_origin + import re import os @@ -599,45 +602,117 @@ def get_state_list(states_str): states_list = sorted(set(map(int, output))) return _cmd.delete_states(_self._COb, name, states_list) - class Selection(str): - pass - - - def _parse_bool(value: str): - if isinstance(value, str): + def _into_types(type, value): + if repr(type) == 'typing.Any': + return value + elif type is bool: + if isinstance(value, bool): + return value if value.lower() in ["yes", "1", "true", "on", "y"]: return True elif value.lower() in ["no", "0", "false", "off", "n"]: return False else: - raise Exception("Invalid boolean value: %s" % value) - elif isinstance(value, bool): - return value - else: - raise Exception(f"Unsuported boolean flag {value}") - - def _parse_list_str(value): - return shlex.split(value) - - def _parse_list_int(value): - return list(map(int, shlex.split(value))) + raise pymol.CmdException("Invalid boolean value: %s" % value) + + 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) + + elif origin == Union: + args = get_args(type) + found = False + for i, arg in enumerate(args): + try: + found = True + return _into_types(arg, value) + except: + found = False + if not found: + raise pymol.CmdException(f"Union was not able to cast %s" % value) + + elif issubclass(list, origin): + args = get_args(type) + if len(args) > 0: + f = args[0] + else: + f = lambda x: x + return [f(i) for i in shlex.split(value)] + + # elif value is None: + # origin = get_origin(type) + # if origin is None: + # return None + # else: + # return _into_types(origin) + # for arg in get_args(origin): + # return _into_types(get_args(origin), value) + + elif isinstance(type, str): + return str(value) + + raise pymol.CmdException(f"Unsupported argument type {type}") + + def parse_documentation(func): + source = inspect.getsource(func) + tokens = tokenize.tokenize(BytesIO(source.encode('utf-8')).readline) + tokens = list(tokens) + comments = [] + params = {} + i = -1 + started = False + while True: + i += 1 + if tokens[i].string == "def": + while tokens[i].string == "(": + i += 1 + started = True + continue + if not started: + continue + if tokens[i].string == "->": + break + if tokens[i].type == tokenize.NEWLINE: + break + if tokens[i].string == ")": + break + if tokens[i].type == tokenize.COMMENT: + comments.append(tokens[i].string) + continue + if tokens[i].type == tokenize.NAME and tokens[i+1].string == ":": + name = tokens[i].string + name_line = tokens[i].line + i += 1 + while not (tokens[i].type == tokenize.NAME and tokens[i+1].string == ":"): + if tokens[i].type == tokenize.COMMENT and tokens[i].line == name_line: + comments.append(tokens[i].string) + break + elif tokens[i].type == tokenize.NEWLINE: + break + i += 1 + else: + i -= 3 + docs = ' '.join(c[1:].strip() for c in comments) + params[name] = docs + comments = [] + return params - def _parse_list_float(value): - return list(map(float, shlex.split(value))) def declare_command(name, function=None, _self=cmd): + if function is None: name, function = name.__name__, name - # new style commands should have annotations - annotations = [a for a in function.__annotations__ if a != "return"] - if function.__code__.co_argcount != len(annotations): - raise Exception("Messy annotations") - # docstring text, if present, should be dedented if function.__doc__ is not None: - function.__doc__ = dedent(function.__doc__).strip() - + function.__doc__ = dedent(function.__doc__) # Analysing arguments spec = inspect.getfullargspec(function) @@ -658,37 +733,32 @@ def declare_command(name, function=None, _self=cmd): def inner(*args, **kwargs): frame = traceback.format_stack()[-2] caller = frame.split("\"", maxsplit=2)[1] - # It was called from command line or pml script, so parse arguments if caller.endswith("pymol/parser.py"): - kwargs = {**kwargs_, **kwargs, **dict(zip(args2_, args))} + kwargs = {**kwargs, **dict(zip(args2_, args))} kwargs.pop("_self", None) - for arg in kwargs.copy(): - if funcs[arg] == bool: - funcs[arg] = _parse_bool - elif funcs[arg] == List[str]: - funcs[arg] = _parse_list_str - elif funcs[arg] == List[int]: - funcs[arg] = _parse_list_int - elif funcs[arg] == List[float]: - funcs[arg] = _parse_list_float - else: - # Assume it's a literal supported type - pass - # Convert the argument to the correct type - kwargs[arg] = funcs[arg](kwargs[arg]) - return function(**kwargs) + 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 + return function(**final_kwargs) # It was called from Python, so pass the arguments as is else: return function(*args, **kwargs) + inner.__arg_docs = parse_documentation(function) - name = function.__name__ - _self.keyword[name] = [inner, 0, 0, ",", parsing.STRICT] - _self.kwhash.append(name) - _self.help_sc.append(name) + _self.keyword[name] = [inner, 0,0,',',parsing.STRICT] return inner + def extend(name, function=None, _self=cmd): ''' diff --git a/testing/tests/api/commanding.py b/testing/tests/api/commanding.py index b8bd4a541..a9ef99894 100644 --- a/testing/tests/api/commanding.py +++ b/testing/tests/api/commanding.py @@ -1,15 +1,12 @@ from __future__ import print_function import sys -import pytest -import pymol import __main__ from pymol import cmd, testing, stored - from typing import List - - +from typing import Optional, Any, Tuple, Union, List +from pathlib import Path class TestCommanding(testing.PyMOLTestCase): @@ -185,26 +182,29 @@ def testRun(self, namespace, mod, rw): if mod: self.assertEqual(rw, hasattr(sys.modules[mod], varname)) + def test_declare_command_casting(): from pathlib import Path - @cmd.declare_command def func(a: int, b: Path): assert isinstance(a, int) and a == 1 - assert isinstance(b, (Path, str)) and "/tmp" == str(b) - func(1, "/tmp") + assert isinstance(b, Path) and "/tmp" == str(b) cmd.do('func 1, /tmp') - -def test_declare_command_default(capsys): - from pymol.commanding import Selection +def test_declare_command_optional(capsys): @cmd.declare_command - def func(a: Selection = "sele"): - assert a == "sele" - func() + def func(a: Optional[int] = None): + assert a is None cmd.do("func") out, err = capsys.readouterr() - assert out == '' + assert out+err == '' + + @cmd.declare_command + def func(a: Optional[int] = None): + assert a is 10 + cmd.do("func 10") + out, err = capsys.readouterr() + assert out+err == '' def test_declare_command_docstring(): @cmd.declare_command @@ -212,72 +212,104 @@ def func(): """docstring""" assert func.__doc__ == "docstring" + +def test_declare_command_bool(capsys): @cmd.declare_command - def func(): - """ - docstring - Test: - --foo - """ - assert func.__doc__ == "docstring\nTest:\n --foo" + def func(a: bool, b: bool): + assert a + assert not b + cmd.do("func yes, 0") + out, err = capsys.readouterr() + assert out == '' and err == '' -def test_declare_command_type_return(capsys): - @cmd.declare_command - def func() -> int: - return 1 - assert func() == 1 +def test_declare_command_generic(capsys): + @cmd.declare_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 == '' + assert out + err == '' +def test_declare_command_path(capsys): @cmd.declare_command - def func(): - return 1 - assert func() == 1 + def func(dirname: Path = Path('.')): + assert dirname.exists() + cmd.do('func ..') + cmd.do('func') + out, err = capsys.readouterr() + assert out + err == '' -def test_declare_command_list_str(capsys): +def test_declare_command_any(capsys): @cmd.declare_command - def func(a: List[str]): - print(a[-1]) + def func(old_style: Any): + assert old_style != "RuntimeError" + cmd.do("func RuntimeError") + out, err = capsys.readouterr() + assert 'AssertionError' in out+err - func(["a", "b", "c"]) - cmd.do('func a b c') +def test_declare_command_list(capsys): + @cmd.declare_command + def func(a: List): + assert a[1] == "2" + cmd.do("func 1 2 3") out, err = capsys.readouterr() - assert out == 'c\nc\n' + assert out + err == '' -def test_declare_command_list_int(capsys): @cmd.declare_command def func(a: List[int]): - print(a[-1] ** 2) - return a[-1] ** 2 - - assert func([1, 2, 3]) == 9 - cmd.do('func 1 2 3') + assert a[1] == 2 + cmd.do("func 1 2 3") out, err = capsys.readouterr() - assert out == '9\n9\n' + assert out + err == '' - -def test_declare_command_list_float(capsys): +def test_declare_command_tuple(capsys): @cmd.declare_command - def func(a: List[float]): - print(a[-1]**2) - return a[-1]**2 - - assert func([1.1, 2.0, 3.0]) == 9.0 - cmd.do('func 1 2 3') + def func(a: Tuple[str, int]): + assert a == ("fooo", 42) + cmd.do("func fooo 42") out, err = capsys.readouterr() - assert out == '9.0\n9.0\n' + assert out + err == '' - -def test_declare_command_bool(capsys): +def test_declare_command_arg_docs(): @cmd.declare_command - def func(a: bool, b: bool): - assert a - assert not b + def func( + # multiline + # documentation works + foo: int, # inline + a: str, + # bar are strings + bar: Tuple[str, int], # continued... + b: Any = 10, # The new old age + # aaaa + c: Any = 'a' # b + ): + "main description" + pass - func(True, False) + assert func.__arg_docs['foo'] == "multiline documentation works inline" + assert func.__arg_docs['a'] == "" + assert func.__arg_docs['bar'] == "bar are strings continued..." + assert func.__arg_docs['b'] == 'The new old age' + assert func.__arg_docs['c'] == 'aaaa b' + assert func.__annotations__['foo'] == int + assert func.__annotations__['bar'] == Tuple[str, int] - cmd.do("func yes, no") - out, err = capsys.readouterr() - assert out == '' and err == '' \ No newline at end of file +def test_declare_command_default(): + @cmd.declare_command + def func(a: str="sele"): + assert a == "a" + func("a") + cmd.do('func a') \ No newline at end of file diff --git a/testing/tests/api/helping.py b/testing/tests/api/helping.py deleted file mode 100644 index d442015a6..000000000 --- a/testing/tests/api/helping.py +++ /dev/null @@ -1,50 +0,0 @@ -import sys -import unittest -from pymol import cmd, testing, stored - -try: - from io import StringIO - from unittest.mock import patch - mock_not_available = False -except ImportError: - mock_not_available = True - - -def func_with_indented_help(): - ''' - USAGE - - foo - - SEE ALSO - - https://github.com/schrodinger/pymol-open-source/issues/116 - ''' - - -cmd.extend('func_with_indented_help', func_with_indented_help) - - -@unittest.skipIf(mock_not_available, "unittest.mock not available") -class TestHelping(testing.PyMOLTestCase): - def testApi(self): - with patch('sys.stdout', new=StringIO()) as out: - cmd.api("color") - self.assertTrue('API: pymol.viewing.color' in out.getvalue()) - - def testHelp(self): - with patch('sys.stdout', new=StringIO()) as out: - cmd.help('color') - self.assertTrue('USAGE\n\n color color' in out.getvalue()) - - @testing.requires_version('2.5') - def testHelp_dedent(self): - with patch('sys.stdout', new=StringIO()) as out: - cmd.help('func_with_indented_help') - self.assertTrue('USAGE\n\n foo\n\nSEE' in out.getvalue()) - - @testing.requires_version('2.4') - @testing.requires('incentive') - def testHelpSetting(self): - out = cmd.help_setting('transparency') - self.assertTrue('controls surface transparency' in out) From d0f8406b740538acb3322010399fe96a0fd3f991 Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Sat, 31 May 2025 21:18:06 -0300 Subject: [PATCH 02/11] Fix mixed string formatting --- 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 4a8816a01..00dd9a8be 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -636,7 +636,7 @@ def _into_types(type, value): except: found = False if not found: - raise pymol.CmdException(f"Union was not able to cast %s" % value) + raise pymol.CmdException("Union was not able to cast %s" % value) elif issubclass(list, origin): args = get_args(type) From 40d5053136b478768e1292198c2f20bed7561e7b Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Thu, 19 Jun 2025 22:37:10 -0300 Subject: [PATCH 03/11] Fix quient default parameter --- modules/pymol/commanding.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 00dd9a8be..f6d71bad2 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -605,13 +605,18 @@ def get_state_list(states_str): def _into_types(type, value): if repr(type) == 'typing.Any': return value + elif type is bool: if isinstance(value, bool): return value - if value.lower() in ["yes", "1", "true", "on", "y"]: - return True - elif value.lower() in ["no", "0", "false", "off", "n"]: - return False + elif isinstance(value, str): + if value.lower() in ["yes", "1", "true", "on", "y"]: + return True + elif value.lower() in ["no", "0", "false", "off", "n"]: + return False + elif isinstance(value, int): + if value in [0, 1]: + return bool(value) else: raise pymol.CmdException("Invalid boolean value: %s" % value) @@ -706,7 +711,6 @@ def parse_documentation(func): def declare_command(name, function=None, _self=cmd): - if function is None: name, function = name.__name__, name From d9dec36e1fdbed8d5ee6d48bede81a573ca178b2 Mon Sep 17 00:00:00 2001 From: Thomas Holder Date: Sun, 1 Jun 2025 00:37:36 +0200 Subject: [PATCH 04/11] Eliminate two Biopython deprecation warnings (#458) - Bio.pairwise2 has been deprecated -> Use Bio.Align.PairwiseAligner instead - SeqRecord constructor: Using a string as the sequence is deprecated -> Convert to Seq The changes are based on https://github.com/speleo3/pymol-psico/blob/ae2b92c262bc/psico/seqalign.py --- modules/pymol/seqalign.py | 39 ++++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/modules/pymol/seqalign.py b/modules/pymol/seqalign.py index d938a3978..45e4928a8 100644 --- a/modules/pymol/seqalign.py +++ b/modules/pymol/seqalign.py @@ -11,6 +11,25 @@ from pymol import cmd, CmdException +import functools +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + import Bio.Align + + +@functools.cache +def _get_aligner_BLOSUM62() -> "Bio.Align.PairwiseAligner": + from Bio.Align import PairwiseAligner, substitution_matrices + blosum62 = substitution_matrices.load("BLOSUM62") + missing_codes = ''.join(set('JUO-.?').difference(blosum62.alphabet)) + blosum62 = blosum62.select(blosum62.alphabet + missing_codes) + aligner = PairwiseAligner(internal_open_gap_score=-10, + extend_gap_score=-.5, + substitution_matrix=blosum62) + assert aligner.mode == "global" + return aligner + def needle_alignment(s1, s2): ''' @@ -19,26 +38,16 @@ def needle_alignment(s1, s2): Does a Needleman-Wunsch Alignment of sequence s1 and s2 and returns a Bio.Align.MultipleSeqAlignment object. ''' - from Bio import pairwise2 from Bio.Align import MultipleSeqAlignment from Bio.SeqRecord import SeqRecord - try: - from Bio.Align import substitution_matrices - except ImportError: - from Bio.SubsMat.MatrixInfo import blosum62 - else: - blosum62 = substitution_matrices.load("BLOSUM62") - - def match_callback(c1, c2): - return blosum62.get((c1, c2), 1 if c1 == c2 else -4) + from Bio.Seq import Seq - alns = pairwise2.align.globalcs(s1, s2, - match_callback, -10., -.5, - one_alignment_only=True) + aligner = _get_aligner_BLOSUM62() + alns = aligner.align(s1, s2) a = MultipleSeqAlignment([]) - s1 = SeqRecord(alns[0][0], id="s1") - s2 = SeqRecord(alns[0][1], id="s2") + s1 = SeqRecord(Seq(alns[0][0]), id="s1") + s2 = SeqRecord(Seq(alns[0][1]), id="s2") a.extend([s1, s2]) return a From 9fc0304bd3a691118a3e53eb0a3e7b8bc3abe684 Mon Sep 17 00:00:00 2001 From: Thomas Holder Date: Wed, 4 Jun 2025 05:06:17 +0200 Subject: [PATCH 05/11] Fix declare_command on Windows (#459) --- modules/pymol/commanding.py | 1 + 1 file changed, 1 insertion(+) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index f6d71bad2..33398d043 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -13,6 +13,7 @@ #Z* ------------------------------------------------------------------- from pymol.shortcut import Shortcut +from pymol.parser import __file__ as _parser_filename if True: From 3140e2804fec58f769a70761a2b2439d073230ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Vedran=20Mileti=C4=87?= Date: Tue, 17 Jun 2025 16:09:14 +0200 Subject: [PATCH 06/11] Fixed build on FreeBSD (#461) --- layer0/MemoryUsage.cpp | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/layer0/MemoryUsage.cpp b/layer0/MemoryUsage.cpp index 35d5d0938..3d0f810e1 100644 --- a/layer0/MemoryUsage.cpp +++ b/layer0/MemoryUsage.cpp @@ -4,7 +4,7 @@ #include "pymol/memory.h" -#ifdef __linux__ +#if defined(__linux__) || defined(__FreeBSD__) #include #include #include @@ -32,7 +32,7 @@ size_t memory_usage() { #ifdef _WEBGL return 0; -#elif defined(__linux__) +#elif defined(__linux__) || defined(__FreeBSD__) size_t vmRSS = 0; if (auto fp = std::fopen("/proc/self/statm", "rb")) { std::fscanf(fp, "%*zu%zu", &vmRSS); @@ -66,7 +66,7 @@ size_t memory_available() { #ifdef _WEBGL return 0; -#elif defined(__linux__) +#elif defined(__linux__) || defined(__FreeBSD__) size_t memAvail = 0; if (auto fp = unique_ptr_take_ownership( std::fopen("/proc/meminfo", "rb"), std::fclose)) { From 3aa351bf0ec704b19dc15910054c7d27f125aac7 Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Thu, 19 Jun 2025 23:06:01 -0300 Subject: [PATCH 07/11] Sorry for the messy commits --- modules/pymol/commanding.py | 1 + testing/tests/api/commanding.py | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 33398d043..23ec0e3d9 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -712,6 +712,7 @@ def parse_documentation(func): def declare_command(name, function=None, _self=cmd): + if function is None: name, function = name.__name__, name diff --git a/testing/tests/api/commanding.py b/testing/tests/api/commanding.py index a9ef99894..d3991c6a4 100644 --- a/testing/tests/api/commanding.py +++ b/testing/tests/api/commanding.py @@ -1,8 +1,7 @@ -from __future__ import print_function - import sys import __main__ +from pytest import mark from pymol import cmd, testing, stored from typing import List from typing import Optional, Any, Tuple, Union, List @@ -191,6 +190,8 @@ def func(a: int, b: Path): assert isinstance(b, Path) and "/tmp" == str(b) cmd.do('func 1, /tmp') + +@mark.skip(reason="API not implemented yet") def test_declare_command_optional(capsys): @cmd.declare_command def func(a: Optional[int] = None): From 36123020eed8f8401816f4d096d491eef524b180 Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Thu, 19 Jun 2025 23:48:26 -0300 Subject: [PATCH 08/11] Fix some bugs --- modules/pymol/commanding.py | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 01e2839fc..2e3203518 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -741,20 +741,14 @@ def inner(*args, **kwargs): # It was called from command line or pml script, so parse arguments if caller == _parser_filename: - kwargs = {**kwargs_, **kwargs, **dict(zip(args2_, args))} + kwargs = {**kwargs, **dict(zip(args2_, args))} 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 - return function(**final_kwargs) + return function(**new_kwargs) # It was called from Python, so pass the arguments as is else: From b2493c765b203140a9fc6029bbc7ea1f9bf0907e Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Fri, 20 Jun 2025 10:32:54 -0300 Subject: [PATCH 09/11] Add support for Enum types in the `declare_command` --- modules/pymol/commanding.py | 8 +++++++- testing/tests/api/commanding.py | 20 +++++++++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 2e3203518..6e13e31b1 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -620,7 +620,13 @@ def _into_types(type, value): return bool(value) else: raise pymol.CmdException("Invalid boolean value: %s" % value) - + + elif isinstance(type, Enum): + if value in type: + return type(value) + else: + raise pymol.CmdException(f"Invalid value for enum {type.__name__}: {value}") + elif isinstance(type, builtins.type): return type(value) diff --git a/testing/tests/api/commanding.py b/testing/tests/api/commanding.py index d3991c6a4..11f0e6d95 100644 --- a/testing/tests/api/commanding.py +++ b/testing/tests/api/commanding.py @@ -1,4 +1,5 @@ import sys +from enum import Enum import __main__ from pytest import mark @@ -313,4 +314,21 @@ def test_declare_command_default(): def func(a: str="sele"): assert a == "a" func("a") - cmd.do('func a') \ No newline at end of file + cmd.do('func a') + + +def test_declare_command_enum(capsys): + class E(str, Enum): + A = "a" + B = "b" + C = "c" + + @cmd.declare_command + def func(e: E): + assert isinstance(e, E) + assert e == E.A + assert e == "a" + + cmd.do('func a') + out, err = capsys.readouterr() + assert out + err == '' \ No newline at end of file From ab4c87e8b47db26d2b74b34b0949f80b0354b4dd Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Tue, 22 Jul 2025 11:35:56 -0300 Subject: [PATCH 10/11] Documents declare_command helpers --- modules/pymol/commanding.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/modules/pymol/commanding.py b/modules/pymol/commanding.py index 6e13e31b1..dc4356a0e 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -604,6 +604,8 @@ def get_state_list(states_str): return _cmd.delete_states(_self._COb, name, states_list) def _into_types(type, value): + """Convert a string value to an specific type.""" + if repr(type) == 'typing.Any': return value @@ -616,8 +618,7 @@ def _into_types(type, value): elif value.lower() in ["no", "0", "false", "off", "n"]: return False elif isinstance(value, int): - if value in [0, 1]: - return bool(value) + return bool(value) else: raise pymol.CmdException("Invalid boolean value: %s" % value) @@ -630,6 +631,7 @@ def _into_types(type, value): elif isinstance(type, builtins.type): return type(value) + # Composite types for now if origin := get_origin(type): if not repr(origin).startswith('typing.') and issubclass(origin, tuple): args = get_args(type) @@ -657,7 +659,8 @@ def _into_types(type, value): else: f = lambda x: x return [f(i) for i in shlex.split(value)] - + + # TODO Optional/None case isn't working # elif value is None: # origin = get_origin(type) # if origin is None: @@ -672,7 +675,12 @@ def _into_types(type, value): raise pymol.CmdException(f"Unsupported argument type {type}") - def parse_documentation(func): + def parse_args_docs(func): + """Extract the arguments documentation of a function. + + They are given by the # comments preceding or at the same + line of each argument. + """ source = inspect.getsource(func) tokens = tokenize.tokenize(BytesIO(source.encode('utf-8')).readline) tokens = list(tokens) @@ -759,7 +767,7 @@ def inner(*args, **kwargs): # It was called from Python, so pass the arguments as is else: return function(*args, **kwargs) - inner.__arg_docs = parse_documentation(function) + inner.__arg_docs = parse_args_docs(function) _self.keyword[name] = [inner, 0,0,',',parsing.STRICT] return inner From 22ba8f8a7512e3b74cbedd47278b22d13769b1c0 Mon Sep 17 00:00:00 2001 From: Pedro Lacerda Date: Tue, 22 Jul 2025 13:31:57 -0300 Subject: [PATCH 11/11] Prefer f-strings --- 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 dc4356a0e..67a053198 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -620,7 +620,7 @@ def _into_types(type, value): elif isinstance(value, int): return bool(value) else: - raise pymol.CmdException("Invalid boolean value: %s" % value) + raise pymol.CmdException(f"Invalid boolean value: {value}") elif isinstance(type, Enum): if value in type: