From ff63aefc87593cdd41d801ece72439e9454225cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Bal=C3=A1=C5=BE?= Date: Sun, 10 Dec 2023 15:41:40 +0000 Subject: [PATCH] Overhaul of context methods --- core/builder/context.py | 82 ++++++++++++------- core/builder/validator.py | 9 +- core/tests/test_context.py | 24 ++++-- core/tests/test_convertor.py | 19 +++-- modules/naboj/builder/contexts/base.py | 8 +- modules/naboj/builder/contexts/buildable.py | 25 +++--- modules/naboj/builder/contexts/hierarchy.py | 23 +++--- modules/naboj/builder/contexts/i18n.py | 6 +- modules/scholar/builder/context.py | 37 ++++----- modules/scholar/builder/contexts/buildable.py | 12 +-- modules/seminar/builder/context.py | 42 ++++++---- modules/seminar/builder/validators.py | 2 +- 12 files changed, 169 insertions(+), 120 deletions(-) diff --git a/core/builder/context.py b/core/builder/context.py index 3d84d116..1ad77c53 100644 --- a/core/builder/context.py +++ b/core/builder/context.py @@ -16,12 +16,22 @@ class Context(metaclass=ABCMeta): defaults = {} # Defaults for every instance - _schema: Schema = None # Validation schema for the context, or None if it is not to be validated + _schema: Schema | None = None # Validation schema for the context, or None if it is not to be validated + _id: str = None + _data: dict = None @property def schema(self) -> Schema: return self._schema + @property + def data(self) -> dict: + return self._data + + @property + def id(self) -> str: + return self._id + @staticmethod def _default(name, func=None, dfl=''): if name is None: @@ -29,12 +39,12 @@ def _default(name, func=None, dfl=''): else: return name if func is None else func(name) - def __init__(self, new_id=None, **defaults: dict): - self.id = new_id - self.data = copy.deepcopy(self.defaults) + def __init__(self, new_id=None, **defaults): + self._id = new_id + self._data = copy.deepcopy(self.defaults) if defaults is not None: - self.add(defaults) + self.add(**defaults) def __str__(self): return f"<{self.__class__.__name__} named '{self.id}'>" @@ -61,29 +71,22 @@ def validate(self) -> None: pprint.pprint(self._schema.schema) raise exc - def add(self, *dictionaries, overwrite=True): - """ Merge a list of dictionaries into this context, overwriting existing keys """ - self.data = dicts.merge(self.data, *dictionaries, overwrite=overwrite) + def add(self, **kwargs): + """ Merge extra key-value pairs into this context, overwriting existing keys """ + self._data |= kwargs return self - def absorb(self, *contexts: Self): - """ Merge a list of other contexts into this context, overwriting existing keys """ - for ctx in contexts: - self.data |= ctx.data - if self._schema is not None: - self._schema |= ctx.schema - return self - - def adopt(self, key, ctx): + def adopt(self, **ctxs: 'Context') -> Self: """ Adopt a new child context `ctx` under the key `key` """ - assert isinstance(ctx, Context) - self.data[key] = dicts.merge(self.data.get(key), ctx.data) - - if self._schema is not None: - # If child has no schema, accept anything, otherwise merge - self._schema |= Schema( - {key: {object: object} if ctx._schema is None else ctx._schema} - ) + for key, ctx in ctxs.items(): + assert isinstance(ctx, Context) + self.data[key] = dicts.merge(self.data.get(key), ctx.data) + + if self._schema is not None: + # If child has no schema, accept anything, otherwise merge + self._schema |= Schema( + {key: {object: object} if ctx._schema is None else ctx._schema} + ) return self def override(self, key, ctx): @@ -94,10 +97,31 @@ def print(self): pprint.pprint(self.data, width=120) def add_number(self, number): - return self.add({'number': number}) + return self.add(number=number) def add_id(self, new_id): - return self.add({'id': new_id}) + return self.add(id=new_id) + + def __eq__(self, other): + return self.data == other.data and self.id == other.id + + def __ior__(self, other): + if not isinstance(other, Context): + return NotImplemented + else: + self._data |= other.data + + if self.schema is None or other.schema is None: + self._schema = None + else: + self._schema |= other.schema + + return self + + def __or__(self, other): + new = copy.deepcopy(self) + new |= other + return new class FileSystemContext(Context, metaclass=abc.ABCMeta): @@ -142,15 +166,15 @@ def load_yaml(self, *args): log.debug(f"Loading {c.name(self.__class__.__name__)} metadata from {c.path(filename)}") try: contents = yaml.load(open(filename, 'r'), Loader=yaml.SafeLoader) - contents = {} if contents is None else contents + self._data = {} if contents is None else contents except FileNotFoundError as e: log.critical(c.err("[FATAL] Could not load YAML file"), c.path(filename)) raise e - self.data = contents return self def load_meta(self, *path): + """ Shorthand for loading the node_path meta.yaml file """ return self.load_yaml(self.node_path(*path) / 'meta.yaml') @abstractmethod diff --git a/core/builder/validator.py b/core/builder/validator.py index fc9ff23b..7896700a 100644 --- a/core/builder/validator.py +++ b/core/builder/validator.py @@ -17,7 +17,7 @@ class FileSystemValidator(metaclass=abc.ABCMeta): IGNORED = ['.git'] - _schema: Schema | None = None + _schema: Schema = None @property def schema(self) -> Schema: @@ -27,7 +27,7 @@ def __init__(self, root): self.root = Path(root) self.tree = self.scan(self.root) - def scan(self, path): + def scan(self, path) -> str | dict | None: if path.name in self.IGNORED: return None if path.is_dir(): @@ -41,6 +41,9 @@ def scan(self, path): return File def validate(self) -> None: + """ + Validate the tree, re-raising the corresponding SchemaError if anything is out of order + """ try: self.schema.validate(self.tree) self.perform_extra_checks() @@ -53,6 +56,6 @@ def validate(self) -> None: def perform_extra_checks(self) -> None: """ Extra checks that are impossible or cumbersome to implement with schema. - Runs *after* schema validation. + Runs *after* schema validation. By default, this is a no-op. """ pass diff --git a/core/tests/test_context.py b/core/tests/test_context.py index c23ef319..4e38c642 100644 --- a/core/tests/test_context.py +++ b/core/tests/test_context.py @@ -1,5 +1,4 @@ import pytest -import math from core.builder.context import Context @@ -8,34 +7,40 @@ def context_empty(): return Context() + @pytest.fixture def context_defaults(): return Context(foo='bar', baz=5) + @pytest.fixture def context_two(): return Context(foo='hotel', qux=7) + @pytest.fixture def context_old(): return Context(boss='Dušan', pictures='Plyš', htr='Kvík') + @pytest.fixture def context_new(): return Context(boss='Marcel', pictures='Terka', nothing='Nina') + @pytest.fixture def context_override(context_empty, context_old, context_new): - context_empty.adopt('fks', context_old) - context_empty.adopt('fks', context_new) + context_empty.adopt(fks=context_old) + context_empty.adopt(fks=context_new) return context_empty + @pytest.fixture def context_numbered(): return Context(id=123, number=456) -class TestContext(): +class TestContext: def test_empty(self, context_empty): assert context_empty.data == {} @@ -75,5 +80,14 @@ def test_add_number_override(self, context_defaults): assert context_defaults.data['number'] == 666 def test_add(self, context_defaults, context_two): - context_defaults.absorb(context_two) + context_defaults |= context_two assert context_defaults.data == dict(foo='hotel', baz=5, qux=7) + + def test_ior(self): + first = Context('fks', boss='Matúš', coffee='Nina') + second = Context('htr', pictures='Terka', htr='Kvík', iy='Krto') + assert first | second == Context('fks', boss='Matúš', coffee='Nina', pictures='Terka', htr='Kvík', iy='Krto') + + def test_or(self): + """ Note that or'ed contexts retain the parent's name but override items with child's """ + assert Context('foo', bar='mitzvah') | Context('baz', bar='baron') == Context('foo', bar='baron') \ No newline at end of file diff --git a/core/tests/test_convertor.py b/core/tests/test_convertor.py index c5a971fe..58106cbf 100644 --- a/core/tests/test_convertor.py +++ b/core/tests/test_convertor.py @@ -20,6 +20,7 @@ def _convert(fmt, language, string): class DisabledTestQuotes: + """ These tests are currently disabled: we have switched to `csquotes` """ def test_math_plus(self, convert): assert convert('latex', 'sk', '"+"') == r'„+“' + '\n' @@ -46,7 +47,8 @@ def test_english_interpunction(self, convert): assert convert('latex', 'en', 'Ale "to" je "dobré", "nie."?') == r'Ale “to” je “dobré”, “nie.”?' + '\n' def test_french_interpunction(self, convert): - assert convert('latex', 'fr', 'Ale "to" je "dobré", "nie."?') == r'Ale «\,to\,» je «\,dobré\,», «\,nie.\,»?' + '\n' + assert (convert('latex', 'fr', 'Ale "to" je "dobré", "nie."?') == + r'Ale «\,to\,» je «\,dobré\,», «\,nie.\,»?' + '\n') def test_spanish_interpunction(self, convert): assert convert('latex', 'es', 'Ale "to" je "dobré", "nie."?') == r'Ale «to» je «dobré», «nie.»?' + '\n' @@ -56,7 +58,7 @@ def test_english(self, convert): assert output == r'Máme “dačo” a “niečo”. “Čo také?” \emph{“Asi nič.”}' + '\n' -class TestImages(): +class TestImages: def test_image_latex(self, convert): output = convert('latex', 'sk', '![Masívna ryba](ryba.svg){#fig:ryba height=47mm}') assert r'\insertPicture[width=\textwidth,height=47mm]{ryba.pdf}' in output @@ -67,11 +69,12 @@ def test_image_latex_multiline(self, convert): Veľmi masívne. Aj s newlinami.](subor.png){#fig:dlhy height=53mm} """) - assert re.search(r'\\insertPicture\[width=\\textwidth,height=53mm\]{subor\.png}', output) is not None + assert re.search(r'\\insertPicture\[width=\\textwidth,height=53mm]{subor\.png}', output) is not None def test_image_html(self, convert): output = convert('html', 'sk', '![Masívna ryba](ryba.svg){#fig:ryba height=47mm}') - assert re.match(r'
.*.*
', output, flags=re.DOTALL) is not None + assert re.match(r'
.*.*
', + output, flags=re.DOTALL) is not None def test_image_html_multiline(self, convert): output = convert('html', 'sk', """ @@ -79,13 +82,13 @@ def test_image_html_multiline(self, convert): Veľmi masívne. Aj s newlinami.](subor.png){#fig:dlhy height=53mm} """) - # Pandoc can split lines but we cannot tell how, so we replace newlines with spaces + # Pandoc can split lines, but we cannot tell how, so we replace newlines with spaces output = output.replace('\n', ' ') assert re.match(r'
.*.*
', output) is not None -class TestTags(): +class TestTags: def test_h_latex(self, convert): output = convert('latex', 'en', '@H this should not be seen!') assert output == '\n' @@ -104,7 +107,7 @@ def test_l_html(self, convert): def test_e_latex(self, convert): output = convert('latex', 'sk', '@E error') - assert re.match(r'\\errorMessage\{error\}\n', output) is not None + assert re.match(r'\\errorMessage\{error}\n', output) is not None def test_e_html(self, convert): output = convert('html', 'sk', '@E error') @@ -112,4 +115,4 @@ def test_e_html(self, convert): def test_aligned(self, convert): output = convert('latex', 'sk', '$${\na\n}$$') - assert re.match(r'\\\[.*\\begin\{aligned\}\na\n\\end\{aligned\}.*\\\]', output, flags=re.DOTALL) is not None + assert re.match(r'\\\[.*\\begin\{aligned}\na\n\\end\{aligned}.*\\]', output, flags=re.DOTALL) is not None diff --git a/modules/naboj/builder/contexts/base.py b/modules/naboj/builder/contexts/base.py index 1013c18d..9e0819f6 100644 --- a/modules/naboj/builder/contexts/base.py +++ b/modules/naboj/builder/contexts/base.py @@ -59,8 +59,8 @@ def populate(self, repo_root: str): - dgs branch and git hash - repo branch and git hash """ - self.add({ - 'build': { + self.add( + build={ 'user': os.environ.get('USERNAME'), 'dgs': { 'hash': get_last_commit_hash(), @@ -71,8 +71,8 @@ def populate(self, repo_root: str): 'branch': get_branch(self.node_path(repo_root)), }, 'timestamp': datetime.datetime.now(datetime.timezone.utc), - } - }) + }, + ) def as_tuple(self, competition: str = None, volume: int = None, sub: str = None, issue: str = None): assert competition in ContextNaboj.competitions diff --git a/modules/naboj/builder/contexts/buildable.py b/modules/naboj/builder/contexts/buildable.py index 591f2eb1..48826e08 100644 --- a/modules/naboj/builder/contexts/buildable.py +++ b/modules/naboj/builder/contexts/buildable.py @@ -14,9 +14,11 @@ class BuildableContextNaboj(BuildableFileSystemContext, ContextNaboj, metaclass= def populate(self, competition, volume): super().populate(competition) - self.adopt('module', ContextModule('naboj')) - self.adopt('competition', ContextCompetition(self.root, competition)) - self.adopt('volume', ContextVolume(self.root, competition, volume)) + self.adopt( + module=ContextModule('naboj'), + competition=ContextCompetition(self.root, competition), + volume=ContextVolume(self.root, competition, volume), + ) class BuildableContextLanguage(BuildableContextNaboj): @@ -30,8 +32,10 @@ def __init__(self, root, *args): def populate(self, competition, volume, language): super().populate(competition, volume) - self.adopt('language', ContextLanguage(self.root, competition, volume, language)) - self.adopt('i18n', ContextI18nGlobal(self.root, competition)) + self.adopt( + language=ContextLanguage(self.root, competition, volume, language), + i18n=ContextI18nGlobal(self.root, competition), + ) class BuildableContextVenue(BuildableContextNaboj): @@ -45,12 +49,11 @@ def __init__(self, root, *args): def populate(self, competition, volume, venue): super().populate(competition, volume) - self.adopt('venue', ContextVenue(self.root, competition, volume, venue) - .override('start', self.data['volume']['start'])) - self.adopt('i18n', ContextI18nGlobal(self.root, competition)) - self.add({ - 'language': i18n.languages[self.data['venue']['language']].as_dict() - }) + self.adopt( + venue=ContextVenue(self.root, competition, volume, venue) + .override('start', self.data['volume']['start']), + i18n=ContextI18nGlobal(self.root, competition) + ).add(language=i18n.languages[self.data['venue']['language']].as_dict()) if 'start' not in self.data['venue']: self.data['venue']['start'] = self.data['volume']['start'] diff --git a/modules/naboj/builder/contexts/hierarchy.py b/modules/naboj/builder/contexts/hierarchy.py index 369fe707..f29eb565 100644 --- a/modules/naboj/builder/contexts/hierarchy.py +++ b/modules/naboj/builder/contexts/hierarchy.py @@ -63,9 +63,11 @@ class ContextLanguage(ContextNaboj): def populate(self, competition, volume, language): super().populate(competition) self.load_meta(competition, volume, language) \ - .add_id(language) - self.add({'name': i18n.languages[language].name}) - self.add({'rtl': i18n.languages[language].rtl}), + .add_id(language) \ + .add( + name=i18n.languages[language].name, + rtl=i18n.languages[language].rtl, + ) class ContextVenue(ContextNaboj): @@ -118,16 +120,16 @@ def populate(self, competition, volume, venue): self.load_meta(competition, volume, venue) \ .add_id(venue) self._add_extra_teams(comp, vol) - self.add({ - 'teams': lists.numerate(self.data.get('teams'), itertools.count(0)), - 'teams_grouped': lists.split_div( + self.add( + teams=lists.numerate(self.data.get('teams'), itertools.count(0)), + teams_grouped=lists.split_div( lists.numerate(self.data.get('teams')), comp.data['tearoff']['per_page'] ), - 'problems_modulo': lists.split_mod( + problems_modulo=lists.split_mod( lists.add_numbers([x['id'] for x in vol.data['problems']], itertools.count(1)), self.data['evaluators'], first=1, ), - }) + ) class ContextVolume(ContextNaboj): @@ -144,11 +146,8 @@ class ContextVolume(ContextNaboj): def populate(self, competition, volume): super().populate(competition) - comp = ContextCompetition(self.root, competition) self.load_meta(competition, volume) \ .add_id(f'{volume:02d}') \ .add_number(volume) - self.add(dict( - problems=lists.add_numbers(self.data['problems'], itertools.count(1)), - )) + self.add(problems=lists.add_numbers(self.data['problems'], itertools.count(1))) diff --git a/modules/naboj/builder/contexts/i18n.py b/modules/naboj/builder/contexts/i18n.py index 2a875337..542b56ca 100644 --- a/modules/naboj/builder/contexts/i18n.py +++ b/modules/naboj/builder/contexts/i18n.py @@ -66,5 +66,7 @@ def node_path(self, competition): return Path(self.root, competition, '.static', 'i18n') def populate(self, competition): - for language in [x.stem for x in self.node_path(competition).glob('*.yaml')]: - self.adopt(language, ContextI18n(self.root, competition, language)) + self.adopt(**{ + language: ContextI18n(self.root, competition, language) + for language in [x.stem for x in self.node_path(competition).glob('*.yaml')] + }) diff --git a/modules/scholar/builder/context.py b/modules/scholar/builder/context.py index 0570c36c..59efdf52 100644 --- a/modules/scholar/builder/context.py +++ b/modules/scholar/builder/context.py @@ -3,8 +3,7 @@ sys.path.append('.') -from core.builder.context import FileSystemContext, BuildableFileSystemContext -from core.utilities import crawler +from core.builder.context import BuildableFileSystemContext class ContextScholarSingle(BuildableFileSystemContext): @@ -15,15 +14,16 @@ def node_path(root, course='', lecture='', part='', problem=''): class ContextScholarLecture(ContextScholarSingle): def populate(self, course, lecture): - self.load_meta(course, lecture) - self.adopt('module', ContextSingleModule(self.root, 'scholar')) - self.adopt('course', ContextSingleCourse(self.root, course)) - self.adopt('lecture', ContextSingleLecture(self.root, course, lecture)) + self.load_meta(course, lecture).adopt( + module=ContextSingleModule(self.root, 'scholar'), + course=ContextSingleCourse(self.root, course), + lecture=ContextSingleLecture(self.root, course, lecture), + ) if 'parts' in self.data: - self.add({'parts': [ - ContextScholarPart(self.root, course, lecture, part).data for part in self.data['parts'] - ]}) + self.add( + parts=[ContextScholarPart(self.root, course, lecture, part).data for part in self.data['parts']] + ) else: self.add_subdirs(ContextScholarPart, 'parts', self.root, course, lecture) @@ -40,9 +40,11 @@ class ContextScholarProblem(ContextScholarSingle): def populate(self, course, lecture, part, problem): self.name(course, lecture, part, problem) self.load_meta(course, lecture, part, problem) \ - .add_id(problem) - self.add({'has_problem': Path(self.root, course, lecture, part, problem, 'problem.md').is_file()}) - self.add({'has_solution': Path(self.root, course, lecture, part, problem, 'solution.md').is_file()}) + .add_id(problem) \ + .add( + has_problem=Path(self.root, course, lecture, part, problem, 'problem.md').is_file(), + has_solution=Path(self.root, course, lecture, part, problem, 'solution.md').is_file(), + ) class ContextSingleModule(ContextScholarSingle): @@ -58,16 +60,7 @@ def populate(self, root, course): class ContextSingleLecture(ContextScholarSingle): def populate(self, root, course, lecture): self.load_meta(root, course, lecture).add_id(lecture) - self.add({'has_abstract': Path(root, course, lecture, 'abstract.md').is_file()}) - - -class ContextDir(FileSystemContext): - def populate(self, root, *deeper): - self.load_meta(root, *deeper) \ - .add_id(deeper[-1] if deeper else root) - - crawl = crawler.Crawler(Path(root, *deeper)) - self.add({'children': ContextDir(root, *deeper, child).data for child in crawl.children()}) + self.add(has_abstract=Path(root, course, lecture, 'abstract.md').is_file()) @staticmethod def node_path(*args): diff --git a/modules/scholar/builder/contexts/buildable.py b/modules/scholar/builder/contexts/buildable.py index e7398490..2aac001c 100644 --- a/modules/scholar/builder/contexts/buildable.py +++ b/modules/scholar/builder/contexts/buildable.py @@ -12,8 +12,10 @@ class ContextIssueBase(BuildableFileSystemContext, ContextScholar, metaclass=ABC _validator_class = ScholarValidator def populate(self, course: str, year: int, issue: int): - self.adopt('module', ContextModule('scholar')) - self.adopt('course', ContextCourse(self.root, course)) - self.adopt('year', ContextYear(self.root, course, year)) - self.adopt('i18n', ContextI18n(self.root, self.data['course']['language'])) - self.adopt('issue', self._issue_context_class(self.root, course, year, issue)) + self.adopt( + module=ContextModule('scholar'), + course=ContextCourse(self.root, course), + year=ContextYear(self.root, course, year), + i18n=ContextI18n(self.root, self.data['course']['language']), + issue=self._issue_context_class(self.root, course, year, issue), + ) diff --git a/modules/seminar/builder/context.py b/modules/seminar/builder/context.py index 4df680c6..72b46317 100644 --- a/modules/seminar/builder/context.py +++ b/modules/seminar/builder/context.py @@ -82,22 +82,22 @@ class ContextSemester(ContextSeminar): }) def populate(self, competition, volume, semester): - self.id = str(semester) + self._id = str(semester) self.load_meta(competition, volume, semester) \ .add_id(self.id) \ .add_number(semester) # Add fancy names for the semesters # Maybe this should be a Jinja filter...? - self.add({ - 'neuter': { + self.add( + neuter={ 'nominative': ['zimné', 'letné'][semester - 1], 'genitive': ['zimného', 'letného'][semester - 1], }, - 'feminine': { + feminine={ 'nominative': ['zimná', 'letná'][semester - 1], 'genitive': ['zimnej', 'letnej'][semester - 1], }, - }) + ) class ContextSemesterFull(ContextSemester, BuildableFileSystemContext): @@ -156,7 +156,7 @@ def populate(self, competition, volume, semester, issue, problem): vol = ContextVolume(self.root, competition, volume) categories = vol.data['categories'] - self.add({'categories': categories[problem - 1]}) + self.add(categories=categories[problem - 1]) class ContextRoundFull(ContextRound): @@ -177,17 +177,21 @@ def populate(self, competition, volume, semester, issue): class ContextVolumeBooklet(BuildableFileSystemContext, ContextSeminar): def populate(self, root, competition, volume): - self.adopt('module', ContextModule('seminar')) - self.adopt('competition', ContextCompetition(root, competition)) - self.adopt('volume', ContextVolume(root, competition, volume)) + self.adopt( + module=ContextModule('seminar'), + competition=ContextCompetition(root, competition), + volume=ContextVolume(root, competition, volume), + ) class ContextSemesterBooklet(BuildableFileSystemContext, ContextSeminar): def populate(self, root, competition, volume, semester): - self.adopt('module', ContextModule('seminar')) - self.adopt('competition', ContextCompetition(root, competition)) - self.adopt('volume', ContextVolume(root, competition, volume)) - self.adopt('semester', ContextSemesterFull(root, competition, volume, semester)) + self.adopt( + module=ContextModule('seminar'), + competition=ContextCompetition(root, competition), + volume=ContextVolume(root, competition, volume), + semester=ContextSemesterFull(root, competition, volume, semester), + ) class ContextBooklet(BuildableFileSystemContext, ContextSeminar): @@ -197,8 +201,10 @@ class ContextBooklet(BuildableFileSystemContext, ContextSeminar): def populate(self, competition, volume, semester, issue): super().populate(competition) - self.adopt('module', ContextModule('seminar')) - self.adopt('competition', ContextCompetition(self.root, competition)) - self.adopt('volume', ContextVolume(self.root, competition, volume)) - self.adopt('semester', ContextSemester(self.root, competition, volume, semester)) - self.adopt('round', ContextRoundFull(self.root, competition, volume, semester, issue)) + self.adopt( + module=ContextModule('seminar'), + competition=ContextCompetition(self.root, competition), + volume=ContextVolume(self.root, competition, volume), + semester=ContextSemester(self.root, competition, volume, semester), + round=ContextRoundFull(self.root, competition, volume, semester, issue), + ) diff --git a/modules/seminar/builder/validators.py b/modules/seminar/builder/validators.py index 0533d08e..f482433a 100644 --- a/modules/seminar/builder/validators.py +++ b/modules/seminar/builder/validators.py @@ -12,4 +12,4 @@ class SeminarRoundValidator(FileSystemValidator): 'meta.yaml': File, }, 'meta.yaml': File, - }) \ No newline at end of file + })