diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5462115b..8c4f43e0c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -28,8 +28,6 @@ jobs: python3-dev python3-setuptools python3-numpy - python3-pil - python3-pytest python3-pip - name: Install collada2gltf @@ -45,13 +43,14 @@ jobs: - name: Build run: | pip install --upgrade pip - pip install -v --config-settings testing=True . + pip install -v --config-settings testing=True '.[dev]' env: DEBUG: 1 - name: Test run: | pymol -ckqy testing/testing.py --run all + python -m pytest tests -vv build-Windows: @@ -74,7 +73,7 @@ jobs: shell: cmd run: |- CALL %CONDA_ROOT%\\Scripts\\activate.bat - conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build + conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython msgpack-python pip python-build - name: Conda info shell: cmd @@ -94,13 +93,14 @@ jobs: shell: cmd run: | CALL %CONDA_ROOT%\\Scripts\\activate.bat - pip install -v --config-settings testing=True . + pip install -v --config-settings testing=True .[dev] - name: Test shell: cmd run: | CALL %CONDA_ROOT%\\Scripts\\activate.bat pymol -ckqy testing\\testing.py --run all + python -m pytest tests -vv build-MacOS: @@ -117,7 +117,7 @@ jobs: bash $CONDA_ROOT.sh -b -p $CONDA_ROOT export PATH="$CONDA_ROOT/bin:$PATH" conda config --set quiet yes - conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython pillow msgpack-python pytest pip python-build + conda install -y -c conda-forge -c schrodinger python cmake libpng freetype pyside6 glew libxml2 numpy=1.26.4 catch2=2.13.3 glm libnetcdf collada2gltf biopython msgpack-python pip python-build conda info - name: Get additional sources @@ -131,9 +131,10 @@ jobs: run: |- export MACOSX_DEPLOYMENT_TARGET=12.0 export PATH="$CONDA_ROOT/bin:$PATH" - pip install -v --config-settings testing=True . + pip install -v --config-settings testing=True '.[dev]' - name: Test run: |- export PATH="$CONDA_ROOT/bin:$PATH" pymol -ckqy testing/testing.py --run all + python -m pytest tests -vv diff --git a/modules/pymol/cmd.py b/modules/pymol/cmd.py index 09ae92f9e..c2dcb6da8 100644 --- a/modules/pymol/cmd.py +++ b/modules/pymol/cmd.py @@ -33,6 +33,7 @@ # # In rare cases, certain nonserious error or warning output should # also be suppressed. Set "quiet" to 2 for this behavior. +from pymol.shortcut import Shortcut def _deferred_init_pymol_internals(_pymol): # set up some global session tasks @@ -54,7 +55,7 @@ def _deferred_init_pymol_internals(_pymol): # take care of some deferred initialization - _pymol._view_dict_sc = Shortcut({}) + _pymol._view_dict_sc = Shortcut() # if True: @@ -78,7 +79,6 @@ def _deferred_init_pymol_internals(_pymol): _pymol = pymol - from .shortcut import Shortcut from chempy import io diff --git a/modules/pymol/shortcut.py b/modules/pymol/shortcut.py index ed654b79b..1f172a85d 100644 --- a/modules/pymol/shortcut.py +++ b/modules/pymol/shortcut.py @@ -1,184 +1,171 @@ -#A* ------------------------------------------------------------------- -#B* This file contains source code for the PyMOL computer program -#C* Copyright (c) Schrodinger, LLC. -#D* ------------------------------------------------------------------- -#E* It is unlawful to modify or remove this copyright notice. -#F* ------------------------------------------------------------------- -#G* Please see the accompanying LICENSE file for further information. -#H* ------------------------------------------------------------------- -#I* Additional authors of this source file include: -#-* -#-* -#-* -#Z* ------------------------------------------------------------------- - -if __name__=='pymol.shortcut': - from . import parsing - from .checking import is_string, is_list - -if True: - def mkabbr(a, m=1): - b = a.split('_') - b[:-1] = [c[0:m] for c in b[:-1]] - return '_'.join(b) - - class Shortcut: - - def __call__(self): - return self - - def __init__(self, keywords=(), filter_leading_underscore=1): - self.filter_leading_underscore = filter_leading_underscore - if filter_leading_underscore: - self.keywords = [x for x in keywords if x[:1]!='_'] - else: - self.keywords = list(keywords) - self.shortcut = {} - self.abbr_dict = {} - self.rebuild() - - def add_one(self,a): - # optimize symbols - hash = self.shortcut - abbr_dict = self.abbr_dict - for b in range(1,len(a)): - sub = a[0:b] - hash[sub] = 0 if sub in hash else a - if '_' in a: - for n in (1, 2): - abbr = mkabbr(a, n) - if a!=abbr: - if abbr in abbr_dict: - if a not in abbr_dict[abbr]: - abbr_dict[abbr].append(a) - else: - abbr_dict[abbr]=[a] - for b in range(abbr.find('_')+1,len(abbr)): - sub = abbr[0:b] - hash[sub] = 0 if sub in hash else a - - def rebuild(self, keywords=None): - if keywords is not None: - if self.filter_leading_underscore: - self.keywords = [x for x in keywords if x[:1]!='_'] - else: - self.keywords = list(keywords) - # optimize symbols - self.shortcut = {} - hash = self.shortcut - self.abbr_dict = {} - abbr_dict = self.abbr_dict - # - for a in self.keywords: - for b in range(1,len(a)): - sub = a[0:b] - hash[sub] = 0 if sub in hash else a - if '_' in a: - for n in (1, 2): - abbr = mkabbr(a, n) - if a!=abbr: - if abbr in abbr_dict: - abbr_dict[abbr].append(a) - else: - abbr_dict[abbr]=[a] - for b in range(abbr.find('_')+1,len(abbr)): - sub = abbr[0:b] - hash[sub] = 0 if sub in hash else a - - self._rebuild_finalize() - - def _rebuild_finalize(self): - hash = self.shortcut - for a, adk in self.abbr_dict.items(): - if len(adk)==1: - hash[a]=adk[0] - for a in self.keywords: - hash[a]=a - - def interpret(self,kee, mode=0): - ''' - Returns None (no hit), str (one hit) or list (multiple hits) - - kee = str: query string, setting prefix or shortcut - mode = 0/1: if mode=1, do prefix search even if kee has exact match - ''' - if not len(kee): # empty string matches everything - return list(self.keywords) - - try: - r = self.shortcut[kee] - except KeyError: - return None - if r and not mode: - return r - - # prefix search - lst_set = set(a for a in self.keywords if a.startswith(kee)) - for abbr, a_list in self.abbr_dict.items(): - if abbr.startswith(kee): - lst_set.update(a_list) - - # no match - if not lst_set: - return None - - # single match: str - lst = list(lst_set) - if len(lst) == 1: - return lst[0] - - # multiple matches: list - return lst - - def has_key(self,kee): - return kee in self.shortcut - - __contains__ = has_key - - def __getitem__(self,kee): - return self.shortcut.get(kee, None) - - def __delitem__(self,kee): - self.keywords.remove(kee) - self.rebuild() - - def append(self,kee): - self.keywords.append(kee) - self.add_one(kee) - self._rebuild_finalize() - - def auto_err(self,kee,descrip=None): - result = None - if kee not in self.shortcut: - if descrip is not None: - msg = "Error: unknown %s: '%s'." % (descrip, kee) - lst = self.interpret('') - if is_list(lst): - if len(lst)<100: - lst.sort() - lst = parsing.list_to_str_list(lst) - msg += " Choices:\n" - msg += "\n".join(lst) - raise parsing.QuietException(msg) - - else: - result = self.interpret(kee) - if not is_string(result): - if descrip is not None: - lst = parsing.list_to_str_list(result) - msg = "Error: ambiguous %s:\n%s" % (descrip, '\n'.join(lst)) - raise parsing.QuietException(msg) +# A* ------------------------------------------------------------------- +# B* This file contains source code for the PyMOL computer program +# C* Copyright (c) Schrodinger, LLC. +# D* ------------------------------------------------------------------- +# E* It is unlawful to modify or remove this copyright notice. +# F* ------------------------------------------------------------------- +# G* Please see the accompanying LICENSE file for further information. +# H* ------------------------------------------------------------------- +# I* Additional authors of this source file include: +# -* +# -* +# -* +# Z* ------------------------------------------------------------------- + +from typing import Iterable, Optional +from collections import defaultdict +from pymol import parsing + + +class Shortcut: + def __init__( + self, + keywords: Optional[Iterable] = None, + filter_leading_underscore: bool = True, + ): + keywords = list(keywords) if keywords is not None else [] + self.filter_leading_underscore = filter_leading_underscore + self.keywords = ( + [keyword for keyword in keywords if keyword[:1] != "_"] + if filter_leading_underscore + else keywords + ) + self.shortcut: dict[str, str | int] = {} + self.abbreviation_dict = defaultdict(list) + + for keyword in self.keywords: + self.optimize_symbols(keyword) + + self._rebuild_finalize() + + def __contains__(self, keyword: str) -> bool: + return keyword in self.shortcut + + def __getitem__(self, keyword: str) -> Optional[int | str]: + return self.shortcut.get(keyword) + + def __delitem__(self, keyword: str) -> None: + self.keywords.remove(keyword) + self.rebuild() + + def make_abbreviation(self, s: str, groups_length: int) -> str: + """ + Example 1: + Input: s:'abc_def_ghig', groups_length: 1 + Output: 'a_d_ghig' + Example 2: + Input: s:'abc_def', groups_length: 2 + Output: 'a_def' + """ + groups = s.split("_") + groups[:-1] = [c[0:groups_length] for c in groups[:-1]] + return "_".join(groups) + + def optimize_symbols(self, keyword: str) -> None: + for i in range(1, len(keyword)): + substr = keyword[0:i] + self.shortcut[substr] = 0 if substr in self.shortcut else keyword + + if "_" not in keyword: + return + + for n in (1, 2): + abbreviation = self.make_abbreviation(keyword, n) + + if keyword == abbreviation: + continue + + self.abbreviation_dict[abbreviation].append(keyword) + + for i in range(abbreviation.find("_") + 1, len(abbreviation)): + sub = abbreviation[0:i] + self.shortcut[sub] = 0 if sub in self.shortcut else keyword + + def rebuild(self, keywords: Optional[Iterable] = None) -> None: + keywords = list(keywords) if keywords is not None else [] + self.keywords = ( + [keyword for keyword in keywords if keyword[:1] != "_"] + if self.filter_leading_underscore + else keywords + ) + # optimize symbols + self.shortcut = {} + self.abbreviation_dict = defaultdict(list) + for keyword in self.keywords: + self.optimize_symbols(keyword) + + self._rebuild_finalize() + + def _rebuild_finalize(self) -> None: + for abbreviation, keywords in self.abbreviation_dict.items(): + if len(keywords) == 1: + self.shortcut[abbreviation] = keywords[0] + for keyword in self.keywords: + self.shortcut[keyword] = keyword + + def interpret( + self, keyword: str, mode: bool = False + ) -> Optional[int | str | list[str]]: + """ + Returns None (no hit), str (one hit) or list (multiple hits) + + keyword = str: query string, setting prefix or shortcut + mode = True/False: if mode=True, do prefix search even if kee has exact match + """ + if keyword == "": + return self.keywords + + result = self.shortcut.get(keyword) + if result is None: + return + if result and not mode: return result -if __name__=='__main__': - sc = Shortcut(['warren','wasteland','electric','well']) - tv = sc.has_key('a') - print(tv==0,tv) - tv = sc.has_key('w') - print(tv==1,tv) - tv = sc.has_key('war') - print(tv==1,tv) - tv = sc.interpret('w') - print(sorted(tv)==['warren', 'wasteland', 'well'],tv) - tv = sc.interpret('e') - print(isinstance(tv, str), tv) + # prefix search + unique_keywords = set( + word for word in self.keywords if word.startswith(keyword) + ) + for abbreviation, keywords in self.abbreviation_dict.items(): + if abbreviation.startswith(keyword): + unique_keywords.update(keywords) + # no match + if not unique_keywords: + return + + # single match: str + # multiple matches: list + return ( + unique_keywords.pop() + if len(unique_keywords) == 1 + else list(unique_keywords) + ) + + def append(self, keyword) -> None: + self.keywords.append(keyword) + self.optimize_symbols(keyword) + self._rebuild_finalize() + + def auto_err( + self, keyword: str, descrip: Optional[str] = None + ) -> Optional[int | str | list[str]]: + if keyword == "": + return + + result = self.interpret(keyword) + + if result is None and descrip is not None: + msg = f"Error: unknown {descrip}: '{keyword}'." + lst = self.interpret("") + if isinstance(lst, list) and len(lst) < 100: + lst.sort() + lst = parsing.list_to_str_list(lst) + msg += " Choices:\n" + "\n".join(lst) + raise parsing.QuietException(msg) + + if isinstance(result, list) and descrip is not None: + lst = parsing.list_to_str_list(result) + options = "\n".join(lst) + msg = f"Error: ambiguous {descrip}\\n {options}" + raise parsing.QuietException(msg) + + return result diff --git a/pyproject.toml b/pyproject.toml index d8629e40a..f1f2dc117 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,8 @@ requires = [ ] [project.optional-dependencies] -test = [ +dev = [ + "numpy>=1.26.4,<2", "pillow==10.3.0", "pytest==8.2.2", ] diff --git a/testing/tests/api/shortcut.py b/testing/tests/api/shortcut.py index c8d4103d2..a1830b501 100644 --- a/testing/tests/api/shortcut.py +++ b/testing/tests/api/shortcut.py @@ -11,7 +11,7 @@ class TestShortcut(testing.PyMOLTestCase): def testShortcut(self): # build shortcut sc = cmd.Shortcut(words) - + # get all keywords self.assertItemsEqual(words, sc.interpret('')) @@ -33,8 +33,8 @@ def testShortcut(self): self.assertItemsEqual(['foo', 'foo_new'], sc.interpret('f')) self.assertEqual('foo', sc.interpret('foo')) self.assertEqual('foo_new', sc.interpret('foo_')) - - self.assertEqual(False, sc.has_key('')) + + self.assertEqual(False, '' in sc) # abbreviations diff --git a/tests/pymol/test_shortcut.py b/tests/pymol/test_shortcut.py new file mode 100644 index 000000000..d7e86aa51 --- /dev/null +++ b/tests/pymol/test_shortcut.py @@ -0,0 +1,123 @@ +import pytest + +from pymol.shortcut import Shortcut + + +@pytest.fixture +def sc() -> Shortcut: + return Shortcut(["foo", "bar", "baz", "com", "com_bla", "com_xxx"]) + + +@pytest.mark.parametrize( + "keyword, expected_result", + [ + ("a", False), + ("w", True), + ("war", True), + ], +) +def test_contains(keyword: str, expected_result: bool): + shortcut = Shortcut(["warren", "wasteland", "electric", "well"]) + assert (keyword in shortcut) is expected_result + + +def test_interpret(): + shortcut = Shortcut(["warren", "wasteland", "electric", "well"]) + list_result = shortcut.interpret("w") + assert list_result is not None + assert not isinstance(list_result, int) + assert sorted(list_result) == ["warren", "wasteland", "well"] + + string_result = shortcut.interpret("e") + assert list_result is not None + assert string_result == "electric" + + +def test_all_keywords(sc: Shortcut): + assert ["foo", "bar", "baz", "com", "com_bla", "com_xxx"] == sc.interpret("") + + +@pytest.mark.parametrize( + "prefixs, expected_result", + [ + (["f", "fo", "foo"], "foo"), + (["b", "ba"], ["bar", "baz"]), + (["bar"], "bar"), + (["c", "co"], ["com", "com_bla", "com_xxx"]), + (["com"], "com"), + ], +) +def test_full_prefix_hits( + sc: Shortcut, prefixs: list[str], expected_result: str | list[str] +): + for prefix in prefixs: + result = sc.interpret(prefix) + result = sorted(result) if isinstance(result, list) else result + assert expected_result == result + + +def test_append(sc: Shortcut): + sc.append("foo_new") + + assert ["foo", "foo_new"], sc.interpret("f") + assert "foo", sc.interpret("foo") + assert "foo_new", sc.interpret("foo_") + + assert "" not in sc + + +def test_abbreviations(sc: Shortcut): + sc.append("foo_new") + + assert "foo_new" == sc.interpret("f_") + assert "foo_new" == sc.interpret("f_new") + assert "foo_new" == sc.interpret("fo_") + assert "com_xxx" == sc.interpret("c_x") + assert "com_xxx" == sc.interpret("c_xxx") + assert "com_xxx" == sc.interpret("co_x") + + +def test_missing_key(sc: Shortcut): + assert None is sc.interpret("missing_key") + + +def test_auto_error(sc: Shortcut): + assert None is sc.auto_err("") + assert None is sc.auto_err("missing_key") + + result = sc.auto_err("co") + assert isinstance(result, list) + assert ["com", "com_bla", "com_xxx"] == sorted(result) + assert "com", sc.auto_err("com") + + +def test_interpret_mode_true(sc: Shortcut): + assert "foo" == sc.interpret("f", True) + + result = sc.interpret("com", True) + assert isinstance(result, list) + assert ["com", "com_bla", "com_xxx"] == sorted(result) + + sc.append("foo_new") + result = sc.interpret("foo", True) + assert isinstance(result, list) + assert ["foo", "foo_new"] == sorted(result) + + +def test_rebuild(sc: Shortcut): + coms = ["com", "com_bla", "com_xxx"] + sc.rebuild(coms) + + assert None is sc.interpret("f") + assert None is sc.interpret("foo") + + result = sc.interpret("c") + assert isinstance(result, list) + assert coms == sorted(result) + + result = sc.interpret("com", True) + assert isinstance(result, list) + assert coms == sorted(result) + + assert "com" == sc.interpret("com") + assert "com_xxx" == sc.interpret("c_x")