#!/usr/bin/env python3
# -*- coding: utf-8 -*-
# ---------------------------------------------------------------------
#
# _____ _ _ _ _____ _____
# | ____|__| | ___| |_ _____(_)___ ___| ___| ____|
# | _| / _` |/ _ \ \ \ /\ / / _ \ / __/ __| |_ | _|
# | |__| (_| | __/ |\ V V / __/ \__ \__ \ _| | |___
# |_____\__,_|\___|_| \_/\_/ \___|_|___/___/_| |_____|
#
#
# Unit of Strength of Materials and Structural Analysis
# University of Innsbruck,
# 2017 - today
#
# Matthias Neuner matthias.neuner@uibk.ac.at
# Paul Hofer Paul.Hofer@uibk.ac.at
#
# This file is part of EdelweissFE.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# The full text of the license can be found in the file LICENSE.md at
# the top level directory of EdelweissFE.
# ---------------------------------------------------------------------
"""
Inputfileparser for inputfiles employing an Abaqus-like syntax.
"""
import dataclasses
from os.path import dirname, join
from edelweissfe.config import registry
from edelweissfe.config.registry import RegistryConflictError, RegistryLookupError
from edelweissfe.utils.caseinsensitivedict import CaseInsensitiveDict
from edelweissfe.utils.misc import (
caseInsensitiveKwargsChecker,
caseInsensitiveRequiredArgsChecker,
convertAssignmentsToCaseInsensitiveStringDictionary,
splitLineAtCommas,
strCaseCmp,
)
from edelweissfe.utils.schema import (
coerceValue,
datalineFieldMeta,
fieldSchemaMeta,
subKeywordFieldNames,
)
#: The ``.inp`` syntax markers: a top-level keyword line starts with ``*``, a sub-keyword line with ``>>``.
keywordIdentifier = "*"
moduleLevelKeywordIdentifier = ">>"
#: Top-level keywords whose grammar is completed by a dispatched target's own schema: maps the
#: keyword's casefolded name to ``(registry category, the keyword-line option carrying the dispatch value)``.
#: ``step`` is included only for :func:`_expectsPlainDatalines`; its ``>>`` grammar always resolves
#: via the ``stepaction`` category.
_DISPATCH_CATEGORY_BY_KEYWORD = {
"output": ("outputmanager", "type"),
"section": ("section", "type"),
"analyticalfield": ("analyticalfield", "type"),
"constraint": ("constraint", "type"),
"modelmodifier": ("modelmodifier", "type"),
"modelgenerator": ("generator", "generator"),
"step": ("step", "type"),
"linearsolver": ("linsolver", "type"),
}
def _updateStepActionSchema(moduleKeyword: str):
"""Return the dedicated ``update<keyword>`` schema for a step action's partial re-declaration
in a later step, or ``None`` if this step action has no such companion.
Only three step actions declare one at all (``dirichlet``, ``distributedload``,
``nodeforces``) -- every other step action either has no update path (a re-declaration must
restate every required argument again) or -- ``options`` -- validates dynamically and never
reaches this function at all (see the ``isDynamicOptionsKeyword`` branch of
:func:`parseModuleKeywordLine`). A small explicit table, not a registry category: these
``Update<Name>Schema`` classes are documentation/parser-validation companions the step action's
own constructor never references (see e.g. ``stepactions/dirichlet.py``'s own docstring), so
they have no home in the ``stepaction`` registry, which maps to the step action classes
themselves.
Parameters
----------
moduleKeyword
The step action's own name, as written in the ``.inp`` file (e.g. ``"dirichlet"``).
Returns
-------
type | None
The update schema, or ``None``.
"""
name = moduleKeyword.casefold()
if name == "dirichlet":
from edelweissfe.stepactions.dirichlet import UpdateDirichletSchema
return UpdateDirichletSchema
if name == "distributedload":
from edelweissfe.stepactions.distributedload import UpdateDistributedloadSchema
return UpdateDistributedloadSchema
if name == "nodeforces":
from edelweissfe.stepactions.nodeforces import UpdateNodeforcesSchema
return UpdateNodeforcesSchema
return None
def _allScalarFields(schemaCls: type | None) -> dict:
"""Map every input-file option name of ``schemaCls``'s own scalar (non-dataline,
non-sub-keyword) fields to its :class:`dataclasses.Field`.
Not :func:`~edelweissfe.utils.schema.scalarOptionNames`: that function excludes
``structuralOnly`` fields (the adapter pops them before ``buildSchemaFromOptions`` ever sees
them), but the *parser* must still recognize and require every scalar option a schema declares,
with no such distinction at all (``structuralOnly``/``optionsOverrideOnly``/``updateOnly`` are
schema-only rendering/construction concerns, see ``utils/schema.py``'s ``SchemaFieldMeta``).
Parameters
----------
schemaCls
A frozen dataclass schema, or ``None`` (a keyword/module declaring no line options at all).
Returns
-------
dict
Maps option name to field, in declaration order. Empty if ``schemaCls`` is ``None``.
"""
if schemaCls is None:
return {}
fields = {}
for field in dataclasses.fields(schemaCls):
meta = fieldSchemaMeta(field)
if meta.isDataline or meta.subSchema is not None:
continue
fields[meta.optionName or field.name] = field
return fields
def _realScalarFields(schemaCls: type | None) -> dict:
"""Like :func:`_allScalarFields`, but excluding ``optionsOverrideOnly``/``updateOnly`` fields --
the ones that exist purely for the ``>>options`` override mechanism or the ``update<keyword>``
grammar and were never part of a keyword's own real line/dataline grammar (whose presence is
what implies a dispatch target actually expects plain datalines, see
:func:`_expectsPlainDatalines`).
Parameters
----------
schemaCls
A frozen dataclass schema, or ``None``.
Returns
-------
dict
Maps option name to field, in declaration order. Empty if ``schemaCls`` is ``None``.
"""
return {
name: field
for name, field in _allScalarFields(schemaCls).items()
if not fieldSchemaMeta(field).optionsOverrideOnly and not fieldSchemaMeta(field).updateOnly
}
def _requiredAndOptionalNames(schemaCls: type | None) -> tuple[list, list]:
"""Split :func:`_allScalarFields`'s option names into required/optional lists, the shape
:func:`~edelweissfe.utils.misc.caseInsensitiveKwargsChecker` expects.
Parameters
----------
schemaCls
A frozen dataclass schema, or ``None``.
Returns
-------
tuple[list, list]
``(requiredNames, optionalNames)``.
"""
fields = _allScalarFields(schemaCls)
required = [name for name, field in fields.items() if fieldSchemaMeta(field).required]
optional = [name for name, field in fields.items() if not fieldSchemaMeta(field).required]
return required, optional
def _coerceKnownFields(schemaCls: type | None, options: dict) -> CaseInsensitiveDict:
"""Coerce and default the keys of ``options`` that match one of ``schemaCls``'s own scalar
fields; every other key is left untouched, as a raw string.
Parameters
----------
schemaCls
A frozen dataclass schema, or ``None`` (nothing is coerced or defaulted).
options
A mapping of (possibly mis-cased) option names to raw, typically string, values.
Returns
-------
CaseInsensitiveDict
``options``, with its known keys coerced to their declared type and every missing optional
field defaulted.
"""
options = CaseInsensitiveDict(options)
for optionName, field in _allScalarFields(schemaCls).items():
meta = fieldSchemaMeta(field)
if optionName in options:
options[optionName] = coerceValue(options[optionName], meta.dtype)
elif not meta.required:
options[optionName] = field.default if field.default is not dataclasses.MISSING else None
return options
def _resolveDispatchSchemaForKeywordLine(keyword: str, options: dict) -> type | None:
"""Resolve the schema of the ``type=``/``generator=``-dispatched target hosted by a
top-level keyword's line, so a value meant for *that* target's own options (e.g. a plane
section's ``thickness``) written directly on the ``*section`` line is not rejected as unknown.
Parameters
----------
keyword
The top-level keyword being parsed.
options
Its already-coerced-so-far option mapping (used only to read the dispatch value).
Returns
-------
type | None
The dispatch target's schema, or ``None`` if this keyword has no such dispatch, the
dispatch value is missing, or it does not resolve to anything registered.
"""
dispatch = _DISPATCH_CATEGORY_BY_KEYWORD.get(keyword.casefold())
if dispatch is None:
return None
category, optionName = dispatch
dispatchValue = options.get(optionName)
if not dispatchValue:
return None
try:
_, schema = registry.lookup(category, dispatchValue)
except RegistryConflictError:
# a name conflict is an error, not a miss
raise
except RegistryLookupError:
return None
return schema
def _resolveModuleKeywordSchema(topLevelKeyword: str, topLevelOptions: dict, moduleKeyword: str) -> type | None:
"""Resolve the schema describing one ``>>`` sub-keyword block's own grammar.
For ``*step``, every step action registers identically onto every step type, so its ``>>``
grammar is resolved directly against the ``stepaction`` registry category, independent of the
step's own ``type=``. Every other keyword's ``>>`` grammar is a
:func:`~edelweissfe.utils.schema.subKeywordField` of either the dispatch target's own schema
(resolved via ``type=``/``generator=``, e.g. ensight's ``>>perNode``) or, for a keyword with no
such dispatch (``*fieldOutput``), the keyword's own schema directly.
Parameters
----------
topLevelKeyword
The enclosing top-level keyword (e.g. ``"output"``, ``"step"``).
topLevelOptions
The enclosing top-level keyword's own (already coerced) option mapping.
moduleKeyword
The ``>>`` sub-keyword's own name, as written in the ``.inp`` file.
Returns
-------
type | None
The sub-keyword's own schema.
Raises
------
ValueError
If ``moduleKeyword`` does not name a known ``>>`` sub-keyword in this context.
"""
if strCaseCmp(topLevelKeyword, "step"):
try:
_, schema = registry.lookup("stepaction", moduleKeyword)
except RegistryLookupError as e:
raise ValueError(str(e)) from e
return schema
_, keywordSchema = registry.lookup("keyword", topLevelKeyword)
dispatch = _DISPATCH_CATEGORY_BY_KEYWORD.get(topLevelKeyword.casefold())
if dispatch is None:
hostSchema = keywordSchema
else:
category, optionName = dispatch
dispatchValue = topLevelOptions.get(optionName)
try:
_, hostSchema = registry.lookup(category, dispatchValue)
except RegistryLookupError as e:
raise ValueError(str(e)) from e
subFields = subKeywordFieldNames(hostSchema) if hostSchema is not None else {}
foldedFields = {name.casefold(): field for name, field in subFields.items()}
field = foldedFields.get(moduleKeyword.casefold())
if field is None:
available = ", ".join(sorted(subFields)) or "none"
raise ValueError(
f"'{moduleLevelKeywordIdentifier}{moduleKeyword}' is not a valid module keyword for "
f"'{keywordIdentifier}{topLevelKeyword}'. Available: {available}."
)
return fieldSchemaMeta(field).subSchema
def _dispatchTargetExpectsPlainDatalines(schemaCls: type | None) -> bool:
"""Whether a resolved dispatch target (a section type, output-manager type, generator, ...)
accepts plain (non-``>>``) datalines: a dispatch target's *own* scalar options, unlike a
top-level keyword's, are conventionally supplied via datalines rather than on the keyword line
itself, so a schema with any real scalar field or a dedicated dataline payload of its own both
count.
Parameters
----------
schemaCls
The dispatch target's own schema, or ``None``.
Returns
-------
bool
Whether a plain dataline is valid here. ``True`` when ``schemaCls`` is ``None``: a dispatch
target with no schema at all yet is a genuinely raw-datalines case in every instance that
exists today (``executePythonCode``'s Python source), so this conservatively allows rather
than rejecting every real ``.inp`` file exercising it.
"""
if schemaCls is None:
return True
return bool(_realScalarFields(schemaCls)) or datalineFieldMeta(schemaCls) is not None
def _expectsPlainDatalines(keyword: str, options: dict) -> bool:
"""Whether a plain (non-``>>``) line following a ``*keyword`` block's own line is a valid
dataline for it, resolved from the keyword's (or its dispatch target's) own schema.
Parameters
----------
keyword
The current top-level keyword.
options
Its current (already coerced) option mapping -- used to read the dispatch value
(``type=``/``generator=``) for a keyword whose plain-dataline grammar belongs to a resolved
dispatch target rather than to the keyword's own schema (e.g. ``*modelGenerator``'s
``x0=...`` datalines belong to the resolved generator, not to ``*modelGenerator`` itself).
Returns
-------
bool
Whether a plain dataline is valid here.
"""
dispatch = _DISPATCH_CATEGORY_BY_KEYWORD.get(keyword.casefold())
if dispatch is None:
_, keywordSchema = registry.lookup("keyword", keyword)
return keywordSchema is not None and datalineFieldMeta(keywordSchema) is not None
if strCaseCmp(keyword, "step"):
# every step type shares one schema, which accepts datalines
return True
category, optionName = dispatch
dispatchValue = options.get(optionName)
if not dispatchValue:
return False
try:
_, dispatchSchema = registry.lookup(category, dispatchValue)
except RegistryConflictError:
# a name conflict is an error, not a miss
raise
except RegistryLookupError:
return False
return _dispatchTargetExpectsPlainDatalines(dispatchSchema)
def parseKeywordLine(line, fileName):
lineElements = splitLineAtCommas(line.removeprefix(keywordIdentifier))
keyword = lineElements[0]
optionAssignments = lineElements[1:]
try:
options = convertAssignmentsToCaseInsensitiveStringDictionary(optionAssignments)
except ValueError as e:
e.args = (f"Error during parsing of keyword {keywordIdentifier}{keyword}: " + e.args[0],)
raise e
try:
_, keywordSchema = registry.lookup("keyword", keyword)
except RegistryLookupError as e:
raise ValueError(str(e)) from e
options = _coerceKnownFields(keywordSchema, options)
requiredNames, optionalNames = _requiredAndOptionalNames(keywordSchema)
@caseInsensitiveKwargsChecker(requiredNames, optionalNames)
def checkKeywordInput(*args, **kwargs):
"""this is a dummy function needed to apply kwargsChecker"""
return
try:
checkKeywordInput(**options)
except ValueError as e:
# dispatch-target options may be given directly on the keyword line (e.g. a plane section's 'thickness')
dispatchSchema = _resolveDispatchSchemaForKeywordLine(keyword, options)
if dispatchSchema is not None:
extraRequired, extraOptional = _requiredAndOptionalNames(dispatchSchema)
@caseInsensitiveKwargsChecker(requiredNames, optionalNames + extraOptional + extraRequired)
def checkKeywordInput(*args, **kwargs):
"""this is a dummy function needed to apply kwargsChecker"""
return
checkKeywordInput(**options)
else:
e.args = (f"Error during parsing of keyword {keywordIdentifier}{keyword}: " + e.args[0],)
raise e
options["inputFile"] = fileName # save also the filename of the original inputfile!
options["datalines"] = []
return keyword, options
def parseModuleKeywordLine(line, fileName, topLevelKeyword, topLevelOptions, fileDict):
lineElements = splitLineAtCommas(line.removeprefix(moduleLevelKeywordIdentifier))
keyword = lineElements[0]
optionAssignments = lineElements[1:]
try:
rawOptions = convertAssignmentsToCaseInsensitiveStringDictionary(optionAssignments)
except ValueError as e:
e.args = (f"Error during parsing of keyword {keywordIdentifier}{keyword}: " + e.args[0],)
raise e
isStepDynamicOptions = strCaseCmp(topLevelKeyword, "step") and strCaseCmp(keyword, "options")
isTypeDispatchedMarker = strCaseCmp(topLevelKeyword, "modelModifier") and strCaseCmp(keyword, "marker")
if isStepDynamicOptions or isTypeDispatchedMarker:
# '>>options' (depends on the targeted step action) and '>>marker' (depends on 'type') are
# validated later; only the discriminator is checked here, all other keys are passed raw.
discriminator = "name" if isStepDynamicOptions else "type"
@caseInsensitiveRequiredArgsChecker([discriminator])
def checkKeywordInput(*args, **kwargs):
"""this is a dummy function needed to apply kwargsChecker"""
return
checkKeywordInput(**rawOptions)
options = CaseInsensitiveDict(rawOptions)
else:
moduleSchema = _resolveModuleKeywordSchema(topLevelKeyword, topLevelOptions, keyword)
options = _coerceKnownFields(moduleSchema, rawOptions)
requiredNames, optionalNames = _requiredAndOptionalNames(moduleSchema)
@caseInsensitiveKwargsChecker(requiredNames, optionalNames)
def checkKeywordInput(*args, **kwargs):
"""this is a dummy function needed to apply kwargsChecker"""
return
try:
checkKeywordInput(**options)
except ValueError as e:
# A step action can be updated in a subsequent step by repeating the module
# level keyword with the same name as previously defined. Updates are validated
# against the dedicated 'update<keyword>' schema, if this step action provides one.
updateSchema = None
if (
strCaseCmp(topLevelKeyword, "step")
and "name" in rawOptions
and rawOptions["name"].casefold()
in [ # check if a step action with the same name already exists
item["name"].casefold()
for step in fileDict["step"]
if keyword in step["moduleOptions"]
for item in step["moduleOptions"][keyword]
if "name" in item
]
):
updateSchema = _updateStepActionSchema(keyword)
if updateSchema is not None:
options = _coerceKnownFields(updateSchema, rawOptions)
updateRequired, updateOptional = _requiredAndOptionalNames(updateSchema)
@caseInsensitiveKwargsChecker(updateRequired, updateOptional)
def checkUpdateKeywordInput(*args, **kwargs):
"""this is a dummy function needed to apply kwargsChecker"""
return
try:
checkUpdateKeywordInput(**options)
except ValueError as e2:
e2.args = (
f"Error during updating stepaction {moduleLevelKeywordIdentifier}{keyword}, "
f"name={options['name']}: " + e2.args[0],
)
raise e2
else:
e.args = (
f"Error during parsing of module level keyword {moduleLevelKeywordIdentifier}{keyword}: "
+ e.args[0],
)
raise e
options["inputFile"] = fileName # save also the filename of the original inputfile!
# No `datalines` key for `>>` blocks: e.g. `>>bodyforce` passes the whole dict to `buildSchemaFromOptions`.
return keyword, options