diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b5462115b..2abbf922b 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,7 +43,7 @@ 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 @@ -74,7 +72,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,7 +92,7 @@ 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 @@ -117,7 +115,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,7 +129,7 @@ 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: |- 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/commanding.py b/modules/pymol/commanding.py index 470994f33..27d724e05 100644 --- a/modules/pymol/commanding.py +++ b/modules/pymol/commanding.py @@ -12,6 +12,8 @@ #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut + if True: import sys @@ -39,7 +41,7 @@ cmd = sys.modules["pymol.cmd"] import pymol - from .cmd import _cmd, Shortcut, QuietException, \ + from .cmd import _cmd, QuietException, \ fb_module, fb_mask, is_list, \ DEFAULT_ERROR, DEFAULT_SUCCESS, is_ok, is_error, is_string diff --git a/modules/pymol/completing.py b/modules/pymol/completing.py index e9280582c..169006032 100644 --- a/modules/pymol/completing.py +++ b/modules/pymol/completing.py @@ -1,18 +1,23 @@ +from typing import Optional + +from pymol.shortcut import Shortcut + cmd = __import__("sys").modules["pymol.cmd"] -class ExprShortcut(cmd.Shortcut): +class ExprShortcut(Shortcut): ''' Expression shortcut for iterate/alter/label with "s." prefix setting autocompletion. ''' - def interpret(self, kee, mode=0): - if not kee.startswith('s.'): - return cmd.Shortcut.interpret(self, kee, mode) - v = cmd.setting.setting_sc.interpret(kee[2:]) - if isinstance(v, str): - return 's.' + v + def interpret(self, keyword: str, mode: bool = False): + if not keyword.startswith('s.'): + return super().interpret(keyword, mode) + v: Optional[int | str | list[str]] = cmd.setting.setting_sc.interpret(keyword[2:]) + + if isinstance(v, str) or isinstance(v, int): + return f"s.{v}" if isinstance(v, list): - return ['s.' + v for v in v] + return [f"s.{v}" for v in v] return None expr_sc = ExprShortcut([ @@ -32,7 +37,7 @@ def interpret(self, kee, mode=0): def fragments_sc(): import os import chempy - return cmd.Shortcut([ + return Shortcut([ f[:-4] for f in os.listdir(chempy.path + 'fragments') if f.endswith('.pkl') ]) @@ -40,9 +45,9 @@ def fragments_sc(): def vol_ramp_sc(): from . import colorramping - return cmd.Shortcut(colorramping.namedramps) + return Shortcut(colorramping.namedramps) -names_sc = lambda: cmd.Shortcut(cmd.get_names('public')) +names_sc = lambda: Shortcut(cmd.get_names('public')) aa_nam_e = [ names_sc , 'name' , '' ] aa_nam_s = [ names_sc , 'name' , ' ' ] @@ -58,24 +63,24 @@ def vol_ramp_sc(): aa_rep_c = [ cmd.repres_sc , 'representation' , ', ' ] aa_rem_c = [ cmd.repmasks_sc , 'representation' , ', ' ] aa_v_r_c = [ vol_ramp_sc , 'volume ramp' , ', ' ] -aa_ali_e = [ cmd.Shortcut(['align', 'super', 'cealign']), 'alignment method', ''] +aa_ali_e = [ Shortcut(['align', 'super', 'cealign']), 'alignment method', ''] def wizard_sc(): import os, pymol.wizard names_glob = [name[:-3] for p in pymol.wizard.__path__ for name in os.listdir(p) if name.endswith('.py')] - return cmd.Shortcut(names_glob) + return Shortcut(names_glob) def get_auto_arg_list(self_cmd=cmd): self_cmd = self_cmd._weakrefproxy aa_vol_c = [ lambda: - cmd.Shortcut(self_cmd.get_names_of_type('object:volume')), + Shortcut(self_cmd.get_names_of_type('object:volume')), 'volume', '' ] aa_ramp_c = [ lambda: - cmd.Shortcut(self_cmd.get_names_of_type('object:ramp')), + Shortcut(self_cmd.get_names_of_type('object:ramp')), 'ramp', '' ] - aa_scene_e = [lambda: cmd.Shortcut(cmd.get_scene_list()), 'scene', ''] + aa_scene_e = [lambda: Shortcut(cmd.get_scene_list()), 'scene', ''] return [ # 1st @@ -168,7 +173,7 @@ def get_auto_arg_list(self_cmd=cmd): 'sculpt_iterate' : aa_obj_c, 'set' : aa_set_c, 'set_bond' : aa_set_c, - 'set_key' : [ lambda: cmd.Shortcut(cmd.key_mappings), 'key' , ', ' ], + 'set_key' : [ lambda: Shortcut(cmd.key_mappings), 'key' , ', ' ], 'set_name' : aa_nam_c, 'set_title' : aa_obj_c, 'show' : aa_rem_c, diff --git a/modules/pymol/constants.py b/modules/pymol/constants.py index dc19cdebc..caa70aab4 100644 --- a/modules/pymol/constants.py +++ b/modules/pymol/constants.py @@ -1,8 +1,8 @@ # constant objects +from pymol.shortcut import Shortcut from .parsing import QuietException -from .shortcut import Shortcut from .constants_palette import palette_dict import re diff --git a/modules/pymol/controlling.py b/modules/pymol/controlling.py index 74929e149..9de1de7ca 100644 --- a/modules/pymol/controlling.py +++ b/modules/pymol/controlling.py @@ -12,10 +12,11 @@ #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut + if True: try: from . import selector, internal - from .shortcut import Shortcut cmd = __import__("sys").modules["pymol.cmd"] from .cmd import _cmd, QuietException, is_string, \ boolean_dict, boolean_sc, \ @@ -23,7 +24,6 @@ location_code, location_sc import pymol except: - from shortcut import Shortcut cmd = None diff --git a/modules/pymol/creating.py b/modules/pymol/creating.py index ac9c7fbc9..6ea15cc45 100644 --- a/modules/pymol/creating.py +++ b/modules/pymol/creating.py @@ -12,6 +12,7 @@ #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut from .constants import CURRENT_STATE, ALL_STATES if True: @@ -23,7 +24,7 @@ import re import gzip import os - from .cmd import _cmd, Shortcut, is_list, is_string, \ + from .cmd import _cmd, is_list, is_string, \ safe_list_eval, safe_alpha_list_eval, \ DEFAULT_ERROR, DEFAULT_SUCCESS, is_ok, is_error, \ is_tuple diff --git a/modules/pymol/editing.py b/modules/pymol/editing.py index 4f288b237..420073fa6 100644 --- a/modules/pymol/editing.py +++ b/modules/pymol/editing.py @@ -13,6 +13,7 @@ #Z* ------------------------------------------------------------------- import pymol +from pymol.shortcut import Shortcut from .constants import CURRENT_STATE, ALL_STATES @@ -67,7 +68,7 @@ def _iterate_prepare_args(expression, space, _self): import math from . import selector cmd = __import__("sys").modules["pymol.cmd"] - from .cmd import _cmd,lock,unlock,Shortcut,is_string, \ + from .cmd import _cmd,lock,unlock,is_string, \ boolean_sc,boolean_dict,safe_list_eval, is_sequence, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error from chempy import cpv diff --git a/modules/pymol/experimenting.py b/modules/pymol/experimenting.py index 5dda05c17..e91c1abed 100644 --- a/modules/pymol/experimenting.py +++ b/modules/pymol/experimenting.py @@ -15,7 +15,7 @@ if True: from . import selector - from .cmd import _cmd,lock,unlock,Shortcut,QuietException, \ + from .cmd import _cmd,lock,unlock,QuietException, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error cmd = __import__("sys").modules["pymol.cmd"] import threading diff --git a/modules/pymol/exporting.py b/modules/pymol/exporting.py index 132a8407a..e6cd9f18c 100644 --- a/modules/pymol/exporting.py +++ b/modules/pymol/exporting.py @@ -12,6 +12,7 @@ #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut from . import colorprinting if True: @@ -24,7 +25,7 @@ import pymol cmd = sys.modules["pymol.cmd"] - from .cmd import _cmd,Shortcut,QuietException + from .cmd import _cmd,QuietException from chempy import io from chempy.pkl import cPickle from .cmd import _feedback,fb_module,fb_mask, \ diff --git a/modules/pymol/externing.py b/modules/pymol/externing.py index 7f43cf792..20a6169bf 100644 --- a/modules/pymol/externing.py +++ b/modules/pymol/externing.py @@ -23,7 +23,7 @@ import traceback from glob import glob - from .cmd import _cmd,lock,unlock,Shortcut,QuietException, \ + from .cmd import _cmd,lock,unlock,QuietException, \ _feedback,fb_module,fb_mask, exp_path, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error diff --git a/modules/pymol/feedingback.py b/modules/pymol/feedingback.py index 3e4d0bcb8..56e6b8266 100644 --- a/modules/pymol/feedingback.py +++ b/modules/pymol/feedingback.py @@ -1,6 +1,7 @@ import sys +from pymol.shortcut import Shortcut cmd = __import__("sys").modules["pymol.cmd"] -from .cmd import Shortcut, is_string, QuietException +from .cmd import is_string, QuietException from .cmd import fb_module, fb_mask, fb_action,_raising import copy diff --git a/modules/pymol/fitting.py b/modules/pymol/fitting.py index 7097b2080..213b15d42 100644 --- a/modules/pymol/fitting.py +++ b/modules/pymol/fitting.py @@ -20,7 +20,7 @@ import os import pymol - from .cmd import _cmd,lock,unlock,Shortcut, \ + from .cmd import _cmd,lock,unlock, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error diff --git a/modules/pymol/internal.py b/modules/pymol/internal.py index 087ed03fa..0e5c5fb79 100644 --- a/modules/pymol/internal.py +++ b/modules/pymol/internal.py @@ -1,6 +1,7 @@ import os import sys cmd = sys.modules["pymol.cmd"] +from pymol.shortcut import Shortcut from pymol import _cmd import threading import traceback @@ -14,7 +15,7 @@ import chempy.io -from .cmd import DEFAULT_ERROR, DEFAULT_SUCCESS, loadable, _load2str, Shortcut, \ +from .cmd import DEFAULT_ERROR, DEFAULT_SUCCESS, loadable, _load2str, \ is_string, is_ok # cache management: diff --git a/modules/pymol/movie.py b/modules/pymol/movie.py index 1c9bdf43b..3bde747fd 100644 --- a/modules/pymol/movie.py +++ b/modules/pymol/movie.py @@ -21,6 +21,7 @@ import threading import time from . import colorprinting +from pymol.shortcut import Shortcut def get_movie_fps(_self): r = _self.get_setting_float('movie_fps') @@ -817,7 +818,7 @@ def _encode(filename,first,last,preserve, 'ray' : 2, } -produce_mode_sc = cmd.Shortcut(produce_mode_dict.keys()) +produce_mode_sc = Shortcut(produce_mode_dict.keys()) def find_exe(exe): diff --git a/modules/pymol/moving.py b/modules/pymol/moving.py index 8c489c9e3..67c03eec2 100644 --- a/modules/pymol/moving.py +++ b/modules/pymol/moving.py @@ -11,6 +11,7 @@ #-* #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut if True: @@ -21,7 +22,7 @@ import pymol import re cmd = sys.modules["pymol.cmd"] - from .cmd import _cmd,Shortcut, \ + from .cmd import _cmd, \ toggle_dict,toggle_sc, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error diff --git a/modules/pymol/plugins/__init__.py b/modules/pymol/plugins/__init__.py index 91772d764..23c236431 100644 --- a/modules/pymol/plugins/__init__.py +++ b/modules/pymol/plugins/__init__.py @@ -11,6 +11,7 @@ import pymol from pymol import cmd from pymol import colorprinting +from pymol.shortcut import Shortcut from .legacysupport import * # variables @@ -434,6 +435,6 @@ def initialize(pmgapp=-1): cmd.extend('plugin_pref_save', pref_save) # autocompletion -cmd.auto_arg[0]['plugin_load'] = [ lambda: cmd.Shortcut(plugins), 'plugin', '' ] +cmd.auto_arg[0]['plugin_load'] = [ lambda: Shortcut(plugins), 'plugin', '' ] # vi:expandtab:smarttab:sw=4 diff --git a/modules/pymol/querying.py b/modules/pymol/querying.py index 6750eaaf0..bf8b3eae2 100644 --- a/modules/pymol/querying.py +++ b/modules/pymol/querying.py @@ -20,7 +20,7 @@ from . import selector import pymol cmd = __import__("sys").modules["pymol.cmd"] - from .cmd import _cmd,lock,unlock,Shortcut, \ + from .cmd import _cmd,lock,unlock, \ _feedback,fb_module,fb_mask,is_list, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error diff --git a/modules/pymol/selecting.py b/modules/pymol/selecting.py index 3be7b5f93..c8552aa69 100644 --- a/modules/pymol/selecting.py +++ b/modules/pymol/selecting.py @@ -11,6 +11,7 @@ #-* #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut if True: @@ -18,7 +19,7 @@ cmd = __import__("sys").modules["pymol.cmd"] - from .cmd import _cmd,Shortcut, \ + from .cmd import _cmd, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error import pymol diff --git a/modules/pymol/setting.py b/modules/pymol/setting.py index 21ebba646..f6c36cc61 100644 --- a/modules/pymol/setting.py +++ b/modules/pymol/setting.py @@ -13,6 +13,9 @@ #Z* ------------------------------------------------------------------- # must match layer1/Setting.h +from pymol.shortcut import Shortcut + + cSetting_tuple = -1 cSetting_blank = 0 cSetting_boolean = 1 @@ -26,7 +29,6 @@ import traceback from . import selector - from .shortcut import Shortcut cmd = __import__("sys").modules["pymol.cmd"] from .cmd import _cmd,lock,lock_attempt,unlock,QuietException, \ is_string, \ diff --git a/modules/pymol/shortcut.py b/modules/pymol/shortcut.py index ed654b79b..f97e3272f 100644 --- a/modules/pymol/shortcut.py +++ b/modules/pymol/shortcut.py @@ -1,184 +1,203 @@ -#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: + """ + Creates an abbreviation for a string by shortening its components. + The abbreviation takes the first `groups_length` + characters of each part before the last component. + + Example 1: + Input: s:'abc_def_ghig', groups_length: 1 + Output: 'a_d_ghig' + + Example 2: + Input: s:'abc_def', groups_length: 2 + Output: 'ab_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: + """ + Optimizes the given keyword by adding abbreviations and shortening + components. This method also builds a shortcut dictionary + for the keyword and its abbreviated forms. + """ + 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[str]] = None) -> None: + """ + Rebuilds the shortcuts and abbreviation dictionaries + based on the provided list of keywords. + This method clears the existing shortcuts and optimizes symbols + for the new list of keywords. + """ + 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: + """ + Finalizes the rebuild process + by setting shortcuts for abbreviations and keywords. + + This method ensures that each abbreviation points to a single keyword and that + each keyword has a shortcut. + """ + 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: str) -> None: + """Adds a new keyword to the list and rebuilds the shortcuts.""" + + 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]]: + """ + Automatically raises an error if a keyword is unknown or ambiguous. + + This method checks if a keyword is valid, and if not, + raises a descriptive error with suggestions for possible matches. + """ + + 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/modules/pymol/viewing.py b/modules/pymol/viewing.py index a65970949..ba2f7dbc2 100644 --- a/modules/pymol/viewing.py +++ b/modules/pymol/viewing.py @@ -12,6 +12,7 @@ #-* #Z* ------------------------------------------------------------------- +from pymol.shortcut import Shortcut from . import colorprinting if True: @@ -25,7 +26,7 @@ import re cmd = sys.modules["pymol.cmd"] - from .cmd import _cmd, Shortcut, \ + from .cmd import _cmd, \ _feedback,fb_module,fb_mask, \ repres,repres_sc, is_string, is_list, \ repmasks,repmasks_sc, \ diff --git a/modules/pymol/wizarding.py b/modules/pymol/wizarding.py index bf707d48c..48512575e 100644 --- a/modules/pymol/wizarding.py +++ b/modules/pymol/wizarding.py @@ -17,7 +17,7 @@ import pymol import sys cmd = __import__("sys").modules["pymol.cmd"] - from .cmd import _cmd,lock,unlock,Shortcut,QuietException,_raising, \ + from .cmd import _cmd,lock,unlock,QuietException,_raising, \ _feedback,fb_module,fb_mask, \ DEFAULT_ERROR, DEFAULT_SUCCESS, _raising, is_ok, is_error diff --git a/pyproject.toml b/pyproject.toml index d8629e40a..fc0cfab43 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ requires = [ ] [project.optional-dependencies] -test = [ +dev = [ "pillow==10.3.0", "pytest==8.2.2", ] diff --git a/testing/tests/api/shortcut.py b/testing/tests/api/shortcut.py deleted file mode 100644 index c8d4103d2..000000000 --- a/testing/tests/api/shortcut.py +++ /dev/null @@ -1,80 +0,0 @@ -import pymol -from pymol import cmd, testing, stored - -foos = ['foo'] -ba_s = ['bar', 'baz'] -coms = ['com', 'com_bla', 'com_xxx'] -words = foos + ba_s + coms - -class TestShortcut(testing.PyMOLTestCase): - - def testShortcut(self): - # build shortcut - sc = cmd.Shortcut(words) - - # get all keywords - self.assertItemsEqual(words, sc.interpret('')) - - # full/prefix hits - self.assertEqual('foo', sc.interpret('f')) - self.assertEqual('foo', sc.interpret('fo')) - self.assertEqual('foo', sc.interpret('foo')) - - self.assertItemsEqual(ba_s, sc.interpret('b')) - self.assertItemsEqual(ba_s, sc.interpret('ba')) - self.assertEqual('bar', sc.interpret('bar')) - - self.assertItemsEqual(coms, sc.interpret('c')) - self.assertItemsEqual(coms, sc.interpret('co')) - self.assertEqual('com', sc.interpret('com')) - - # add one - sc.append('foo_new') - 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('')) - - - # abbreviations - self.assertEqual('foo_new', sc.interpret('f_')) - self.assertEqual('foo_new', sc.interpret('f_new')) - self.assertEqual('foo_new', sc.interpret('fo_')) - self.assertEqual('com_xxx', sc.interpret('c_x')) - self.assertEqual('com_xxx', sc.interpret('c_xxx')) - self.assertEqual('com_xxx', sc.interpret('co_x')) - - # missing key - self.assertEqual(None, sc.interpret('missing_key')) - - # auto error - self.assertEqual(None, sc.auto_err('')) - self.assertEqual(None, sc.auto_err('missing_key')) - self.assertItemsEqual(coms, sc.auto_err('co')) - self.assertEqual('com', sc.auto_err('com')) - - def testShortcutMode1(self): - # build shortcut - sc = cmd.Shortcut(words) - - # full/prefix hits - self.assertEqual('foo', sc.interpret('f', 1)) - self.assertItemsEqual(coms, sc.interpret('com', 1)) - - # add one - sc.append('foo_new') - self.assertItemsEqual(['foo', 'foo_new'], sc.interpret('foo', 1)) - - def testShortcutRebuild(self): - sc = cmd.Shortcut(words) - sc.rebuild(coms) - - self.assertEqual(None, sc.interpret('f')) - self.assertEqual(None, sc.interpret('foo')) - - self.assertItemsEqual(coms, sc.interpret('c')) - self.assertItemsEqual(coms, sc.interpret('com', 1)) - self.assertEqual('com', sc.interpret('com')) - self.assertEqual('com_xxx', sc.interpret('c_x')) - diff --git a/testing/tests/api/test_shortcut.py b/testing/tests/api/test_shortcut.py new file mode 100644 index 000000000..d7e86aa51 --- /dev/null +++ b/testing/tests/api/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")