plint

French poetry validator (local mirror of https://gitlab.com/a3nm/plint)
git clone https://a3nm.net/git/plint/
Log | Files | Refs | README

template.py (10318B)


      1 import copy
      2 import re
      3 
      4 from plint import error, rhyme
      5 from plint.common import normalize
      6 from plint.nature import nature_count
      7 from plint.options import default_options
      8 from plint.pattern import Pattern
      9 from plint.verse import Verse
     10 
     11 
     12 OPTION_ALIASES = {
     13         'fusionner': 'merge',
     14         'ambiguous_ok': 'forbidden_ok',
     15         'ambigu_ok': 'forbidden_ok',
     16         'dierese': 'diaeresis',
     17         'verifie_occurrences': 'check_occurrences',
     18         'repetition_ok': 'repeat_ok',
     19         'incomplet_ok': 'incomplete_ok',
     20         'phon_supposee_ok': 'phon_supposed_ok',
     21         'oeil_supposee_ok': 'eye_supposed_ok',
     22         'oeil_tolerance_ok': 'eye_tolerance_ok',
     23         'pauvre_oeil_requise': 'poor_eye_required',
     24         'pauvre_oeil_supposee_ok': 'poor_eye_supposed_ok',
     25         'pauvre_oeil_vocalique_ok': 'poor_eye_vocalic_ok',
     26     }
     27 
     28 
     29 def reset_conditional(d):
     30     return dict((k, v) for k, v in d.items() if len(k) > 0 and k[0] == '!')
     31 
     32 
     33 class Template:
     34 
     35     def __init__(self, template_string=None):
     36         self.template = []
     37         self.pattern_line_no = 0
     38         self.options = dict(default_options)
     39         self.mergers = []
     40         self.old_position = None
     41         self.old_env = None
     42         self.old_femenv = None
     43         self.old_occenv = None
     44         self.overflowed = False
     45         if template_string is not None:
     46             self.load(template_string)
     47         self.line_no = 0
     48         self.position = 0
     49         self.prev = None
     50         self.env = {}
     51         self.feminine_environment = {}
     52         self.occurrence_environment = {}
     53         self.reject_errors = False
     54 
     55     def load(self, template_string):
     56         """Load from a string"""
     57         for line in template_string.split('\n'):
     58             line = line.strip()
     59             self.pattern_line_no += 1
     60             if len(line) != 0 and line[0] != '#':
     61                 if line[0] == '!':
     62                     # don't count the '!' in the options, that's why we use [1:]
     63                     for option_string in line.split()[1:]:
     64                         self.read_option(option_string)
     65                 else:
     66                     self.template.append(self.parse_line(line.strip()))
     67         if len(self.template) == 0:
     68             raise error.TemplateLoadError("Template is empty")
     69 
     70     def read_option(self, option_string):
     71         try:
     72             key, value = option_string.split(':')
     73         except ValueError:
     74             raise error.TemplateLoadError("Global options must be provided as key-value pairs")
     75         if key in OPTION_ALIASES:
     76             key = OPTION_ALIASES[key]
     77         if key == 'merge':
     78             self.mergers.append(value)
     79         elif key == 'diaeresis':
     80             if value == "classique":
     81                 value = "classical"
     82             if value not in ["permissive", "classical"]:
     83                 raise error.TemplateLoadError("Bad value for global option %s" % key)
     84             self.options['diaeresis'] = value
     85         elif key in self.options:
     86             self.options[key] = str2bool(value)
     87         else:
     88             raise error.TemplateLoadError("Unknown global option")
     89 
     90     def parse_line(self, line):
     91         """Parse template line from a line"""
     92         split = line.split(' ')
     93         metric = split[0]
     94         if len(split) >= 2:
     95             my_id = split[1]
     96         else:
     97             my_id = str(self.pattern_line_no)  # unique
     98         if len(split) >= 3:
     99             feminine_id = split[2]
    100         else:
    101             feminine_id = str(self.pattern_line_no)  # unique
    102         id_split = my_id.split(':')
    103         classical = True
    104         n_common_suffix_phones = 1
    105         if len(id_split) >= 2:
    106             constraint = id_split[-1].split('|')
    107             if len(constraint) > 0:
    108                 classical = False if constraint[0] in ["no", "non"] else constraint[0]
    109             if len(constraint) > 1:
    110                 n_common_suffix_phones = int(constraint[1])
    111         else:
    112             constraint = []
    113         if len(constraint) == 0:
    114             n_common_suffix_phones = 1
    115         return Pattern(metric, my_id, feminine_id, rhyme.Constraint(classical, n_common_suffix_phones))
    116 
    117     def match(self, line, output_file=None, last=False, n_syllables=None, offset=0):
    118         """Check a line against current pattern, return errors"""
    119 
    120         was_incomplete = last and not self.beyond
    121 
    122         errors = []
    123         pattern = self.get()
    124 
    125         line_with_case = normalize(line, downcase=False)
    126 
    127         verse = Verse(line, self, pattern)
    128 
    129         if n_syllables:
    130             verse.print_n_syllables(n_syllables, offset, output_file)
    131             return errors, pattern, verse
    132 
    133         if last:
    134             if was_incomplete and not self.options['incomplete_ok'] and not self.overflowed:
    135                 return [error.ErrorIncompleteTemplate()], pattern, verse
    136             return [], pattern, verse
    137 
    138         if self.overflowed:
    139             return [error.ErrorOverflowedTemplate()], pattern, verse
    140 
    141         rhyme_failed = False
    142         # rhymes
    143         if pattern.my_id not in self.env:
    144             # initialize the rhyme
    145             # last_count is passed later
    146             self.env[pattern.my_id] = rhyme.Rhyme(verse.normalized, pattern.constraint, self.mergers, self.options)
    147         else:
    148             # update the rhyme
    149             self.env[pattern.my_id].feed(verse.normalized, pattern.constraint)
    150             if not self.env[pattern.my_id].satisfied_phon():
    151                 # no more possible rhymes, something went wrong, check phon
    152                 self.env[pattern.my_id].rollback()
    153                 rhyme_failed = True
    154                 errors.append(error.ErrorBadRhymeSound(self.env[pattern.my_id],
    155                                                        self.env[pattern.my_id].new_rhyme))
    156 
    157         # occurrences
    158         if self.options['check_occurrences']:
    159             if pattern.my_id not in self.occurrence_environment.keys():
    160                 self.occurrence_environment[pattern.my_id] = {}
    161             last_word = re.split(r'[- ]', line_with_case)[-1]
    162             if last_word not in self.occurrence_environment[pattern.my_id].keys():
    163                 self.occurrence_environment[pattern.my_id][last_word] = 0
    164             self.occurrence_environment[pattern.my_id][last_word] += 1
    165             if self.occurrence_environment[pattern.my_id][last_word] > nature_count(last_word):
    166                 errors.insert(0,
    167                               error.ErrorMultipleWordOccurrence(last_word,
    168                                                                 self.occurrence_environment[pattern.my_id][last_word]))
    169 
    170         verse.phon = self.env[pattern.my_id].phon
    171         verse.parse()
    172 
    173         # now that we have parsed, adjust rhyme to reflect last word length
    174         # and check eye
    175         if not rhyme_failed:
    176             self.env[pattern.my_id].adjust_last_count(verse.get_last_count())
    177             if not self.env[pattern.my_id].satisfied_eye():
    178                 old_phon = len(self.env[pattern.my_id].phon)
    179                 self.env[pattern.my_id].rollback()
    180                 errors.append(error.ErrorBadRhymeEye(self.env[pattern.my_id],
    181                                                      self.env[pattern.my_id].new_rhyme, old_phon))
    182 
    183         errors = verse.problems() + errors
    184 
    185         verse.print_possible(output_file)
    186 
    187         # rhyme genres
    188         # inequality constraint
    189         # TODO this is simplistic and order-dependent
    190         if pattern.feminine_id.swapcase() in self.feminine_environment.keys():
    191             new = {'M', 'F'} - self.feminine_environment[pattern.feminine_id.swapcase()]
    192             if len(new) > 0:
    193                 self.feminine_environment[pattern.feminine_id] = new
    194         if pattern.feminine_id not in self.feminine_environment.keys():
    195             if pattern.feminine_id == 'M':
    196                 x = {'M'}
    197             elif pattern.feminine_id == 'F':
    198                 x = {'F'}
    199             else:
    200                 x = {'M', 'F'}
    201             self.feminine_environment[pattern.feminine_id] = x
    202         old = list(self.feminine_environment[pattern.feminine_id])
    203         new = verse.genders()
    204         self.feminine_environment[pattern.feminine_id] &= set(new)
    205         if len(self.feminine_environment[pattern.feminine_id]) == 0:
    206             errors.append(error.ErrorBadRhymeGenre(old, new))
    207 
    208         return errors, pattern, verse
    209 
    210     def reset_state(self):
    211         """Reset our state, except ids starting with '!'"""
    212         self.position = 0
    213         self.env = reset_conditional(self.env)
    214         self.feminine_environment = reset_conditional(self.feminine_environment)
    215         self.occurrence_environment = {}  # always reset
    216 
    217     @property
    218     def beyond(self):
    219         return self.position >= len(self.template)
    220 
    221     def get(self):
    222         """Get next state, resetting if needed"""
    223         self.old_position = self.position
    224         self.old_env = copy.deepcopy(self.env)
    225         self.old_femenv = copy.deepcopy(self.feminine_environment)
    226         self.old_occenv = copy.deepcopy(self.occurrence_environment)
    227         if self.beyond:
    228             if not self.options['repeat_ok']:
    229                 self.overflowed = True
    230             self.reset_state()
    231         result = self.template[self.position]
    232         self.position += 1
    233         return result
    234 
    235     def back(self):
    236         """Revert to previous state"""
    237         self.position = self.old_position
    238         self.env = copy.deepcopy(self.old_env)
    239         self.feminine_environment = copy.deepcopy(self.old_femenv)
    240         self.occurrence_environment = copy.deepcopy(self.old_occenv)
    241 
    242     def check(self, line, output_file=None, last=False, n_syllables=None, offset=0):
    243         """Check line (wrapper)"""
    244         self.line_no += 1
    245         line = line.rstrip()
    246         if normalize(line) == '' and not last:
    247             return None
    248 
    249         errors, pattern, verse = self.match(line, output_file,
    250                 last=last, n_syllables=n_syllables, offset=offset)
    251         if len(errors) > 0 and self.reject_errors:
    252             self.back()
    253             self.line_no -= 1
    254         return error.ErrorCollection(self.line_no,
    255                 line, pattern, verse, errors)
    256 
    257 
    258 def str2bool(x):
    259     if x.lower() in ["yes", "oui", "y", "o", "true", "t", "vrai", "v"]:
    260         return True
    261     if x.lower() in ["no", "non", "n", "false", "faux", "f"]:
    262         return False
    263     raise error.TemplateLoadError("Bad value in global option")