diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index a4ed28f58..ec279f273 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -25,13 +25,15 @@ jobs: sudo apt-get update -q && sudo apt-get install --no-install-recommends -y xvfb python3-dev python3-gi python3-gi-cairo gir1.2-gtk-3.0 libgirepository1.0-dev - libcairo2-dev enchant + gir1.2-poppler-0.18 libcairo2-dev enchant intltool - name: Install dependencies run: | sudo python3 -m pip install --upgrade pip sudo pip3 install flake8 nose mypy sudo pip3 install --upgrade keyrings.alt if [ -f requirements.txt ]; then pip3 install -r requirements.txt; fi + - name: Prepare plugins + run: python3 setup.py build_i18n - name: Test with nose run: | xvfb-run -a nosetests -v gourmet/tests/test_* diff --git a/gourmet/Undo.py b/gourmet/Undo.py index 9a94abfec..287b54a06 100644 --- a/gourmet/Undo.py +++ b/gourmet/Undo.py @@ -86,7 +86,7 @@ def __repr__ (self): class UndoableTextChange (UndoableObject): def __init__ (self, set_text_action, history, initial_text="",text="",txt_id=None,is_undo=False): self.txt_id = txt_id - self.blob_matcher = re.compile('\s+\S+\s+') + self.blob_matcher = re.compile(r'\s+\S+\s+') self.initial_text = initial_text if initial_text is not None else "" self.text = text self._set_text = set_text_action diff --git a/gourmet/backends/db.py b/gourmet/backends/db.py index 56fc46f21..c678bc3db 100644 --- a/gourmet/backends/db.py +++ b/gourmet/backends/db.py @@ -114,7 +114,15 @@ class DBObject: # categories_table: id -> recipe_id, category_entry_id -> id # ingredients_table: ingredient_id -> id, id -> recipe_id -class RecData (Pluggable, BaseException): +def db_url(file: Optional[str]=None, custom_url: Optional[str]=None) -> str: + if custom_url is not None: + return custom_url + else: + if file is None: + file = os.path.join(gglobals.gourmetdir, 'recipes.db') + return 'sqlite:///' + file + +class RecData (Pluggable): """RecData is our base class for handling database connections. @@ -125,27 +133,29 @@ class RecData (Pluggable, BaseException): AMT_MODE_AVERAGE = 1 AMT_MODE_HIGH = 2 - _singleton = {} + _instance_by_db_url = {} + + @classmethod + def instance_for( + cls, file: Optional[str]=None, custom_url: Optional[str]=None + ) -> 'RecData': + url = db_url(file, custom_url) + + if url not in cls._instance_by_db_url: + cls._instance_by_db_url[url] = cls(file, url) - def __init__ (self, file=os.path.join(gglobals.gourmetdir,'recipes.db'), - custom_url=None): + return cls._instance_by_db_url[url] + + def __init__ (self, file: str, url: str): # hooks run after adding, modifying or deleting a recipe. # Each hook is handed the recipe, except for delete_hooks, # which is handed the ID (since the recipe has been deleted) - if file in RecData._singleton: - raise RecData._singleton[file] - else: - RecData._singleton[file] = self # We keep track of IDs we've handed out with new_id() in order # to prevent collisions self.new_ids = [] self._created = False - if custom_url: - self.url = custom_url - self.filename = None - else: - self.filename = file - self.url = 'sqlite:///' + self.filename + self.filename = file + self.url = url self.add_hooks = [] self.modify_hooks = [] self.delete_hooks = [] @@ -616,7 +626,7 @@ def update_version_info (self, version_string): blob = getattr(r,src) url = None if blob: - m = re.search('\w+://[^ ]*',blob) + m = re.search(r'\w+://[^ ]*',blob) if m: rec_url = blob[m.start():m.end()] if rec_url[-1] in ['.',')',',',';',':']: @@ -1843,14 +1853,37 @@ def get_default_values (self, colname): return [] -class RecipeManager (RecData): +class RecipeManager: + _instance_by_db_url = {} - def __init__ (self,*args,**kwargs): + @classmethod + def instance_for( + cls, file: Optional[str]=None, custom_url: Optional[str]=None + ) -> 'RecipeManager': + url = db_url(file, custom_url) + + if url not in cls._instance_by_db_url: + cls._instance_by_db_url[url] = cls(file, custom_url) + + return cls._instance_by_db_url[url] + + def __init__ (self, *args, **kwargs): debug('recipeManager.__init__()',3) - RecData.__init__(self,*args,**kwargs) + self.rd = get_database(*args, **kwargs) #self.km = keymanager.KeyManager(rm=self) self.km = keymanager.get_keymanager(rm=self) + def __getattr__(self, name): + # RecipeManager was previously a subclass of RecData. + # This was changed as they're both used as singletons, and there's + # no good way to have a subclassed singleton (unless the parent class + # is an abstract thing that's never used directly, which it wasn't). + # However, lots of code uses RecData methods on RecipeManager objects. + # This ensures that that code keeps working. + if name.startswith('_'): + raise AttributeError(name) + return getattr(self.rd, name) + def key_search (self, ing): """Handed a string, we search for keys that could match the ingredient.""" @@ -1882,7 +1915,7 @@ def parse_ingredient (self, s, conv=None, get_key=True): s = s.decode('utf8') s = s.strip( '\u2022\u2023\u2043\u204C\u204D\u2219\u25C9\u25D8\u25E6\u2619\u2765\u2767\u29BE\u29BF\n\t #*+-') - option_m = re.match('\s*optional:?\s*',s,re.IGNORECASE) + option_m = re.match(r'\s*optional:?\s*',s,re.IGNORECASE) if option_m: s = s[option_m.end():] d['optional']=True @@ -1907,14 +1940,14 @@ def parse_ingredient (self, s, conv=None, get_key=True): d['unit']=u.strip() else: # has this unit been used - prev_uses = self.fetch_all(self.ingredients_table,unit=u.strip()) + prev_uses = self.rd.fetch_all(self.rd.ingredients_table,unit=u.strip()) if prev_uses: d['unit']=u else: # otherwise, unit is not a unit i = u + ' ' + i if i: - optmatch = re.search('\s+\(?[Oo]ptional\)?',i) + optmatch = re.search(r'\s+\(?[Oo]ptional\)?',i) if optmatch: d['optional']=True i = i[0:optmatch.start()] + i[optmatch.end():] @@ -1931,10 +1964,25 @@ def parse_ingredient (self, s, conv=None, get_key=True): def ing_search (self, ing, keyed=None, recipe_table=None, use_regexp=True, exact=False): """Search for an ingredient.""" - if not recipe_table: recipe_table = self.recipe_table - vw = self.joined_search(recipe_table,self.ingredients_table,'ingkey',ing,use_regexp=use_regexp,exact=exact) + if not recipe_table: + recipe_table = self.rd.recipe_table + vw = self.joined_search( + recipe_table, + self.rd.ingredients_table, + search_by='ingkey', + search_str=ing, + use_regexp=use_regexp, + exact=exact + ) if not keyed: - vw2 = self.joined_search(recipe_table,self.ingredients_table,'item',ing,use_regexp=use_regexp,exact=exact) + vw2 = self.joined_search( + recipe_table, + self.rd.ingredients_table, + search_by='item', + search_str=ing, + use_regexp=use_regexp, + exact=exact + ) if vw2 and vw: vw = vw.union(vw2) else: vw = vw2 @@ -1956,14 +2004,14 @@ def clear_remembered_optional_ings (self, recipe=None): Otherwise, we clear *all* recipes. """ if recipe: - vw = self.get_ings(recipe) + vw = self.rd.get_ings(recipe) else: - vw = self.ingredients_table + vw = self.rd.ingredients_table # this is ugly... vw1 = vw.select(shopoptional=1) vw2 = vw.select(shopoptional=2) for v in vw1,vw2: - for i in v: self.modify_ing(i,{'shopoptional':0}) + for i in v: self.rd.modify_ing(i,{'shopoptional':0}) class DatabaseConverter(convert.Converter): def __init__ (self, db): @@ -2098,11 +2146,5 @@ def items (self): # fetch_all -> #recipe_table -> recipe_table -def get_database (*args,**kwargs): - try: - return RecData(*args,**kwargs) - except RecData as rd: - return rd - -if __name__ == '__main__': - db = RecData() +def get_database (*args, **kwargs): + return RecData.instance_for(*args, **kwargs) diff --git a/gourmet/check_encodings.py b/gourmet/check_encodings.py index 8c7a33ae6..f3ea558a7 100644 --- a/gourmet/check_encodings.py +++ b/gourmet/check_encodings.py @@ -51,14 +51,15 @@ def get_encodings (self): def test_all_encodings (self,encodings=None): """Test all encodings and return a dictionary of possible encodings.""" - if not encodings: encodings=self.all_encodings + if not encodings: + encodings=self.all_encodings self.possible_encodings = {} for e in encodings: try: d=self.txt.decode(e) - if d and (not d in list(self.possible_encodings.values())): + if d and (d not in self.possible_encodings.values()): # if we don't already have this possibility, add - self.possible_encodings[e]=d.encode('utf8') + self.possible_encodings[e] = d except UnicodeDecodeError: pass return self.possible_encodings @@ -76,7 +77,6 @@ def __init__ (self,file,encodings=None): self.enc = encoding self.lines = encs[self.enc].splitlines() debug('reading file %s as encoding %s'%(file, self.enc)) - self.lines = [l.encode() for l in self.lines] else: raise Exception("Cannot decode file %s" % file) @@ -106,7 +106,7 @@ def __init__ (self, default=None, label=_("Select encoding"), de.OptionDialog.__init__(self, default=default,label=label, sublabel=sublabel, options=self.options, expander=expander) self.set_default_size(700,500) - self.optionMenu.connect('activate',self.change_encoding) + self.combobox.connect('changed',self.change_encoding) self.change_encoding() self.created = False self.expander.set_expanded(True) @@ -154,7 +154,7 @@ def setup_buffers (self): self.line_highlight_tags = [self.encoding_buffers[k].create_tag(background='green')] self.set_buffer_text(self.encoding_buffers[k],t) - def change_encoding (self): + def change_encoding (self, _widget=None): if self.cursor_already_set: im=self.buffer.get_insert() ti=self.buffer.get_iter_at_mark(im) diff --git a/gourmet/convert.py b/gourmet/convert.py index d997c09c5..098f2b10b 100644 --- a/gourmet/convert.py +++ b/gourmet/convert.py @@ -1,10 +1,12 @@ import re, locale, math import collections.abc +from typing import Optional from .defaults.defaults import lang as defaults from gettext import gettext as _ from gettext import ngettext from .gdebug import debug +# TODO: these should be turned into Enums FRACTIONS_ALL = 1 FRACTIONS_NORMAL = 0 FRACTIONS_ASCII = -1 @@ -540,7 +542,7 @@ def timestring_to_seconds (self, timestring): # Note the following will be true # 1:30 = 1 1/2 hours # 00:00:20 = 20 seconds - if re.match('^\d\d?:\d\d(:\d\d)?$',timestring): + if re.match(r'^\d\d?:\d\d(:\d\d)?$',timestring): times = [locale.atof(s) for s in timestring.split(':')] if len(times) == 3: h,m,s = times @@ -567,7 +569,7 @@ def timestring_to_seconds_old (self, timestring): This logic may be a little fragile for non-English languages. """ - words = re.split('[ \s,;]+',str(timestring)) + words = re.split(r'[ \s,;]+',str(timestring)) seconds = 0 num = [] for n,w in enumerate(words): @@ -598,13 +600,14 @@ def get_converter (): 'seconds':lambda seconds: ngettext("second","seconds",seconds), } -def seconds_to_timestring (time, round_at=None, fractions=FRACTIONS_NORMAL): - time = int(time) +def seconds_to_timestring(time: int, + round_at: Optional[int] = None, + fractions: int = FRACTIONS_NORMAL): time_strings = [] units = list(Converter.unit_to_seconds.items()) - units.sort(key=lambda x: x[1]) #old cmp-func: lambda a,b: a[1]b[1] and -1 or 0 + units.sort(key=lambda x: x[1], reverse=True) for unit,divisor in units: - time_covered = time / int(divisor) + time_covered = time // int(divisor) # special case hours, which we English speakers anyway are # used to hearing in 1/2s -- i.e. 1/2 hour is better than 30 # minutes. @@ -678,9 +681,9 @@ def integerp (num, approx=0.01): lambda x,y: ((len(y)>len(x) and 1) or (len(x)>len(y) and -1) or 0) )) -NUMBER_WORD_REGEXP = '|'.join(all_number_words).replace(' ','\s+') +NUMBER_WORD_REGEXP = '|'.join(all_number_words).replace(' ',r'\s+') FRACTION_WORD_REGEXP = '|'.join([n for n in all_number_words if NUMBER_WORDS[n]<1.0] - ).replace(' ','\s+') + ).replace(' ',r'\s+') NORMAL_FRACTIONS = [(1,2),(1,4),(3,4)] @@ -745,7 +748,7 @@ def integerp (num, approx=0.01): for k,v in list(d.items()): UNICODE_INTEGERS[v]=k -NUMBER_REGEXP = "[\d" +NUMBER_REGEXP = r"[\d" #for k in UNICODE_INTEGERS.keys(): NUMBER_REGEXP+=k # COVERED by re.UNICODE for k in list(UNICODE_FRACTIONS.keys()): NUMBER_REGEXP+=k NUMBER_START_REGEXP = NUMBER_REGEXP + ']' @@ -772,7 +775,7 @@ def integerp (num, approx=0.01): FRACTION_REGEXP = "(" + UNICODE_FRACTION_REGEXP + "|" + DIVIDEND_REGEXP + \ SLASH_REGEXP + DIVISOR_REGEXP + ")" -AND_REGEXP = "(\s+%s\s+|\s*[&+]\s*|\s+)"%_('and') +AND_REGEXP = r"(\s+%s\s+|\s*[&+]\s*|\s+)"%_('and') # Match a fraction if NUMBER_WORD_REGEXP: @@ -784,17 +787,17 @@ def integerp (num, approx=0.01): ) else: - NUM_AND_FRACTION_REGEXP = "((?P%s)+\s+)?(?P%s)"%(NUMBER_START_REGEXP,FRACTION_REGEXP) + NUM_AND_FRACTION_REGEXP = r"((?P%s)+\s+)?(?P%s)"%(NUMBER_START_REGEXP,FRACTION_REGEXP) FRACTION_MATCHER = re.compile(NUM_AND_FRACTION_REGEXP,re.UNICODE) -NUMBER_FINDER_REGEXP = "(%(NUM_AND_FRACTION_REGEXP)s|%(NUMBER_NO_RANGE_REGEXP)s)(?=($| |[\s]))"%locals() +NUMBER_FINDER_REGEXP = r"(%(NUM_AND_FRACTION_REGEXP)s|%(NUMBER_NO_RANGE_REGEXP)s)(?=($| |[\s]))"%locals() NUMBER_FINDER = re.compile(NUMBER_FINDER_REGEXP,re.UNICODE) # Note: the order matters on this range regular expression in order # for it to properly split things like 1 - to - 3, which really do # show up sometimes. -RANGE_REGEXP = '([ -]*%s[ -]*|\s*-\s*)'%_('to') # for 'to' used in a range, as in 3-4 +RANGE_REGEXP = r'([ -]*%s[ -]*|\s*-\s*)'%_('to') # for 'to' used in a range, as in 3-4 RANGE_MATCHER = re.compile(RANGE_REGEXP[1:-1]) # no parens for this one @@ -821,7 +824,7 @@ def integerp (num, approx=0.01): NUMBER_FINDER_REGEXP2 = NUMBER_FINDER_REGEXP.replace('int','int2').replace('frac','frac2') try: - ING_MATCHER_REGEXP = """ + ING_MATCHER_REGEXP = r""" \s* # opening whitespace (?P %(NUMBER_FINDER_REGEXP)s # a number diff --git a/gourmet/exporters/exportManager.py b/gourmet/exporters/exportManager.py index 3ad37ff46..d293d161d 100644 --- a/gourmet/exporters/exportManager.py +++ b/gourmet/exporters/exportManager.py @@ -1,15 +1,18 @@ +import os.path + +from gettext import gettext as _ +from gi.repository.GLib import get_user_special_dir, UserDirectory +from gi.repository import Gtk + import gourmet.plugin_loader as plugin_loader from gourmet.plugin import ExporterPlugin import gourmet.gtk_extras.dialog_extras as de from gourmet.threadManager import get_thread_manager, get_thread_manager_gui -from gi.repository.GLib import (get_user_special_dir, USER_DIRECTORY_PICTURES, - USER_DIRECTORY_DOCUMENTS) -from gettext import gettext as _ -import os.path EXTRA_PREFS_AUTOMATIC = -1 EXTRA_PREFS_DEFAULT = 0 + class ExportManager (plugin_loader.Pluggable): '''A class to manage exporters. @@ -42,7 +45,7 @@ def offer_single_export (self, rec, prefs, mult=1, parent=None): if default_extension and default_extension[0]=='.': default_extension = default_extension[1:] exp_directory = prefs.get('rec_exp_directory', - get_user_special_dir(USER_DIRECTORY_DOCUMENTS) + get_user_special_dir(UserDirectory.DIRECTORY_DOCUMENTS) ) filename,exp_type = de.saveas_file(_('Save recipe as...'), filename='%s%s%s%s%s'%(exp_directory, @@ -99,7 +102,7 @@ def offer_multiple_export (self, recs, prefs, parent=None, prog=None, self.app.rd.include_linked_recipes(recs) ext = prefs.get('save_recipes_as','%sxml'%os.path.extsep) exp_directory = prefs.get('rec_exp_directory', - get_user_special_dir(USER_DIRECTORY_DOCUMENTS) + get_user_special_dir(UserDirectory.DIRECTORY_DOCUMENTS) ) fn,exp_type=de.saveas_file(_("Export recipes"), filename="%s%s%s%s"%(exp_directory, diff --git a/gourmet/gtk_extras/dialog_extras.py b/gourmet/gtk_extras/dialog_extras.py index 33c439173..98158ed33 100644 --- a/gourmet/gtk_extras/dialog_extras.py +++ b/gourmet/gtk_extras/dialog_extras.py @@ -6,7 +6,7 @@ import xml.sax.saxutils from gettext import gettext as _ from gourmet.gdebug import debug -from gi.repository.GLib import get_user_special_dir, USER_DIRECTORY_PICTURES +from gi.repository.GLib import get_user_special_dir, UserDirectory H_PADDING=12 Y_PADDING=12 @@ -320,35 +320,27 @@ def __init__ (self, default=None, label="Select Option", sublabel=None, options= """Options can be a simple option or can be a tuple or a list where the first item is the label and the second the value""" ModalDialog.__init__(self, okay=True, label=label, sublabel=sublabel, parent=parent, expander=expander, cancel=cancel) - self.menucb = self.get_option - self.optdic={} - self.menu = Gtk.Menu() - # set the default value to the first item - first = options[0] - if isinstance(first, str): - self.ret = first - else: - self.ret = first[1] + + self.combobox = Gtk.ComboBoxText() + self.vbox.pack_start( + self.combobox, expand=False, fill=False, padding=0 + ) + self.option_values = [] for o in options: if isinstance(o, str): - l=o - v=o + label = value = o else: - l=o[0] - v=o[1] - i = Gtk.MenuItem(l) - i.connect('activate',self.menucb) - i.show() - self.optdic[i]=v - self.menu.append(i) - self.optionMenu=Gtk.OptionMenu() - self.vbox.pack_start(self.optionMenu, expand=False, fill=False, padding=0) - self.optionMenu.set_menu(self.menu) - self.optionMenu.show() - self.menu.show() + label, value = o + self.combobox.append_text(label) + self.option_values.append(value) + + # set the default value to the first item + self.ret = self.option_values[0] + self.combobox.connect('changed', self.get_option) + self.combobox.show() def get_option (self, widget): - self.ret=self.optdic[widget] + self.ret = self.option_values[self.combobox.get_active()] #return self.ret def set_value (self, value): @@ -1118,7 +1110,7 @@ def __init__ (self, buttons=None ): FileSelectorDialog.__init__(self, title, filename, filters, action, set_filter, buttons) - pictures_dir = get_user_special_dir(USER_DIRECTORY_PICTURES) + pictures_dir = get_user_special_dir(UserDirectory.DIRECTORY_PICTURES) if not pictures_dir == None: self.fsd.set_current_folder(pictures_dir) diff --git a/gourmet/importers/generic_recipe_parser.py b/gourmet/importers/generic_recipe_parser.py index 01f6c958b..cc1760c55 100644 --- a/gourmet/importers/generic_recipe_parser.py +++ b/gourmet/importers/generic_recipe_parser.py @@ -80,7 +80,7 @@ def (match_object, full_text, attribute): joinable_tags = ['instructions','ingredient','ingredients',None] change_on_join = {'ingredient':'ingredients'} - ing_matcher = re.compile("^\s*\u2022?\u2023?\u2043?\u204C?\u204D?\u2219?\u25C9?\u25D8?\u25E6?\u2619?\u2765?\u2767?\u29BE?\u29BF?\s*(%s\s+\w+.*)"%convert.NUMBER_REGEXP) + ing_matcher = re.compile(r"^\s*\u2022?\u2023?\u2043?\u204C?\u204D?\u2219?\u25C9?\u25D8?\u25E6?\u2619?\u2765?\u2767?\u29BE?\u29BF?\s*(%s\s+\w+.*)"%convert.NUMBER_REGEXP) def __init__ (self): self.title_parsed = False @@ -97,23 +97,23 @@ def make_rules (self): self.ing_matcher, 1], ['servings', - re.compile("serv(ing|e)s?:?\s*%(num)s|%(num)s\s*servings?"%{ + re.compile(r"serv(ing|e)s?:?\s*%(num)s|%(num)s\s*servings?"%{ 'num':convert.NUMBER_REGEXP},re.IGNORECASE), lambda m,txt,attr: (parse_group(m,txt,2,attr) or parse_group(m,txt,3,attr)) ],] for a in self.ATTRIBUTES: - self.rules.append([a,re.compile('\s*%s\s*:\s*(.*)'%a, + self.rules.append([a,re.compile(r'\s*%s\s*:\s*(.*)'%a, re.IGNORECASE), 1]) for name,attr in self.ALIASES: - self.rules.append([attr,re.compile('\s*%s\s*:\s*(.*)'%name, + self.rules.append([attr,re.compile(r'\s*%s\s*:\s*(.*)'%name, re.IGNORECASE), 1]) for ig in self.IGNORE_ON_OWN: self.rules.append([None, - re.compile('^\W*%s\W*$'%ig,re.IGNORECASE), + re.compile(r'^\W*%s\W*$'%ig,re.IGNORECASE), None]) self.rules.append([ # instructions are our generic fallback @@ -125,7 +125,7 @@ def parse_yield (match_obj, full_text, attr): return [(amt.strip(),'yields'),(unit.strip(),'yield_unit')] self.rules = [[ 'yield', - re.compile('%(yield)s(:|s|\s-)\s-*%(num)s\s-*(.*)'%{ + re.compile(r'%(yield)s(:|s|\s-)\s-*%(num)s\s-*(.*)'%{ 'yield':_('yield'), 'num':convert.NUMBER_REGEXP },re.IGNORECASE), diff --git a/gourmet/importers/html_importer.py b/gourmet/importers/html_importer.py index ff02b97fa..3c57f7e2d 100644 --- a/gourmet/importers/html_importer.py +++ b/gourmet/importers/html_importer.py @@ -312,8 +312,8 @@ def __call__ (self, top_tag, strip=True): if strip: self.text = self.text.strip() # No more than two spaces! - self.text = re.sub('\n\t','\n',self.text) - self.text = re.sub('\n\s*\n\s+','\n\n',self.text) + self.text = re.sub(r'\n\t',r'\n',self.text) + self.text = re.sub(r'\n\s*\n\s+',r'\n\n',self.text) try: return str(self.text,errors='ignore') except: diff --git a/gourmet/importers/importer.py b/gourmet/importers/importer.py index 746368b3d..18afb77f4 100644 --- a/gourmet/importers/importer.py +++ b/gourmet/importers/importer.py @@ -165,7 +165,7 @@ def commit_rec (self): timeaction = TimeAction('importer.commit_rec',10) for key in ['cuisine','category','title']: if key in self.rec: - self.rec[key]=str(re.sub('\s+',' ',self.rec[key]).strip()) + self.rec[key]=str(re.sub(r'\s+',' ',self.rec[key]).strip()) # if yields/servings can't be recognized as a number, add them # to the instructions. if 'yields' in self.rec: @@ -270,7 +270,7 @@ def commit_rec (self): def parse_yields (self, str): '''Parse number and field.''' - m = re.match("(?P\w+\s+)?(?P[0-9/. ]+)(?P\s*\w+)?",str) + m = re.match(r"(?P\w+\s+)?(?P[0-9/. ]+)(?P\s*\w+)?",str) if m: num = m.group('num') num = convert.frac_to_float(num) @@ -323,7 +323,7 @@ def finish_ing (self): # Strip whitespace... for key in ['item','ingkey','unit']: if key in self.ing: - self.ing[key]=re.sub('\s+',' ',self.ing[key]).strip() + self.ing[key]=re.sub(r'\s+',' ',self.ing[key]).strip() if not ( ('refid' in self.ing and self.ing['refid']) @@ -419,7 +419,7 @@ def resume (self): NUMBER_REGEXP = convert.NUMBER_REGEXP simple_matcher = re.compile( - '(%(NUMBER_REGEXP)s+)\s*/\s*([\d]+)'%locals() + r'(%(NUMBER_REGEXP)s+)\s*/\s*([\d]+)'%locals() ) def parse_range (number_string): @@ -455,19 +455,23 @@ def test (self, filename): self.matcher = re.compile(self.regexp) CLOSE=False if isinstance(filename, str): - self.ofi = open(filename,'r') + # Latin-1 can decode any bytes, letting us open ASCII-compatible + # text files and sniff their contents - e.g. for XML tags - + # without worrying too much about their real text encoding. + ofi = open(filename, 'r', encoding='latin1') CLOSE=True - else: self.ofi=filename - l = self.ofi.readline() - while l: - if self.matcher.match(l): - self.ofi.close() - return True - l = self.ofi.readline() - if CLOSE: - self.ofi.close() else: - self.ofi.seek(0) + ofi = filename + + try: + for l in ofi: + if self.matcher.match(l): + return True + finally: + if CLOSE: + ofi.close() + else: + ofi.seek(0) class RatingConverter: diff --git a/gourmet/importers/interactive_importer.py b/gourmet/importers/interactive_importer.py index e9d97531e..7fc8d85b9 100644 --- a/gourmet/importers/interactive_importer.py +++ b/gourmet/importers/interactive_importer.py @@ -1,14 +1,16 @@ -from gi.repository import Gtk, Pango +from gettext import gettext as _ +import re from xml.sax.saxutils import escape -from .generic_recipe_parser import RecipeParser + +from gi.repository import Gtk, Pango + import gourmet.gtk_extras.cb_extras as cb import gourmet.gglobals as gglobals -from . import importer -import re -from gourmet.threadManager import NotThreadSafe -from . import imageBrowser import gourmet.ImageExtras as ImageExtras -from gettext import gettext as _ +from gourmet.recipeManager import get_recipe_manager +from gourmet.threadManager import NotThreadSafe +from . import importer, imageBrowser +from .generic_recipe_parser import RecipeParser # TODO # 1. Make this interface actually import recipes... @@ -75,10 +77,7 @@ def add_ing_group (self, txt): self.group = txt.strip() def add_ing_from_text (self, txt): - if not hasattr(self,'db'): - import gourmet.backends.db as db - self.db = db.get_database() - parsed_dict = self.db.parse_ingredient(txt) + parsed_dict = get_recipe_manager().parse_ingredient(txt) self.ing = parsed_dict self.commit_ing() @@ -486,7 +485,7 @@ def commit_changes (self): def set_text (self, txt): txt = str(txt) # convert to unicode for good measure - txt = re.sub('(\n\s*\n)+','\n\n',txt) # Take out extra newlines + txt = re.sub(r'(\n\s*\n)+','\n\n',txt) # Take out extra newlines txt = self.parser.parse(txt) # Parse self.set_parsed(txt) diff --git a/gourmet/importers/plaintext_importer.py b/gourmet/importers/plaintext_importer.py index c6445502f..f8eb95825 100644 --- a/gourmet/importers/plaintext_importer.py +++ b/gourmet/importers/plaintext_importer.py @@ -41,7 +41,7 @@ def do_run (self): self.commit_rec() importer.Importer.do_run(self) - def handle_line (self): + def handle_line (self, l): raise NotImplementedError def compile_regexps (self): diff --git a/gourmet/importers/rezkonv_importer.py b/gourmet/importers/rezkonv_importer.py index 3f50cc4bb..489fb0851 100644 --- a/gourmet/importers/rezkonv_importer.py +++ b/gourmet/importers/rezkonv_importer.py @@ -33,27 +33,27 @@ def compile_regexps (self): debug("start compile_regexps",5) plaintext_importer.TextImporter.compile_regexps(self) self.start_matcher = re.compile(rzc_start_pattern) - self.end_matcher = re.compile("^[=M-][=M-][=M-][=M-][=M-]\s*$") - self.group_matcher = re.compile("^\s*([=M-][=M-][=M-][=M-][=M-]+)-*\s*([^-]+)\s*-*",re.IGNORECASE) - self.ing_cont_matcher = re.compile("^\s*[-;]") - self.ing_opt_matcher = re.compile("(.+?)\s*\(?\s*optional\)?\s*$",re.IGNORECASE) + self.end_matcher = re.compile(r"^[=M-][=M-][=M-][=M-][=M-]\s*$") + self.group_matcher = re.compile(r"^\s*([=M-][=M-][=M-][=M-][=M-]+)-*\s*([^-]+)\s*-*",re.IGNORECASE) + self.ing_cont_matcher = re.compile(r"^\s*[-;]") + self.ing_opt_matcher = re.compile(r"(.+?)\s*\(?\s*optional\)?\s*$",re.IGNORECASE) # or or the German, oder - self.ing_or_matcher = re.compile("^[-= ]*[Oo][dD]?[eE]?[Rr][-= ]*$",re.IGNORECASE) - self.variation_matcher = re.compile("^\s*(VARIATION|HINT|NOTES?|VERĂ„NDERUNG|VARIANTEN|TIPANMERKUNGEN)(:.*)?",re.IGNORECASE) + self.ing_or_matcher = re.compile(r"^[-= ]*[Oo][dD]?[eE]?[Rr][-= ]*$",re.IGNORECASE) + self.variation_matcher = re.compile(r"^\s*(VARIATION|HINT|NOTES?|VERĂ„NDERUNG|VARIANTEN|TIPANMERKUNGEN)(:.*)?",re.IGNORECASE) # a crude ingredient matcher -- we look for two numbers, intermingled with spaces # followed by a space or more, followed by a two digit unit (or spaces) self.ing_num_matcher = re.compile( - "^\s*%(top)s%(num)s+\s+[A-Za-z ][A-Za-z ]? .*"%{'top':convert.DIVIDEND_REGEXP, + r"^\s*%(top)s%(num)s+\s+[A-Za-z ][A-Za-z ]? .*"%{'top':convert.DIVIDEND_REGEXP, 'num':convert.NUMBER_REGEXP}, re.IGNORECASE) self.amt_field_matcher = convert.NUMBER_MATCHER # we build a regexp to match anything that looks like # this: ^\s*ATTRIBUTE: Some entry of some kind...$ - attrmatch="^\s*(" + attrmatch=r"^\s*(" self.mmf = rzc for k in list(self.mmf.recattrs.keys()): attrmatch += "%s|"%re.escape(k) - attrmatch="%s):\s*(.*)\s*$"%attrmatch[0:-1] + attrmatch=r"%s):\s*(.*)\s*$"%attrmatch[0:-1] self.attr_matcher = re.compile(attrmatch) testtimer.end() diff --git a/gourmet/importers/xml_importer.py b/gourmet/importers/xml_importer.py index 64e897e83..803158d65 100644 --- a/gourmet/importers/xml_importer.py +++ b/gourmet/importers/xml_importer.py @@ -57,7 +57,10 @@ def do_run (self): # count the recipes in the file t = TimeAction("rxml_to_metakit.run counting lines",0) if isinstance(self.fn, str): - f = open(self.fn, 'rb') + # Latin-1 can decode any bytes, letting us open ASCII-compatible + # text files and sniff their contents - e.g. for XML tags - + # without worrying about their real text encoding. + f = open(self.fn, 'r', encoding='latin1') else: f=self.fn recs = 0 diff --git a/gourmet/keymanager.py b/gourmet/keymanager.py index 35d659e54..4e0dc47d7 100644 --- a/gourmet/keymanager.py +++ b/gourmet/keymanager.py @@ -70,7 +70,7 @@ def regexp_for_all_words (self, txt): if w: #no blank strings! count += 1 regexp="%s%s|"%(regexp,re.escape(w)) - regex="%s)(?=\W|$)"%(regexp[0:-1]) #slice off extra | + regex=r"%s)(?=\W|$)"%(regexp[0:-1]) #slice off extra | if count: return re.compile(regex), count else: diff --git a/gourmet/plugins/import_export/gxml_plugin/gxml_exporter_plugin.py b/gourmet/plugins/import_export/gxml_plugin/gxml_exporter_plugin.py index 32681c7e3..f44c3a8c4 100644 --- a/gourmet/plugins/import_export/gxml_plugin/gxml_exporter_plugin.py +++ b/gourmet/plugins/import_export/gxml_plugin/gxml_exporter_plugin.py @@ -18,18 +18,18 @@ def check_attrs (self): for attr in ['title','cuisine', 'source','link']: if getattr(self.rec,attr): - assert re.search('<%(attr)s>\s*%(val)s\s*'%{ + assert re.search(r'<%(attr)s>\s*%(val)s\s*'%{ 'attr':attr, 'val':getattr(self.rec,attr) }, self.txt), \ 'Did not find %s value %s'%(attr,getattr(self.rec,attr)) if self.rec.yields: - assert re.search('\s*%s\s*%s\s*'%( + assert re.search(r'\s*%s\s*%s\s*'%( self.rec.yields, self.rec.yield_unit), self.txt) or \ - re.search('\s*%s\s*%s\s*'%( + re.search(r'\s*%s\s*%s\s*'%( float_to_frac(self.rec.yields), self.rec.yield_unit), self.txt), \ @@ -38,7 +38,7 @@ def check_attrs (self): for att in ['preptime','cooktime']: if getattr(self.rec,att): tstr = seconds_to_timestring(getattr(self.rec,att)) - assert re.search('<%(att)s>\s*%(tstr)s\s*'%locals(),self.txt),\ + assert re.search(r'<%(att)s>\s*%(tstr)s\s*'%locals(),self.txt),\ 'Did not find %s value %s'%(att,tstr) class GourmetExporterPlugin (ExporterPlugin): diff --git a/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_importer.py b/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_importer.py index a2e999bd2..c8740e63a 100644 --- a/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_importer.py +++ b/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_importer.py @@ -7,7 +7,7 @@ class Mx2Cleaner: def __init__ (self): - self.regs_to_toss = ["<\?xml[^?]+\?>","]+>"] + self.regs_to_toss = [r"<\?xml[^?]+\?>","]+>"] self.toss_regexp = "(" for r in self.regs_to_toss: self.toss_regexp = self.toss_regexp + r + "|" @@ -18,12 +18,12 @@ def __init__ (self): self.encodings = ['cp1252','iso8859','ascii','latin_1','cp850','utf-8'] def cleanup (self, infile, outfile): - infile = open(infile,'r') - outfile = open(outfile,'w') + infile = open(infile, 'rb') + outfile = open(outfile,'w', encoding='utf-8') for l in infile.readlines(): + l = self.decode(l) l = self.toss_regs(l) l = self.fix_attrs(l) - l = self.encode(l) outfile.write(l) infile.close() outfile.close() @@ -51,14 +51,15 @@ def fix_attrs (self, instr): outstr = outstr + instr return outstr - def encode (self, l): + def decode (self, l: bytes) -> str: + """Try several encodings, return the line once it's succesfully decoded + """ for e in self.encodings: try: return l.decode(e) - except: + except UnicodeDecodeError: debug('Could not decode as %s'%e,2) pass - raise Exception("Could not encode %s" % l) class MastercookXMLHandler (xml_importer.RecHandler): """We handle MasterCook XML Files""" @@ -123,7 +124,8 @@ def mx2_handler (self, start=False, end=False, attrs=None): pass def characters (self, ch): - debug('adding to %s bufs: %s'%(len(self.bufs),ch),0) + if self.bufs: + debug('adding to %s bufs: %s'%(len(self.bufs),ch),0) for buf in self.bufs: setattr(self,buf,getattr(self,buf)+ch) diff --git a/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_plaintext_importer.py b/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_plaintext_importer.py index 8a8dc9e45..73098b635 100644 --- a/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_plaintext_importer.py +++ b/gourmet/plugins/import_export/mastercook_import_plugin/mastercook_plaintext_importer.py @@ -4,7 +4,7 @@ from gourmet.gdebug import debug from gettext import gettext as _ -MASTERCOOK_START_REGEXP='\s*\*\s*Exported\s*from\s*MasterCook.*\*\s*' +MASTERCOOK_START_REGEXP=r'\s*\*\s*Exported\s*from\s*MasterCook.*\*\s*' class MastercookPlaintextImporter (plaintext_importer.TextImporter): ATTR_DICT = {'Recipe By':'source', diff --git a/gourmet/plugins/import_export/mealmaster_plugin/mealmaster_importer.py b/gourmet/plugins/import_export/mealmaster_plugin/mealmaster_importer.py index 2195b9617..26c4c10a4 100644 --- a/gourmet/plugins/import_export/mealmaster_plugin/mealmaster_importer.py +++ b/gourmet/plugins/import_export/mealmaster_plugin/mealmaster_importer.py @@ -109,30 +109,30 @@ def compile_regexps (self): debug("start compile_regexps",5) plaintext_importer.TextImporter.compile_regexps(self) self.start_matcher = re.compile(mm_start_pattern) - self.end_matcher = re.compile("^[M-][M-][M-][M-][M-]\s*$") - self.group_matcher = re.compile("^\s*([M-][M-][M-][M-][M-])-*\s*([^-]+)\s*-*|^\s*---\s*([^-]+)\s*---\s*$",re.IGNORECASE) - self.ing_cont_matcher = re.compile("^\s*[-;]") - self.ing_opt_matcher = re.compile("(.+?)\s*\(?\s*optional\)?\s*$",re.IGNORECASE) + self.end_matcher = re.compile(r"^[M-][M-][M-][M-][M-]\s*$") + self.group_matcher = re.compile(r"^\s*([M-][M-][M-][M-][M-])-*\s*([^-]+)\s*-*|^\s*---\s*([^-]+)\s*---\s*$",re.IGNORECASE) + self.ing_cont_matcher = re.compile(r"^\s*[-;]") + self.ing_opt_matcher = re.compile(r"(.+?)\s*\(?\s*optional\)?\s*$",re.IGNORECASE) self.ing_or_matcher = re.compile("^[- ]*[Oo][Rr][- ]*$",re.IGNORECASE) - self.variation_matcher = re.compile("^\s*(VARIATION|HINT|NOTES?)(:.*)?",re.IGNORECASE) + self.variation_matcher = re.compile(r"^\s*(VARIATION|HINT|NOTES?)(:.*)?",re.IGNORECASE) # a crude ingredient matcher -- we look for two numbers, # intermingled with spaces followed by a space or more, # followed by a two digit unit (or spaces) c = convert.get_converter() self.ing_num_matcher = re.compile( - "^\s*%s+\s+([a-z ]{1,2}|%s)\s+.*\w+.*"%( + r"^\s*%s+\s+([a-z ]{1,2}|%s)\s+.*\w+.*"%( convert.NUMBER_REGEXP, '('+'|'.join([x for x in list(c.unit_dict.keys()) if x])+')' ), re.IGNORECASE) - self.amt_field_matcher = re.compile("^(\s*%s\s*)$"%convert.NUMBER_REGEXP) + self.amt_field_matcher = re.compile(r"^(\s*%s\s*)$"%convert.NUMBER_REGEXP) # we build a regexp to match anything that looks like # this: ^\s*ATTRIBUTE: Some entry of some kind...$ self.mmf = mmf - attrmatch="^\s*(" + attrmatch=r"^\s*(" for k in list(self.mmf.recattrs.keys()): attrmatch += "%s|"%re.escape(k) - attrmatch="%s):\s*(.*)\s*$"%attrmatch[0:-1] + attrmatch=r"%s):\s*(.*)\s*$"%attrmatch[0:-1] self.attr_matcher = re.compile(attrmatch) testtimer.end() diff --git a/gourmet/plugins/import_export/web_import_plugin/webpage_importer.py b/gourmet/plugins/import_export/web_import_plugin/webpage_importer.py index efca8803d..728090de7 100644 --- a/gourmet/plugins/import_export/web_import_plugin/webpage_importer.py +++ b/gourmet/plugins/import_export/web_import_plugin/webpage_importer.py @@ -110,7 +110,7 @@ def crawl (self, tag, parent_label=None): def reduce_whitespace (self, s): if not hasattr(self,'__whitespace_regexp'): - self.__whitespace_regexp = re.compile('\s+') + self.__whitespace_regexp = re.compile(r'\s+') return self.__whitespace_regexp.sub(' ',s) def cut_extra_whitespace (self, s): @@ -181,7 +181,7 @@ def postparse (self, parsed): ''' new_parse = [] for p,attr in parsed: - p = re.sub('(\n\s*\n)+','\n\n',p) # Take out extra newlines + p = re.sub(r'(\n\s*\n)+','\n\n',p) # Take out extra newlines if attr == None or attr == 'recipe': new_parse.extend( self.text_parser.parse(p) @@ -225,7 +225,7 @@ def cut_menus (self): continue self.preparsed_elements.append((menu,'ignore')) menu_text_regexp = re.compile( - '.*sitemap.*|^\s-*about\s-*',re.IGNORECASE + r'.*sitemap.*|^\s-*about\s-*',re.IGNORECASE ) for menu in self.soup(text=menu_text_regexp): if hasattr(menu,'name') and menu.name == 'body': continue diff --git a/gourmet/plugins/nutritional_information/databaseGrabber.py b/gourmet/plugins/nutritional_information/databaseGrabber.py index 9d297595d..56f0c03e6 100644 --- a/gourmet/plugins/nutritional_information/databaseGrabber.py +++ b/gourmet/plugins/nutritional_information/databaseGrabber.py @@ -6,9 +6,9 @@ expander_regexp = None def compile_expander_regexp (): - regexp = "(? bool: + if self.rec_editor is not None and self.rec_editor.edited: + return True + return False + + def set_edited(self, val) -> None: + if self.rec_editor is not None and self.rec_editor.edited: + self.rec_editor.edited = bool(val) edited = property(get_edited,set_edited) def show_display (self): @@ -102,12 +101,17 @@ def show_display (self): self.recipe_display = RecCardDisplay(self, self.rg,self.current_rec) self.recipe_display.window.present() - def show_edit (self, module=None): - if not hasattr(self,'recipe_editor'): - self.recipe_editor = RecEditor(self, self.rg,self.current_rec,new=self.new) + def show_edit(self, module: Optional[str] = None) -> None: + """Draw the recipe editor window. + + `module` is the string definition of one of the RecEditor's tabs, as + defined in RecEditor.module_tab_by_name.""" + if self.rec_editor is None: + self.rec_editor = RecEditor(self, self.rg, + self.current_rec, new=self.new) if module: - self.recipe_editor.show_module(module) - self.recipe_editor.present() + self.rec_editor.show_module(module) + self.rec_editor.present() def delete (self, *args): @@ -117,8 +121,9 @@ def update_recipe (self, recipe): self.current_rec = recipe if hasattr(self,'recipe_display'): self.recipe_display.update_from_database() - if hasattr(self,'recipe_editor') and not self.recipe_editor.window.get_property('visible'): - delattr(self,'recipe_editor') + if (self.rec_editor is not None and + not self.rec_editor.window.is_visible()): + self.rec_editor = None def show (self): if self.new: @@ -128,8 +133,9 @@ def show (self): def hide (self): if ((not (hasattr(self,'recipe_display') and self.recipe_display.window.get_property('visible'))) - and - (not (hasattr(self,'recipe_editor') and self.recipe_editor.window.get_property('visible')))): + and + (self.rec_editor is not None and + not self.rec_editor.window.is_visible())): self.rg.del_rc(self.current_rec.id) # end RecCard @@ -933,7 +939,7 @@ def module_edited_cb (self, module, val): return self.set_edited(False) - def show_module (self, module_name): + def show_module(self, module_name: str) -> None: """Show the part of our interface corresponding with module named module_name.""" if module_name not in self.module_tab_by_name: @@ -1690,7 +1696,7 @@ class IngredientController (plugin_loader.Pluggable): OPTIONAL_COL = 4 def __init__ (self, ingredient_editor_module): - self.ingredient_editor_module = ingredient_editor_module; + self.ingredient_editor_module = ingredient_editor_module self.rg = self.ingredient_editor_module.rg self.re = self.ingredient_editor_module.re self.new_item_count = 0 @@ -2156,7 +2162,8 @@ class IngredientTreeUI: } def __init__ (self, ie, tree): - self.ingredient_editor_module =ie; self.rg = self.ingredient_editor_module.rg + self.ingredient_editor_module = ie + self.rg = self.ingredient_editor_module.rg self.ingController = IngredientController(self.ingredient_editor_module) self.ingTree = tree self.ingTree.get_selection().set_mode(Gtk.SelectionMode.MULTIPLE) @@ -2835,9 +2842,9 @@ def inverse (self): self.inverse_action() -def add_with_undo(rc: 'RecCard', method: Callable): - idx = rc.recipe_editor.module_tab_by_name["ingredients"] - ing_controller = rc.recipe_editor.modules[idx].ingtree_ui.ingController +def add_with_undo(editor_module: IngredientEditorModule, method: Callable): + idx = editor_module.re.module_tab_by_name["ingredients"] + ing_controller = editor_module.re.modules[idx].ingtree_ui.ingController uts = UndoableTreeStuff(ing_controller) def do_it (): diff --git a/gourmet/recipeManager.py b/gourmet/recipeManager.py index f970dc35b..7575cdc36 100644 --- a/gourmet/recipeManager.py +++ b/gourmet/recipeManager.py @@ -78,20 +78,11 @@ class through self.rm. except: print('invalid input.') -def get_recipe_manager (**args): - if not args: args = dbargs - try: - return RecipeManager(**args) - except RecData as rd: - return rd +def get_recipe_manager (**kwargs): + return RecipeManager.instance_for(**kwargs) def default_rec_manager (): return get_recipe_manager(**dbargs) - #try: - # return RecipeManager(**dbargs) - #except RecData,rd: - # return rd - if __name__ == '__main__': #rm = RecipeManager(**dbargs) diff --git a/gourmet/sound_gst.py b/gourmet/sound_gst.py index 43d1113b6..5cd89e3da 100644 --- a/gourmet/sound_gst.py +++ b/gourmet/sound_gst.py @@ -1,5 +1,9 @@ from gi import require_version -require_version('Gst', '1.0') +try: + require_version('Gst', '1.0') +except ValueError as e: + # gourmet.sound catches ImportError + raise ImportError("Gst not available") from e from gi.repository import Gst class Player: diff --git a/gourmet/tests/test_importers.py b/gourmet/tests/test_importers.py index 0f652ddc4..a26e10c90 100644 --- a/gourmet/tests/test_importers.py +++ b/gourmet/tests/test_importers.py @@ -110,7 +110,8 @@ def do_test (self, test): for blobby_attribute in ['instructions','modifications']: if test.get(blobby_attribute,False): match_text = test[blobby_attribute] - match_text = re.sub('\s+','\s+',match_text) + match_text = re.sub(r'\s+',r'\s+',match_text) + try: assert(re.match(match_text,getattr(rec,blobby_attribute))) except: diff --git a/gourmet/timeScanner.py b/gourmet/timeScanner.py index 984233a6a..8e9e72182 100644 --- a/gourmet/timeScanner.py +++ b/gourmet/timeScanner.py @@ -17,14 +17,14 @@ time_matcher = re.compile( '(?P'+convert.NUMBER_FINDER_REGEXP + ')(' + \ convert.RANGE_REGEXP + convert.NUMBER_FINDER_REGEXP.replace('int','int2').replace('frac','frac2') + ')?' \ - + '\s*' + '(?P' + '|'.join(all_units) + ')(?=$|\W)', + + r'\s*' + '(?P' + '|'.join(all_units) + r')(?=$|\W)', re.UNICODE ) def make_time_links (s): - return time_matcher.sub('\g<0>',s) + return time_matcher.sub(r'\g<0>',s) class TimeBuffer (LinkedTextView.LinkedPangoBuffer):