Skip to content

Commit

Permalink
Implement YAML templates
Browse files Browse the repository at this point in the history
- Created Template class which defines a series YAML template
- Modified PreferenceParser to read/apply templates when reading/yielding Show objects
- Implements #90
  • Loading branch information
CollinHeist committed Apr 25, 2022
1 parent 524ef00 commit b05ea5b
Show file tree
Hide file tree
Showing 3 changed files with 227 additions and 11 deletions.
80 changes: 71 additions & 9 deletions modules/PreferenceParser.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from pathlib import Path
from re import findall

from tqdm import tqdm
from yaml import safe_load
Expand All @@ -8,6 +9,7 @@
from modules.ImageMaker import ImageMaker
from modules.Show import Show
from modules.ShowSummary import ShowSummary
from modules.Template import Template
from modules.TitleCard import TitleCard
from modules.TMDbInterface import TMDbInterface
from modules.YamlReader import YamlReader
Expand Down Expand Up @@ -212,6 +214,46 @@ def __parse_yaml(self) -> None:
self.imagemagick_container = value


def __apply_template(self, templates: dict, series_yaml: dict,
series_name: str) -> bool:
"""
Apply the correct Template object (if indicated) to the given series
YAML. This effectively "fill out" the indicated template, and updates
the series YAML directly.
:param templates: Dictionary of Template objects to
potentially apply.
:param series_yaml: The YAML of the series to modify.
:param series_name: The name of the series being modified.
:returns: True if the given series contained all the required template
variables for application, False if it did not.
"""

# No templates defined, skip
if templates == {} or 'template' not in series_yaml:
return True

# Get the specified template for this series
if isinstance((series_template := series_yaml['template']), str):
# Assume if only a string, then its the template name
series_template = {'name': series_template}
series_yaml['template'] = series_template

# Warn and return if no template name given
if not (template_name := series_template.get('name', None)):
log.error(f'Missing template name for "{series_name}"')
return False

# Warn and return if template name not mapped
if not (template := templates.get(template_name, None)):
log.error(f'Template "{template_name}" not defined')
return False

# Apply using Template object
return template.apply_to_series(series_name, series_yaml)


def read_file(self) -> None:
"""
Reads this associated preference file and store in `__yaml` attribute.
Expand Down Expand Up @@ -243,41 +285,60 @@ def iterate_series_files(self) -> [Show]:
"""

# For each file in the cards list
for file in (pbar := tqdm(self.series_files, **TQDM_KWARGS)):
for file_ in (pbar := tqdm(self.series_files, **TQDM_KWARGS)):
# Create Path object for this file
file_object = Path(file)
file = Path(file_)

# Update progress bar for this file
pbar.set_description(f'Reading {file_object.name}')
pbar.set_description(f'Reading {file.name}')

