pdfformfill/commands.py
2018-06-25 18:53:55 +02:00

837 lines
28 KiB
Python

"""Commands are the building blocks of the filling out of the form.
When filling out the form the user can get things asked and some data
is automagically created. Both can be modelled by commands.
"""
import datetime
import formfield as ff
from constants import (SUBINFO_SEP, VARIABLE_BEGIN_SEP, VARIABLE_END_SEP,
VARIABLE_PARTS_SEP, VARIABLE_DEFAULT_SEP,
DATE_END_SEP, DATE_BEGIN_SEP,
ConfigError, ON, OFF)
def dateAfter(ref=datetime.date.today(), day=None, month=None):
"""Find first date after ref with the given infos.
Attributes:
ref (datetime.date):
dateAfter returns a date after (or equal to) ref.
day (int):
day in month of the return value.
None (default): use day of ref.
month (int):
month of the return value
None (default): use first possible month.
Returns:
First date after ref with day as day of month, month as month.
"""
if day is None:
day = ref.day
if month is None:
if day < ref.day:
if ref.month == 12:
return datetime.date(ref.year + 1, 1, day)
else:
return datetime.date(ref.year, ref.month + 1, day)
else:
return datetime.date(ref.year, ref.month, day)
return datetime.date(ref.year, ref.month, day)
else: # month is given
if month < ref.month:
return datetime.date(ref.year + 1, month, day)
else:
return datetime.date(ref.year, month, day)
class Command():
"""A command is one action used to create some data for a form.
Information about this command can be accessed via [..] notation.
This access the reason for the necessary information.
Attributes:
name (str):
the name of the command as used in the
info of reason (reason must have an info "<command>-name"
where <command> is the specific type of command used).
reason (FormField):
all information for a command is saved in a FormField.
This FormField is the reason for this Command.
Usually this command populates the reason with information.
fieldList ([FormField]):
list with all fields of the current form
data ({str: [FormField]}:
dictionary with lists of FormFields from
other forms that contain helpful data
"""
def __init__(self, name, reason, fieldList, data):
"""Create a command.
Attributes:
name (str): the name of the command as used in the
info of reason (reason must have an info "<command>: name"
where <command> is the specific type of command used).
reason, fieldList, data: see class-Docstring
# Raises:
# NotImplementedError: Command cannot be instantiated. Use a
# subclass.
"""
self.name = name
self.reason = reason
self.fieldList = fieldList
self.data = data
# raise NotImplementedError("Command cannot be instantiated.")
@classmethod
def extractCommands(cls, reason, fieldList, data):
"""Find all commands saved in reason.
The information used must have the key equal to the subclass.
Attributes:
reason: the field where the commands get extracted.
fieldList, data: see class-docstring
Returns:
a list of commands.
"""
commands = []
for key in reason.iterkeys():
for clss in cls.__subclasses__():
if key == clss.__name__:
commands.extend([clss(commandname, reason,
fieldList, data)
for commandname in reason[key]])
# reason[key] should be a list
# see MobileRegex in readformdata
return commands
def __getitem__(self, key):
"""Get some information.
self.reason must have this information in the form
self["name" + "-" + key].
"""
return self.reason[self.name + SUBINFO_SEP + key]
def __setitem__(self, key, value):
"""Set some information.
Similar as __getitem__.
"""
self.reason[self.name + SUBINFO_SEP + key] = value
def __contains__(self, key):
"""Give it makes sense to ask for this key.
Returns:
self.name + SUBINFO_SEP + key in self.reason
"""
return self.name + SUBINFO_SEP + key in self.reason
def __lt__(self, other):
"""Has self a lower priority than other.
Returns:
If only one has a priority, it is "less".
If both have no priority, True is returned.
"""
try:
myprio = float(self["Priority"])
except KeyError:
return False
except ValueError:
raise ConfigError("Priority in Command " + self.name +
" of field " + self.reason.name +
" is not a float.")
try:
otherprio = float(other["Priority"])
except KeyError:
return True
except ValueError:
raise ConfigError("Priority in Command " + other.name +
" of field " + other.reason.name +
" is not a float.")
return myprio < otherprio
def interpretVariables(self, value):
"""Interpret variables in values.
Replace occurences of {FieldName[#Default]} by the value saved in
field FieldName.
Replace occurences of {REF|FieldName[#Default]} by the value saved
in field FieldName in the data self.data[REF].
[] means that this part is optional.
If nothing is saved raise ConfigError except if a #Default is
given. Then use the default
Variables are replaced as long as there are any.
Hence if there are variables in the new strings they get replaced
as well. Hope no one uses this for recursion. ^^^
Attributes:
value (str): the string in which variables are replaced.
default (str): if some Field has no value saved, use default.
If default is None (default for this attribute),
raise a ConfigError.
Raises:
ConfigError:
if some FieldName does not exist and has no default
"""
while True:
start = value.find(VARIABLE_BEGIN_SEP)
end = value.find(VARIABLE_END_SEP, start)
if start == -1 or end == -1: # not found
return value
sep = value.find(VARIABLE_PARTS_SEP, start)
replaced = value[start:end + len(VARIABLE_END_SEP)]
if sep == -1: # {FieldName}
fieldList = self.fieldList
fieldname = value[start + len(VARIABLE_BEGIN_SEP):end]
else: # {spec|FieldName}
fieldList = self.data[value[
start + len(VARIABLE_BEGIN_SEP):sep]]
fieldname = value[sep + len(VARIABLE_PARTS_SEP):end]
fieldnameparts = fieldname.split(VARIABLE_DEFAULT_SEP, maxsplit=1)
if len(fieldnameparts) == 2:
# fieldname#default
fieldname = fieldnameparts[0]
default = fieldnameparts[1]
else: # len(fieldnameparts) == 1:
fieldname = fieldnameparts[0]
default = None
try:
field = ff.FormField.findByFieldName(fieldList, fieldname,
include=lambda x: True)
except KeyError as e:
raise ConfigError("Variable cannot be replaced due to " +
"missing FormField: " + fieldname
+ " (" + str(e) + ")")
else: # normal case: Field found
try:
value = value.replace(replaced, field["Value"])
except KeyError as e:
if default is None:
raise ConfigError("Variable cannot be replaced" +
" due to missing value: "
+ fieldname + " (" + str(e) + ")")
else:
value = value.replace(replaced, default)
def interpretDates(self, value):
"""Interpret dates in value.
Replace occurences of {TODAY|format} with the current day
in the format format.
format is described in
https://docs.python.org/3.5/library/datetime.html#strftime-strptime-behavior
"""
while True:
start = value.find(DATE_BEGIN_SEP)
end = value.find(DATE_END_SEP, start)
if start == -1 or end == -1:
return value
replaced = value[start: end + len(DATE_END_SEP)]
formatstr = value[start + len(DATE_BEGIN_SEP): end]
formatted = datetime.datetime.now().strftime(formatstr)
value = value.replace(replaced, formatted)
def interpret(self, value):
"""Replace variables and dates with their correct values.
See description of interpretDates and interpretVariables.
"""
value = self.interpretVariables(value)
value = self.interpretDates(value)
return value
def __call__(self): # todo: refactor, make smaller, externalise conditions
"""Run the command.
The subclasses must implement do() because __call__ calls do.
There can be conditions on doing actions. Conditions are and-connected.
That means that all must say yes.
1) even if exist:
Usually commands are not run if there is already a value.
If Ifexist exists as an info, the command is also run if there is
already a value.
2) if unequal:
Do not execute if two values are equal. Syntax:
name-Ifunequal: x
name-x-A: Blub
name-x-B: Bla
Usually Blub and/or Bla contain variables.
3) if equal:
Similarly to Ifunequal
"""
if "Value" in self.reason and "Ifexist" not in self:
# Do not do anything
return
for key, test in [("Ifunequal", lambda a, b: a == b),
("Ifequal", lambda a, b: a != b)]:
try:
for comp in self[key]:
# there can be sevaral tests of the same kind
# hence stored in a list self[key]
comps = {}
# the values to be compared are to be stored in a dict
# with the keys "A" and "B":
for comparevalue in ("A", "B"):
try:
# saved in infos of the kind y-a-A, y-a-B
comps[comparevalue] = self[comp + SUBINFO_SEP +
comparevalue]
except KeyError as e:
# values y-a-A are missing
raise ConfigError("A " + key +
" is missing a value " +
comparevalue + ".(" + str(e) +
" at " +
self.name + " at " +
self.reason.name + ")")
if test(self.interpret(comps["A"]),
self.interpret(comps["B"])):
# do not do anything
return
except KeyError:
# do nothing, apparently key does not exist
assert key not in self # there should be no other KeyError
# pass # necessary without assert statement
self.do()
def userinput(self, prompt=None):
"""Ask the user a question.
Use Prompt if existent or otherwise reason.name for informing the user.
Attributes:
prompt: Text shown to the user to ask him to type something.
Default (None)= Take "Prompt" info or name.
": " is added.
Returns:
If user presses Ctrl+D (EOFError), return None.
Otherwise return what user typed. (Can be "").
Raises:
KeyboardInterrupt:
if user presses Ctrl+C, wanting to cancel the program.
"""
if prompt is None:
try:
prompt = self["Prompt"]
except KeyError:
prompt = self.reason.name
else:
# take the argument
pass
prompt = self.interpret(prompt)
prompt += ": "
try:
return input(prompt)
except EOFError:
return None
def showHelp(self, extrainfo='(Ctrl+D = save nothing)'):
"""Show the help for this question.
Show "Help" or if not existing, the Description or if not existing
telling the user that.
Attributes:
extrainfo: Text that is shown additionally to the help.
"""
print("")
try:
message = self["Help"]
except KeyError:
# no help supplied, use field description
try:
message = self.reason["Description"]
except KeyError:
message = "No help available."
message = self.interpret(message)
print(message)
print(extrainfo)
class Question(Command):
"""The user gets asked a question.
No __init__ method since the Command.__init__ is sufficiant.
Uses infos:
Prompt: what the user sees while typing.
Help: what the user sees if asking for further information
"""
def do(self):
"""Ask the user a question and save the result in the field.
If the user writes '?', a help is shown and the user is asked again.
If the user only writes nothing (aka an empty string) nothing (not even
an empty string) is saved. This sounds like a plausible default.
If the user writes self.EMPTY, an empty string is saved.
Uses the name of self.reason if no other question was supplied.
"""
while True: # while no usable input
print("")
text = self.userinput()
if text == "?":
self.showHelp()
# ask again
else:
# None deletes what is there
self.reason["Value"] = text
break
class Choice(Command):
"""The user gets asked a question with a limited number of answer options.
One possibility is a on-off Button.
Another is a choice of several buttons from which one can be chosen.
Uses infos:
Prompt: what the user sees before typing.
Help: what the user sees if asking for further information
reason[FieldStateOption]: For the options that can be used.
Option-1/2/3/On/Off/...: for each option a description what it means.
Enter: if present tells what should be used if the user just presses
Enter.
"""
class SavedException(Exception):
"""Raise if a value is saved and asking the user should stop.
To be used in the check methods.
"""
class AgainException(Exception):
"""Raise if no value is saved and the user should be asked again.
To be used in the check methods.
"""
@property
def options(self):
"""Return the list of options of the field of this choice.
Raises:
ConfigError: if this info does not exist.
"""
try:
return self.reason["FieldStateOption"]
except KeyError:
raise ConfigError(
"A Choice question was asked but there are no options" +
" specified. Field: " + self.reason.name +
" Choice: " + self.name)
def getEnterDefault(self):
"""Get the default value.
The one that should be saved on pressing Enter without input.
Interprets variables.
Raises:
ConfigError: If it is not a valid option.
Returns:
None if no default was specified.
The default if it was specified.
"""
try:
default = self["Enter"]
except KeyError:
return None
default = self.interpret(default)
if default in self.options:
return default
else:
raise ConfigError(
"Default on Enter - value '" + default
+ "' not a possible option " +
"(" + self.name + " in " + self.reason.name + ")")
def sortOptions(self):
"""Sort the options in a reasonable way.
Change the order of the elements of self.reason["FieldStateOption"].
If default (self["Enter"]) exists, use this as first.
If Yes/On/Ja/ja exists, take this.
If natural numbers exist, take them in the natural order.
If No/Off/Nein/nein exists, take this.
Sort rest alphabetically.
"""
oldorder = self.options[:]
neworder = []
enter = self.getEnterDefault()
if enter is not None:
# check that it is indeed an option already included
neworder.append(enter)
oldorder.remove(enter)
for yes in ON:
if yes in oldorder:
neworder.append(yes)
oldorder.remove(yes)
numbers = sorted([number for number in oldorder if number.isdigit()])
neworder.extend(numbers)
for number in numbers:
oldorder.remove(number)
for no in OFF:
if no in oldorder:
neworder.append(no)
oldorder.remove(no)
neworder.extend(sorted(oldorder)) # add what remained
assert set(neworder) == set(self.options) # check that we have not
# lost anything
self.reason["FieldStateOption"] = neworder
def optionDescription(self, option):
"""Build descriptive option.
Return:
if option is default
option = description (Default on Enter)
or if not default:
option = description
or if no description is existing:
option (No help)
"""
default = ("; " if self.getEnterDefault() != option
else " (Default on Enter); ")
try:
return (option + " = " +
self["Option" + SUBINFO_SEP + option] +
default)
except KeyError:
return option + " (No help) " + default
def optionDescriptionInterpreted(self, option):
"""Build descriptive option.
Interpret variables.
Return:
if option is default
option = description (Default on Enter)
or if not default:
option = description
or if no description is existing:
option (No help)
"""
return self.interpret(self.optionDescription(option))
def showPrompt(self):
"""Show the prompt/ name specified and the options."""
print("")
try:
print(self["Prompt"], "Options: ")
# other version:, sep="\n", end="")
except KeyError:
# no question supplied, hence use field name
print(self.reason.name, "Options:", sep="\n")
# show Default as first option -> included in sorting
self.sortOptions()
for i in range(len(self.options)):
# for option in self.options:
print(i + 1, ") ",
self.optionDescriptionInterpreted(self.options[i]),
sep="")
# print(self.optionDescriptionInterpreted(option))
def usedefault(self):
"""Save the default value.
The default value is saved in "Enter".
Show help if no default is given.
Raises:
ConfigError: if the default is not a valid option.
AgainException: if a help was shown.
SavedException: if the default was shown.
"""
default = self.getEnterDefault()
if default is None:
self.showHelp()
raise self.AgainException()
else:
self.saveValue(default)
def saveValue(self, value):
"""Save value in self.reason["Value"].
Print message what was chosen.
Raises:
SavedException: always
"""
self.reason["Value"] = value
print("Chose", self.optionDescription(value))
raise self.SavedException()
def checkAbort(self, text):
"""Save nothing if the user gave no input (Ctrl + D).
Raises:
SavedException: if nothing is saved.
"""
if text is None:
# user aborts, nothing to be saved
self.reason["Value"] = text # = None = delete if existent
print("Chose nothing.")
raise self.SavedException()
def checkDefault(self, text):
"""Save value if the user wants to use the default.
Raises:
SavedException: if a value got saved, asking the user should stop.
AgainException: if the user should be asked again.
"""
if text == "":
self.usedefault()
# SavedException or AgainException or ConfigError raised
def checkHelp(self, text):
"""Show help if text is '?'.
Raises:
AgainException: if help is shown
"""
if text == "?":
self.showHelp()
raise self.AgainException()
def checkOptionNumber(self, text):
"""Save value which number is entered.
Raises:
SavedException: if input was a valid index.
"""
try:
number = int(text) - 1
# -1 because shown number start with 1, indeces with 0
if number < 0:
raise ValueError
except ValueError:
pass
# is not non-negative int, continue with next try
else:
try:
option = self.options[number]
except IndexError:
pass
# is not valid index, continue with next try
else:
self.saveValue(option)
def checkOptionDescription(self, text):
"""Save value if user input matches beginning of option description.
Ignore case.
Raises:
SavedException: if a value is saved.
AgainException: if two options are possible.
"""
choice = None
for option in self.options:
if ("Option" + SUBINFO_SEP + option in self
# option help exists
and self["Option" + SUBINFO_SEP + option].lower(
).startswith(text.lower())):
if choice is None:
choice = option
else:
# found second match
print("Choice not well-defined.")
raise self.AgainException()
if choice is not None:
self.saveValue(choice)
def notValidInput(self, text):
"""Assume that text is not possible to understand.
Print explaining message.
Raise:
AgainException
"""
print(text, "could not be understood as a valid choice.")
self.showHelp()
raise self.AgainException()
def do(self):
"""Ask the user a question and save the result in the field.
Tell the user of the different options.
If the user writes '?', a help is shown and the user is asked again.
The answer is interpreted in the following manner:
if the answer is empty:
if "Enter" is present, take this value
otherwise show help and reask
if the answer is ?, show help and reask
if the answer is number and a valid index of the option list take it
if one of the descriptions start with the answer, (ignore case):
take this one.
if none of the above, reask
if the user aborts with Ctrl+D (EOFError), do not save anything
Uses the name of self.reason if no other question was supplied.
Uses the description of self.reason if no other help was supplied.
"""
while True: # while no usable input
self.showPrompt()
text = self.userinput(prompt="Choice")
try:
self.checkAbort(text)
except self.SavedException:
break
text = text.strip()
try:
for check in [self.checkDefault,
self.checkHelp,
self.checkOptionNumber,
self.checkOptionDescription,
self.notValidInput]:
check(text)
# Exceptions stop the inner for loop
except self.SavedException:
break
except self.AgainException:
continue
class Default(Command):
"""A default value is saved.
No __init__ method since the Command.__init__ is sufficiant.
Uses infos:
Value: what should be saved. Can include variables.
Addhours, Addmin: see addTime(time)
"""
def do(self):
"""Save the default value.
Understand variables.
If it boils down to an empty string, save nothing (and do not
overwrite any existing value).
TODO: Check if this behavior is useful.
Raises:
ConfigError: if understanding variables failed.
"""
if "Value" not in self:
raise ConfigError("A Default needs a default Value. " +
"Add a line " + self.name + "-Value: " +
"<your Default value> to the field " +
self.reason.name +
" (" + self.name + " in " +
self.reason.name + ")")
value = self.interpret(self["Value"])
if value != "" and value is not None:
# is not None should not happen but who knows
self.reason["Value"] = self.addTime(value)
def addTime(self, time):
"""Add a specified timeshift to time.
If there is a timeshift specified, add this to time.
Attributes:
time (str):
If time is of the form hh:mm, all right.
Otherwise print error message if a time shift is specified.
Uses infos:
Addhours:
must be integer, can be negative. Add this number of hours.
Addmin:
must be integer, can be negative. Add this number of minutes.
Returns:
if time is of the form hh:mm, return (hh+Addhours):(mm+Addmin),
otherwise return time.
"""
try:
split = time.split(":", maxsplit=1)
hours = int(split[0])
minutes = int(split[1])
except (IndexError, ValueError):
if "Addhours" in self or "Addmin" in self:
print("Adding a timeshift for field " +
self.reason.name + " to time '" + time + "' failed" +
" because the original time is not of the form hh:mm.")
return time
else:
try:
hours += int(self["Addhours"])
except ValueError:
raise ConfigError("Option Addhours of field " +
self.reason.name + " is not an integer.")
except KeyError:
pass # do not change hours apparently
try:
minutes += int(self["Addmin"])
except ValueError:
raise ConfigError("Option Addmin of field " +
self.reason.name + " is not an integer.")
except KeyError:
pass # do not change minutes apparently
return str(hours).zfill(2) + ":" + str(minutes).zfill(2)