# If the file doesn't exist, error and skip
if not file_object.exists():
log.error(f'Series file "{file_object.resolve()}" does not '
if not file.exists():
log.error(f'Series file "{file.resolve()}" does not '
f'exist')
continue

# Read file, parse yaml
if (file_yaml := self._read_file(file_object)) == {}:
if (file_yaml := self._read_file(file)) == {}:
continue

# Skip if there are no series to yield
if file_yaml is None or 'series' not in file_yaml:
log.warning(f'Series file has no entries')
log.info(f'Series file has no entries')
continue

# Get library map for this file; error+skip missing library paths
if (library_map := file_yaml.get('libraries', {})):
if not all('path' in library_map[lib] for lib in library_map):
log.error(f'Libraries in series file "{file_object.resolve()}'
f'"are missing their "path" attributes.')
log.error(f'Libraries are missing required "path" in series'
f' file "{file.resolve()}"')
continue

# Get font map for this file
font_map = file_yaml.get('fonts', {})

# Get templates for this file
templates = {}
for name, template in file_yaml.get('templates', {}).items():
# If not specified as dictionary, error and skip
if not isinstance(template, dict):
log.error(f'Invalid template specification for "{name}" in '
f'series file "{file.resolve()}"')
continue
templates[name] = Template(name, template)

# Go through each series in this file
for show_name in tqdm(file_yaml['series'], desc='Creating Shows',
**TQDM_KWARGS):
# Apply template to series
valid = self.__apply_template(
templates, file_yaml['series'][show_name], show_name,
)

# Skip if series is not valid
if not valid:
continue

# Yield the Show object created from this entry
yield Show(
show_name,
Expand Down Expand Up @@ -326,3 +387,4 @@ def get_season_folder(self, season_number: int) -> str:

# Return non-zero-padded season name
return f'Season {season_number}'

4 changes: 2 additions & 2 deletions modules/Show.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,11 @@ def __init__(self, name: str, yaml_dict: dict, library_map: dict,
return None

# Year is given, parse and update year/full name of this show
if not isinstance(year, int) or year <= 0:
if not isinstance(year, int) or year < 0:
log.error(f'Year "{year}" of series "{name}" is invalid')
self.valid = False
return None

# Setup default values that can be overwritten by YAML
self.series_info = SeriesInfo(name, year)
self.media_directory = None
Expand Down
154 changes: 154 additions & 0 deletions modules/Template.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
from re import findall

from modules.Debug import log

class Template:
"""
This class describes a template. A Template is a fallback YAML object that
can be "filled in" with values, or just outright contain them. Variable
data is encoded in the form <<{key}>>. When applied to some series YAML
dictionary, the template'd YAML is applied to the series, unless both have
instances of the data, in which the series data takes priority.
"""

def __init__(self, name: str, template: dict) -> None:
"""
Construct a new Template object with the given name, and with the given
template dictionary. Keys of the form <<{key}>> are search for through
this template.
:param name: The template name/identifier. For logging only.
:param template: The template YAML to implement.
"""

self.name = name
self.__template = template
self.__keys = self.__identify_template_keys(self.__template, set())


def __repr__(self) -> str:
"""Returns a unambiguous string representation of the object."""

return f'<Template {self.name=}, {self.__keys=}, {self.__template=}>'


def __identify_template_keys(self, template: dict, keys: set) -> set:
"""
Identify the required template keys to use this template. This looks for
all unique values like "<<{key}>>". This is a recursive function, and
searches through all sub-dictionaries of template.
:param template: The template dictionary to search through.
:param keys: The existing keys identified, only used for
recursion.
:returns: Set of keys required by the given template.
"""

for _, value in template.items():
# If this attribute value is just a string, add keys to set
if (isinstance(value, str)
and (new_keys := findall(r'<<(.+?)>>', value))):
keys.update(new_keys)
elif isinstance(value, dict):
# Recurse through this sub-attribute
keys.update(self.__identify_template_keys(value, keys))

return keys


def __apply_value_to_key(self, template: dict, key: str, value) -> None:
"""
Apply the given value to all instances of the given key in the template.
This looks for <<{key}>>, and puts value in place. This function is
recursive, so if any values of template are dictionaries, those are
applied as well. For example:
>>> temp = {'year': <<year>>, 'b': {'b1': False, 'b2': 'Hey <<year>>'}}
>>> __apply_value_to_key(temp, 'year', 1234)
>>> temp
{'year': 1234, 'b': 'b1': False, 'b2': 'Hey 1234'}
:param template: The dictionary to modify any instances of
<<{key}>> within. Modified in-place.
:param key: The key to search/replace for.
:param value: The value to replace the key with.
"""

for t_key, t_value in template.items():
# log.info(f'template[{t_key}]={t_value}, {type(t_value)=}')
if isinstance(t_value, str):
# If the templated value is JUST the replacement, just copy over
if t_value == f'<<{key}>>':
template[t_key] = value
else:
template[t_key] = t_value.replace(f'<<{key}>>', str(value))
elif isinstance(t_value, dict):
# Template'd value is dictionary, recurse
self.__apply_value_to_key(template[t_key], key, value)


def __recurse_priority_union(self, base_yaml: dict,
template_yaml: dict) -> None:
"""
Construct the union of the two dictionaries, with all key/values of
template_yaml being ADDED to the first, priority dictionary IF that
specific key is not already present. This is a recurisve function that
applies to any arbitrary set of nested dictionaries. For example:
>>> base_yaml = {'a': 123, 'c': {'c1': False}}
>>> t_yaml = {'a': 999, 'b': 234, 'c': {'c2': True}}
>>> __recurse_priority_union(base_yaml, )
>>> base_yaml
{'a': 123, 'b': 234, 'c': {'c1': False, 'c2': True}}
:param base_yaml: The base - i.e. higher priority, YAML that
forms the basis of the union of these
dictionaries. Modified in-place.
:param template_yaml: The templated - i.e. lower priority - YAML.
"""

# Go through each key in template and add to priority YAML if not present
for t_key, t_value in template_yaml.items():
if t_key in base_yaml:
if isinstance(t_value, dict):
# Both have this dictionary, recurse on keys of dictionary
self.__recurse_priority_union(base_yaml[t_key], t_value)
else:
# Key is not present in base, carryover template value
base_yaml[t_key] = t_value


def apply_to_series(self, series_name: str, series_yaml: dict) -> bool:
"""
Apply this Template object to the given series YAML, modifying it
to include the templated values. This function assumes that the given
series YAML has a template attribute, and that it applies to this object
:param series_name: The name of the series being modified.
:param series_yaml: The series YAML to modify. Must have
'template' key. Modified in-place.
:returns: True if the given series contained all the required template
variables for application, False if it did not.
"""

# If not all required template keys are specified, warn and exit
if not set(series_yaml['template'].keys()).issuperset(self.__keys):
log.warning(f'Missing template data for "{series_name}"')
return False

# Take given template values, fill in template object
modified_template = self.__template.copy()
for key, value in series_yaml['template'].items():
self.__apply_value_to_key(modified_template, key, value)

# Delete the template section from the series YAML
del series_yaml['template']

# Construct union of series and filled-in template YAML
self.__recurse_priority_union(series_yaml, modified_template)

return True


0 comments on commit b05ea5b

Please sign in to comment.