Add mjcf.schema and its declarative schema language.
The complete MJCF surface in one hand-maintained file: 144 elements, 8 shared attribute groups, 47 enums, 1,497 typed attributes with defaults, the presence-constraint inventory previously visible only as hand-written reader checks, and the save policies previously visible only as hand-written writer logic. The language is a small IDL: elements bound to their mjSpec structs, typed attributes with arities and defaults, enum keyword sets with C bindings, reusable and variant groups, explicit name/reference namespaces (id<ns>/ref<ns>, following dm_control's identifier/reference model), child cardinalities, presence constraints (exclusive/together/requires/oneof over attribute bundles), bitwise flag sets, identity constants (set field = CONST), fixed char arrays (chars[n], arity counting characters), numeric range facets, and two escape hatches: reading=custom (no typed binding is generated; both reading and saving are hand-written) and writing=custom (the binding drives the reader, the save policy is hand-written). doc/generate/mjcf_schema.py is the dependency-free parser and semantic validator; errors report file:line; 55 unit tests. The language is documented by the cheat-sheet legend at the top of the schema file. The schema was bootstrapped by extraction from the sources of record -- the MJCF[] table, the mjMap keyword tables, the ~660 ReadAttr*/MapValue call sites, mjspec.h struct fields, and the default-constructors in user_init.c and engine_init.c -- then hand-curated. Same-tag elements that differ by context are distinct declarations carrying an xml= facet; worldbody, frame and replicate carry alias=body, mirroring mjXSchema::NameMatch. The top-level order is by dependency, what a saved file should read like: front matter, declarations before use, the tree, the sections that reference it, the data tail. PiperOrigin-RevId: 958060695 Change-Id: Ie10fd9f0ef202a3626f4d635d02c8731a4d287df
This commit is contained in:
committed by
Copybara-Service
parent
fb07a9ca50
commit
3f8db4c17a
@@ -0,0 +1,767 @@
|
||||
# Copyright 2026 DeepMind Technologies Limited
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Parser for the MJCF schema definition language.
|
||||
|
||||
The MJCF grammar is defined in a single hand-edited file (src/xml/mjcf.schema)
|
||||
written in a small declarative language. This module parses and validates that
|
||||
language and exposes the result as plain dataclasses, consumed by the
|
||||
generators in this directory.
|
||||
|
||||
Grammar (see designs doc for rationale):
|
||||
|
||||
schema := { decl }
|
||||
decl := enum | group | element
|
||||
enum := "enum" IDENT [":" IDENT] "{" { key "=" value } "}"
|
||||
key := IDENT | STRING # XML keyword ("2d" needs quoting)
|
||||
value := IDENT | NUMBER # C constant name or literal value
|
||||
group := "group" IDENT ["variant"] "{" { attr | use | constraint } "}"
|
||||
element := "element" IDENT [":" IDENT] ["(" facet {"," facet} ")"]
|
||||
"{" { attr | use | child | const | constraint } "}"
|
||||
const := "set" IDENT "=" IDENT
|
||||
(field takes the C constant when the element is read; states
|
||||
what the element's identity implies, e.g. a sensor's type)
|
||||
attr := IDENT ":" type ["=" default] ["(" facet {"," facet} ")"]
|
||||
type := scalar ["[" [arity] "]"] | "enum" "<" IDENT ">"
|
||||
| "flags" "<" IDENT ">" | "id" "<" IDENT ">" | "ref" "<" IDENT ">"
|
||||
constraint:= verb bundle bundle { bundle }
|
||||
verb := "exclusive" | "together" | "requires" | "oneof"
|
||||
bundle := IDENT { "+" IDENT }
|
||||
(presence constraints over attributes: exclusive = at most one
|
||||
bundle present, together = all-or-none, requires a b = a needs
|
||||
b, oneof = at least one bundle complete; a bundle is complete
|
||||
when all its attributes appear)
|
||||
scalar := "double" | "float" | "int" | "bool" | "string" | "file"
|
||||
| "chars"
|
||||
arity := NUMBER | NUMBER ".." NUMBER | NUMBER ".." IDENT
|
||||
default := NUMBER | STRING | IDENT | "{" NUMBER {"," NUMBER} "}"
|
||||
facet := IDENT ["=" (IDENT | STRING | NUMBER)]
|
||||
child := "child" IDENT card
|
||||
card := "?" | "!" | "*" | "R"
|
||||
use := "use" IDENT
|
||||
|
||||
Names and references (the dm_control model, namespaces explicit): an
|
||||
attribute of type id<ns> declares a name in namespace `ns` (e.g. geom name);
|
||||
an attribute of type ref<ns> holds the name of an object in that namespace
|
||||
(e.g. an actuator's site). Namespaces exist by virtue of id declarations;
|
||||
a ref into a namespace nothing declares into is an error.
|
||||
"""
|
||||
|
||||
import dataclasses
|
||||
import re
|
||||
import sys
|
||||
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
# Facets accepted on attributes. Unknown facets are an error: forward
|
||||
# compatibility is explicit, not silent.
|
||||
KNOWN_FACETS = frozenset({'field', 'required', 'nodefault', 'pattern',
|
||||
'reading', 'writing', 'min', 'max', 'positive'})
|
||||
|
||||
# Facets accepted on elements. Element names must be unique, but the same XML
|
||||
# tag means different elements in different contexts (joint under body,
|
||||
# equality, composite, tendon/fixed); 'xml' gives the tag when it differs from
|
||||
# the declaration name. 'alias' records the mjXSchema::NameMatch behavior of
|
||||
# tags validated against another element's row (worldbody, frame, replicate
|
||||
# all match body): the MJCF[] emitter skips aliased elements, the XSD emitter
|
||||
# declares them fully. 'field' names the sub-struct of the bound spec that
|
||||
# this element's attributes live in (the mjVisual sub-sections).
|
||||
ELEMENT_FACETS = frozenset({'xml', 'alias', 'field'})
|
||||
|
||||
# 'file' is a string resolved against asset directories and the VFS
|
||||
# (the reader's ReadAttrFile); kept distinct for XSD/tooling and bindings.
|
||||
# 'bool' is a primitive whose XML keywords are exactly "true" and "false".
|
||||
# 'chars' is text bound to a fixed char array; its arity counts characters,
|
||||
# not space-separated tokens, and must be bounded.
|
||||
SCALAR_TYPES = frozenset({'double', 'float', 'int', 'bool', 'string',
|
||||
'file', 'chars'})
|
||||
|
||||
CARDINALITIES = frozenset({'?', '!', '*', 'R'})
|
||||
|
||||
|
||||
class SchemaError(Exception):
|
||||
"""Parse or validation error, formatted as path:line: message."""
|
||||
|
||||
def __init__(self, path: str, line: int, message: str):
|
||||
super().__init__(f'{path}:{line}: {message}')
|
||||
self.path = path
|
||||
self.line = line
|
||||
self.message = message
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Arity:
|
||||
"""Token count of a vector attribute: exact, ranged, or unbounded.
|
||||
|
||||
`hi` is an int, a symbolic C constant name (e.g. 'mjNREF'), or None for
|
||||
unbounded. Scalar attributes have arity (1, 1).
|
||||
"""
|
||||
lo: int
|
||||
hi: Union[int, str, None]
|
||||
|
||||
def is_scalar(self) -> bool:
|
||||
return self.lo == 1 and self.hi == 1
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Attr:
|
||||
"""An attribute declaration in the MJCF schema."""
|
||||
name: str
|
||||
type: str # scalar name, 'enum' or 'ref'
|
||||
target: Optional[str] # enum or ref target name
|
||||
arity: Arity
|
||||
default: Union[None, float, str, tuple[float, ...]]
|
||||
facets: dict[str, Union[bool, str, float]]
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Use:
|
||||
"""A `use` directive referencing a group in the MJCF schema."""
|
||||
group: str
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Child:
|
||||
"""A child element declaration in the MJCF schema."""
|
||||
name: str
|
||||
card: str
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Const:
|
||||
"""A constant assignment (`set field = CONST`) in an element declaration."""
|
||||
field: str # bound C field
|
||||
value: str # C constant it takes
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Constraint:
|
||||
"""A presence constraint over attributes in the MJCF schema."""
|
||||
kind: str # exclusive | together | requires | oneof
|
||||
bundles: list[tuple[str, ...]] # attribute bundles ('+'-joined in source)
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Group:
|
||||
"""An attribute group declaration in the MJCF schema."""
|
||||
name: str
|
||||
variant: bool
|
||||
members: list[Union[Attr, Use, Constraint]]
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Element:
|
||||
"""An XML element declaration in the MJCF schema."""
|
||||
name: str
|
||||
spec: Optional[str] # bound mjs struct name, e.g. 'mjsGeom'
|
||||
facets: dict[str, Union[bool, str, float]]
|
||||
members: list[Union[Attr, Use, Child, Const, Constraint]]
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
def children(self) -> list[Child]:
|
||||
return [m for m in self.members if isinstance(m, Child)]
|
||||
|
||||
def consts(self) -> list[Const]:
|
||||
return [m for m in self.members if isinstance(m, Const)]
|
||||
|
||||
def constraints(self) -> list[Constraint]:
|
||||
return [m for m in self.members if isinstance(m, Constraint)]
|
||||
|
||||
def xml_name(self) -> str:
|
||||
return str(self.facets.get('xml', self.name))
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Enum:
|
||||
"""An enum declaration in the MJCF schema."""
|
||||
name: str
|
||||
ctype: Optional[str] # bound C enum type, e.g. 'mjtGeom'
|
||||
items: list[tuple[str, str]] # (xml keyword, C constant or literal)
|
||||
doc: Optional[str]
|
||||
line: int
|
||||
|
||||
def keywords(self) -> list[str]:
|
||||
return [key for key, _ in self.items]
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class Schema:
|
||||
"""Top-level parsed representation of an MJCF schema."""
|
||||
enums: dict[str, Enum]
|
||||
groups: dict[str, Group]
|
||||
enums: dict[str, Enum]
|
||||
groups: dict[str, Group]
|
||||
elements: dict[str, Element]
|
||||
path: str
|
||||
|
||||
def expanded_attrs(self, element: Element) -> list[Attr]:
|
||||
"""Element's attributes with `use` groups expanded, in declaration order."""
|
||||
out = []
|
||||
for member in element.members:
|
||||
if isinstance(member, Attr):
|
||||
out.append(member)
|
||||
elif isinstance(member, Use):
|
||||
out.extend(self._group_attrs(member.group))
|
||||
return out
|
||||
|
||||
def _group_attrs(self, name: str) -> list[Attr]:
|
||||
out = []
|
||||
for member in self.groups[name].members:
|
||||
if isinstance(member, Attr):
|
||||
out.append(member)
|
||||
elif isinstance(member, Use):
|
||||
out.extend(self._group_attrs(member.group))
|
||||
return out
|
||||
|
||||
|
||||
#--------------------------------- lexer ---------------------------------------
|
||||
|
||||
# NUMBER must not swallow the first dot of a '..' range: 0..3 lexes as
|
||||
# NUMBER(0) DOTDOT NUMBER(3) via the (?!\.) lookahead.
|
||||
_TOKEN_RE = re.compile(r"""
|
||||
(?P<ws>[ \t]+)
|
||||
| (?P<comment>\#[^\n]*)
|
||||
| (?P<newline>\n)
|
||||
| (?P<string>"[^"\n]*")
|
||||
| (?P<number>-?(?:\d+(?:\.(?!\.)\d*)?|\.\d+)(?:[eE][+-]?\d+)?)
|
||||
| (?P<dotdot>\.\.)
|
||||
| (?P<ident>[A-Za-z_][A-Za-z0-9_]*)
|
||||
| (?P<punct>[{}()\[\]<>:=,?!*+])
|
||||
""", re.VERBOSE)
|
||||
|
||||
|
||||
@dataclasses.dataclass
|
||||
class _Token:
|
||||
kind: str # 'string' | 'number' | 'dotdot' | 'ident' | punct char | 'eof'
|
||||
value: str
|
||||
line: int
|
||||
|
||||
|
||||
def _lex(text: str, path: str) -> tuple[list[_Token], dict[int, str]]:
|
||||
"""Returns tokens and a map of line number -> trailing comment text."""
|
||||
tokens = []
|
||||
comments = {}
|
||||
line = 1
|
||||
pos = 0
|
||||
while pos < len(text):
|
||||
match = _TOKEN_RE.match(text, pos)
|
||||
if not match:
|
||||
raise SchemaError(path, line, f'unexpected character {text[pos]!r}')
|
||||
kind = match.lastgroup
|
||||
value = match.group()
|
||||
if kind == 'newline':
|
||||
line += 1
|
||||
elif kind == 'comment':
|
||||
comments[line] = value[1:].strip()
|
||||
elif kind == 'punct':
|
||||
tokens.append(_Token(value, value, line))
|
||||
elif kind != 'ws':
|
||||
tokens.append(_Token(kind, value, line))
|
||||
pos = match.end()
|
||||
tokens.append(_Token('eof', '', line))
|
||||
return tokens, comments
|
||||
|
||||
|
||||
#--------------------------------- parser --------------------------------------
|
||||
|
||||
|
||||
class _Parser:
|
||||
"""Recursive-descent parser over the token stream."""
|
||||
|
||||
def __init__(self, text: str, path: str):
|
||||
self.path = path
|
||||
self.tokens, self.comments = _lex(text, path)
|
||||
self.pos = 0
|
||||
|
||||
def error(self, message: str, line: Optional[int] = None) -> SchemaError:
|
||||
return SchemaError(self.path, line or self.peek().line, message)
|
||||
|
||||
def peek(self) -> _Token:
|
||||
return self.tokens[self.pos]
|
||||
|
||||
def next(self) -> _Token:
|
||||
token = self.tokens[self.pos]
|
||||
self.pos += 1
|
||||
return token
|
||||
|
||||
def expect(self, kind: str) -> _Token:
|
||||
token = self.next()
|
||||
if token.kind != kind:
|
||||
raise self.error(f'expected {kind!r}, got {token.value!r}',
|
||||
line=token.line)
|
||||
return token
|
||||
|
||||
def accept(self, kind: str, value: Optional[str] = None) -> Optional[_Token]:
|
||||
token = self.peek()
|
||||
if token.kind == kind and (value is None or token.value == value):
|
||||
return self.next()
|
||||
return None
|
||||
|
||||
def doc_for(self, line: int) -> Optional[str]:
|
||||
return self.comments.get(line)
|
||||
|
||||
def parse(self) -> Schema:
|
||||
"""Parses the token stream into a Schema dataclass."""
|
||||
schema = Schema(enums={}, groups={}, elements={},
|
||||
path=self.path)
|
||||
while self.peek().kind != 'eof':
|
||||
token = self.expect('ident')
|
||||
if token.value == 'enum':
|
||||
enum = self.parse_enum(token.line)
|
||||
self.declare(schema.enums, enum.name, 'enum', token.line)
|
||||
schema.enums[enum.name] = enum
|
||||
elif token.value == 'group':
|
||||
group = self.parse_group(token.line)
|
||||
self.declare(schema.groups, group.name, 'group', token.line)
|
||||
schema.groups[group.name] = group
|
||||
elif token.value == 'element':
|
||||
element = self.parse_element(token.line)
|
||||
self.declare(schema.elements, element.name, 'element', token.line)
|
||||
schema.elements[element.name] = element
|
||||
else:
|
||||
raise self.error(
|
||||
f"expected 'enum', 'group' or 'element', "
|
||||
f'got {token.value!r}', line=token.line)
|
||||
return schema
|
||||
|
||||
def declare(self, table: dict[str, Any], name: str, what: str, line: int):
|
||||
"""Ensures declaration names are unique within a table."""
|
||||
if name in table:
|
||||
raise self.error(f'duplicate {what} {name!r} '
|
||||
f'(first declared on line {table[name].line})',
|
||||
line=line)
|
||||
|
||||
def parse_enum(self, line: int) -> Enum:
|
||||
"""Parses an enum declaration."""
|
||||
name = self.expect('ident').value
|
||||
ctype = self.expect('ident').value if self.accept(':') else None
|
||||
doc = self.doc_for(line)
|
||||
self.expect('{')
|
||||
items = []
|
||||
seen = {}
|
||||
while not self.accept('}'):
|
||||
key_token = self.next()
|
||||
if key_token.kind == 'string':
|
||||
key = key_token.value.strip('"')
|
||||
elif key_token.kind == 'ident':
|
||||
key = key_token.value
|
||||
else:
|
||||
raise self.error(f'expected enum keyword, got {key_token.value!r}',
|
||||
line=key_token.line)
|
||||
if key in seen:
|
||||
raise self.error(f'duplicate enum keyword {key!r}',
|
||||
line=key_token.line)
|
||||
seen[key] = key_token.line
|
||||
self.expect('=')
|
||||
value_token = self.next()
|
||||
if value_token.kind not in ('ident', 'number'):
|
||||
raise self.error(f'expected C constant or number, '
|
||||
f'got {value_token.value!r}', line=value_token.line)
|
||||
items.append((key, value_token.value))
|
||||
if not items:
|
||||
raise self.error(f'enum {name!r} is empty', line=line)
|
||||
return Enum(name=name, ctype=ctype, items=items, doc=doc, line=line)
|
||||
|
||||
def parse_group(self, line: int) -> Group:
|
||||
"""Parses a group declaration."""
|
||||
name = self.expect('ident').value
|
||||
variant = bool(self.accept('ident', 'variant'))
|
||||
doc = self.doc_for(line)
|
||||
self.expect('{')
|
||||
members = []
|
||||
while not self.accept('}'):
|
||||
members.append(self.parse_member(allow_child=False))
|
||||
if not members:
|
||||
raise self.error(f'group {name!r} is empty', line=line)
|
||||
return Group(name=name, variant=variant, members=members, doc=doc,
|
||||
line=line)
|
||||
|
||||
def parse_element(self, line: int) -> Element:
|
||||
"""Parses an element declaration."""
|
||||
name = self.expect('ident').value
|
||||
spec = self.expect('ident').value if self.accept(':') else None
|
||||
facets = self.parse_facets(ELEMENT_FACETS) if self.accept('(') else {}
|
||||
doc = self.doc_for(line)
|
||||
self.expect('{')
|
||||
members = []
|
||||
while not self.accept('}'):
|
||||
members.append(self.parse_member(allow_child=True))
|
||||
return Element(name=name, spec=spec, facets=facets, members=members,
|
||||
doc=doc, line=line)
|
||||
|
||||
CONSTRAINT_VERBS = frozenset({'exclusive', 'together', 'requires', 'oneof'})
|
||||
|
||||
def parse_member(self, allow_child: bool) -> Union[Attr, Use, Child, Const,
|
||||
Constraint]:
|
||||
"""Parses a member of a group or element."""
|
||||
token = self.expect('ident')
|
||||
if token.value == 'use':
|
||||
return Use(group=self.expect('ident').value, line=token.line)
|
||||
if token.value in self.CONSTRAINT_VERBS and self.peek().kind == 'ident':
|
||||
# constraints are single-line constructs
|
||||
bundles = []
|
||||
while self.peek().kind == 'ident' and self.peek().line == token.line:
|
||||
bundle = [self.expect('ident').value]
|
||||
while self.accept('+'):
|
||||
bundle.append(self.expect('ident').value)
|
||||
bundles.append(tuple(bundle))
|
||||
if len(bundles) < 2:
|
||||
raise self.error(f'{token.value!r} needs at least two attributes',
|
||||
line=token.line)
|
||||
return Constraint(kind=token.value, bundles=bundles,
|
||||
doc=self.doc_for(token.line), line=token.line)
|
||||
if token.value == 'set':
|
||||
if not allow_child:
|
||||
raise self.error("'set' is not allowed in a group", line=token.line)
|
||||
field = self.expect('ident').value
|
||||
self.expect('=')
|
||||
value = self.expect('ident').value
|
||||
return Const(field=field, value=value, doc=self.doc_for(token.line),
|
||||
line=token.line)
|
||||
if token.value == 'child':
|
||||
if not allow_child:
|
||||
raise self.error("'child' is not allowed in a group", line=token.line)
|
||||
name = self.expect('ident').value
|
||||
card = self.next()
|
||||
if card.value not in CARDINALITIES:
|
||||
raise self.error(f'expected cardinality (? ! * R), '
|
||||
f'got {card.value!r}', line=card.line)
|
||||
return Child(name=name, card=card.value, doc=self.doc_for(token.line),
|
||||
line=token.line)
|
||||
return self.parse_attr(token)
|
||||
|
||||
def parse_attr(self, name_token: _Token) -> Attr:
|
||||
"""Parses an attribute declaration."""
|
||||
line = name_token.line
|
||||
self.expect(':')
|
||||
attr_type, target, arity = self.parse_type()
|
||||
default = self.parse_default() if self.accept('=') else None
|
||||
facets = self.parse_facets() if self.accept('(') else {}
|
||||
return Attr(name=name_token.value, type=attr_type, target=target,
|
||||
arity=arity, default=default, facets=facets,
|
||||
doc=self.doc_for(line), line=line)
|
||||
|
||||
def parse_type(self) -> tuple[str, Optional[str], Arity]:
|
||||
"""Parses an attribute type declaration."""
|
||||
token = self.expect('ident')
|
||||
if token.value in ('enum', 'flags', 'ref', 'id'):
|
||||
self.expect('<')
|
||||
target = self.expect('ident').value
|
||||
self.expect('>')
|
||||
return token.value, target, Arity(1, 1)
|
||||
if token.value not in SCALAR_TYPES:
|
||||
raise self.error(f'unknown type {token.value!r}', line=token.line)
|
||||
return token.value, None, self.parse_arity()
|
||||
|
||||
def parse_arity(self) -> Arity:
|
||||
"""Parses attribute arity specification."""
|
||||
if not self.accept('['):
|
||||
return Arity(1, 1)
|
||||
if self.accept(']'):
|
||||
return Arity(0, None) # unbounded
|
||||
lo_token = self.expect('number')
|
||||
lo = self.parse_int(lo_token)
|
||||
if not self.accept('dotdot'):
|
||||
self.expect(']')
|
||||
return Arity(lo, lo)
|
||||
hi_token = self.next()
|
||||
if hi_token.kind == 'number':
|
||||
hi = self.parse_int(hi_token)
|
||||
if hi <= lo:
|
||||
raise self.error(f'arity range [{lo}..{hi}] is not increasing',
|
||||
line=hi_token.line)
|
||||
elif hi_token.kind == 'ident':
|
||||
hi = hi_token.value # symbolic bound, e.g. mjNREF
|
||||
else:
|
||||
raise self.error(f'expected arity bound, got {hi_token.value!r}',
|
||||
line=hi_token.line)
|
||||
self.expect(']')
|
||||
return Arity(lo, hi)
|
||||
|
||||
def parse_int(self, token: _Token) -> int:
|
||||
"""Parses an integer value from a token."""
|
||||
try:
|
||||
value = int(token.value)
|
||||
except ValueError:
|
||||
raise self.error(f'expected integer, got {token.value!r}',
|
||||
line=token.line) from None
|
||||
if value < 0:
|
||||
raise self.error('arity may not be negative', line=token.line)
|
||||
return value
|
||||
|
||||
def parse_default(self) -> Union[float, str, tuple[float, ...]]:
|
||||
"""Parses default value for an attribute."""
|
||||
token = self.next()
|
||||
if token.kind == 'number':
|
||||
return float(token.value)
|
||||
if token.kind == 'string':
|
||||
return token.value.strip('"')
|
||||
if token.kind == 'ident':
|
||||
return token.value # enum keyword
|
||||
if token.kind == '{':
|
||||
values = [float(self.expect('number').value)]
|
||||
while self.accept(','):
|
||||
values.append(float(self.expect('number').value))
|
||||
self.expect('}')
|
||||
return tuple(values)
|
||||
raise self.error(f'expected default value, got {token.value!r}',
|
||||
line=token.line)
|
||||
|
||||
def parse_facets(self, known=KNOWN_FACETS) -> dict[str, Union[bool, str,
|
||||
float]]:
|
||||
"""Parses attribute or element facets."""
|
||||
facets = {}
|
||||
while True:
|
||||
token = self.expect('ident')
|
||||
if token.value not in known:
|
||||
raise self.error(f'unknown facet {token.value!r}', line=token.line)
|
||||
if token.value in facets:
|
||||
raise self.error(f'duplicate facet {token.value!r}', line=token.line)
|
||||
if self.accept('='):
|
||||
value_token = self.next()
|
||||
if value_token.kind == 'string':
|
||||
facets[token.value] = value_token.value.strip('"')
|
||||
elif value_token.kind == 'ident':
|
||||
facets[token.value] = value_token.value
|
||||
elif value_token.kind == 'number':
|
||||
facets[token.value] = float(value_token.value)
|
||||
else:
|
||||
raise self.error(f'expected facet value, got {value_token.value!r}',
|
||||
line=value_token.line)
|
||||
else:
|
||||
facets[token.value] = True
|
||||
if self.accept(')'):
|
||||
return facets
|
||||
self.expect(',')
|
||||
|
||||
|
||||
#------------------------------- validation ------------------------------------
|
||||
|
||||
|
||||
def _validate(schema: Schema):
|
||||
"""Semantic checks; raises SchemaError on the first violation."""
|
||||
path = schema.path
|
||||
|
||||
def err(line: int, message: str):
|
||||
raise SchemaError(path, line, message)
|
||||
|
||||
# group use graph: dangling targets and cycles
|
||||
for group in schema.groups.values():
|
||||
_check_group_cycle(schema, group.name, [], group.line)
|
||||
for group in schema.groups.values():
|
||||
member_names = {m.name for m in group.members
|
||||
if isinstance(m, Attr)}
|
||||
for con in [m for m in group.members if isinstance(m, Constraint)]:
|
||||
for bundle in con.bundles:
|
||||
for name in bundle:
|
||||
if name not in member_names:
|
||||
err(con.line, f'constraint references unknown attribute {name!r}')
|
||||
if group.variant:
|
||||
for member in group.members:
|
||||
if isinstance(member, Use):
|
||||
err(member.line,
|
||||
f"variant group {group.name!r} may not contain 'use'")
|
||||
elif isinstance(member, Attr) and member.facets.get('required'):
|
||||
err(member.line, f'attribute {member.name!r} in variant group '
|
||||
f'{group.name!r} may not be required')
|
||||
|
||||
containers = list(schema.groups.values()) + list(schema.elements.values())
|
||||
for container in containers:
|
||||
for member in container.members:
|
||||
if isinstance(member, Use) and member.group not in schema.groups:
|
||||
err(member.line, f'use of undeclared group {member.group!r}')
|
||||
|
||||
# namespaces exist by virtue of id<ns> declarations
|
||||
namespaces = set()
|
||||
for container in containers:
|
||||
for member in container.members:
|
||||
if isinstance(member, Attr) and member.type == 'id':
|
||||
namespaces.add(member.target)
|
||||
|
||||
for element in schema.elements.values():
|
||||
# element facets
|
||||
for facet in ('xml', 'alias'):
|
||||
if facet in element.facets and not isinstance(element.facets[facet], str):
|
||||
err(element.line, f'element facet {facet!r} requires a name')
|
||||
alias = element.facets.get('alias')
|
||||
if alias is not None and alias not in schema.elements:
|
||||
err(element.line,
|
||||
f'alias references undeclared element {alias!r}')
|
||||
|
||||
# children: dangling targets and duplicates
|
||||
seen_children = set()
|
||||
for child in element.children():
|
||||
if child.name not in schema.elements:
|
||||
err(child.line, f'child references undeclared element {child.name!r}')
|
||||
if child.name in seen_children:
|
||||
err(child.line, f'duplicate child {child.name!r}')
|
||||
seen_children.add(child.name)
|
||||
|
||||
# attributes: duplicates across direct and use-expanded members
|
||||
seen_attrs = {}
|
||||
for attr in schema.expanded_attrs(element):
|
||||
if attr.name in seen_attrs:
|
||||
err(attr.line if attr.line > seen_attrs[attr.name] else element.line,
|
||||
f'duplicate attribute {attr.name!r} in element {element.name!r} '
|
||||
f'(directly or via use)')
|
||||
seen_attrs[attr.name] = attr.line
|
||||
|
||||
# constraints reference the element's own (expanded) attributes
|
||||
for con in element.constraints():
|
||||
for bundle in con.bundles:
|
||||
for name in bundle:
|
||||
if name not in seen_attrs:
|
||||
err(con.line, f'constraint references unknown attribute {name!r}')
|
||||
if con.kind == 'requires' and (
|
||||
len(con.bundles) != 2 or any(len(b) != 1 for b in con.bundles)):
|
||||
err(con.line, "'requires' takes exactly two attributes")
|
||||
|
||||
# per-attribute checks, wherever the attribute is declared
|
||||
for container in containers:
|
||||
for attr in container.members:
|
||||
if isinstance(attr, Attr):
|
||||
_validate_attr(schema, attr, namespaces)
|
||||
|
||||
|
||||
def _check_group_cycle(schema: Schema, name: str, stack: list[str], line: int):
|
||||
"""Recursively checks for cycles in group `use` references."""
|
||||
if name in stack:
|
||||
cycle = ' -> '.join(stack + [name])
|
||||
raise SchemaError(schema.path, line, f'group use cycle: {cycle}')
|
||||
group = schema.groups.get(name)
|
||||
if group is None:
|
||||
return # dangling use is reported separately with its own line
|
||||
for member in group.members:
|
||||
if isinstance(member, Use):
|
||||
_check_group_cycle(schema, member.group, stack + [name], member.line)
|
||||
|
||||
|
||||
def _validate_attr(schema: Schema, attr: Attr, namespaces: set[str]):
|
||||
"""Validates semantic constraints for a single attribute."""
|
||||
path = schema.path
|
||||
|
||||
def err(message: str):
|
||||
raise SchemaError(path, attr.line, message)
|
||||
|
||||
# target existence
|
||||
if attr.type in ('enum', 'flags') and attr.target not in schema.enums:
|
||||
err(f'attribute {attr.name!r} references undeclared enum {attr.target!r}')
|
||||
if attr.type == 'ref' and attr.target not in namespaces:
|
||||
err(f'attribute {attr.name!r} references namespace {attr.target!r}, '
|
||||
f'which no id<{attr.target}> declares')
|
||||
|
||||
# arity restrictions
|
||||
if attr.type in ('file', 'bool') and not attr.arity.is_scalar():
|
||||
err(f'{attr.type} attribute {attr.name!r} may not be a vector')
|
||||
if attr.type == 'chars' and not isinstance(attr.arity.hi, int):
|
||||
err(f'chars attribute {attr.name!r} must declare a bounded length')
|
||||
|
||||
# facet payloads
|
||||
if 'pattern' in attr.facets and attr.type not in ('string', 'chars'):
|
||||
err("facet 'pattern' requires a text attribute")
|
||||
numeric = attr.type in ('double', 'float', 'int')
|
||||
for facet in ('min', 'max'):
|
||||
if facet in attr.facets and not (
|
||||
numeric and isinstance(attr.facets[facet], (int, float))):
|
||||
err(f'facet {facet!r} requires a numeric attribute and value')
|
||||
if 'min' in attr.facets and 'max' in attr.facets:
|
||||
if attr.facets['min'] > attr.facets['max']:
|
||||
err("facet 'min' cannot be greater than 'max'")
|
||||
if attr.facets.get('positive') and not numeric:
|
||||
err("facet 'positive' requires a numeric attribute")
|
||||
if attr.facets.get('required') and attr.default is not None:
|
||||
err(f'attribute {attr.name!r} is required and has a default')
|
||||
|
||||
# defaults
|
||||
if attr.default is None:
|
||||
return
|
||||
if attr.type == 'enum':
|
||||
if not isinstance(attr.default, str):
|
||||
err(f'default for enum attribute {attr.name!r} must be a keyword')
|
||||
keywords = schema.enums[attr.target].keywords()
|
||||
if attr.default not in keywords:
|
||||
err(f'default {attr.default!r} is not a keyword of enum '
|
||||
f'{attr.target!r}')
|
||||
return
|
||||
if attr.type in ('ref', 'id', 'chars'):
|
||||
err(f'{attr.type} attribute {attr.name!r} may not have a default')
|
||||
if attr.type == 'bool':
|
||||
if attr.default not in ('true', 'false'):
|
||||
err(f'default for bool attribute {attr.name!r} must be true or false')
|
||||
return
|
||||
if attr.type in ('string', 'file'):
|
||||
if not isinstance(attr.default, str):
|
||||
err(f'default for {attr.type} attribute {attr.name!r} must be a string')
|
||||
return
|
||||
# numeric scalars and vectors
|
||||
if isinstance(attr.default, str):
|
||||
err(f'default for numeric attribute {attr.name!r} must be numeric')
|
||||
n = len(attr.default) if isinstance(attr.default, tuple) else 1
|
||||
lo, hi = attr.arity.lo, attr.arity.hi
|
||||
if isinstance(attr.default, tuple) and attr.arity.is_scalar():
|
||||
err(f'vector default for scalar attribute {attr.name!r}')
|
||||
if n < lo:
|
||||
err(f'default for {attr.name!r} has {n} values, arity requires '
|
||||
f'at least {lo}')
|
||||
if isinstance(hi, int) and n > hi:
|
||||
err(f'default for {attr.name!r} has {n} values, arity allows '
|
||||
f'at most {hi}')
|
||||
|
||||
|
||||
#--------------------------------- entry points --------------------------------
|
||||
|
||||
|
||||
def parse_string(text: str, path: str = '<string>') -> Schema:
|
||||
"""Parses an MJCF schema from a string."""
|
||||
schema = _Parser(text, path).parse()
|
||||
_validate(schema)
|
||||
return schema
|
||||
|
||||
|
||||
def parse_file(path: str) -> Schema:
|
||||
"""Parses an MJCF schema from a file path."""
|
||||
with open(path, 'r', encoding='utf-8') as file:
|
||||
return parse_string(file.read(), path)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""CLI entry point for checking an MJCF schema file."""
|
||||
if len(sys.argv) != 2:
|
||||
sys.exit(f'usage: {sys.argv[0]} <mjcf.schema>')
|
||||
try:
|
||||
schema = parse_file(sys.argv[1])
|
||||
except SchemaError as error:
|
||||
sys.exit(str(error))
|
||||
n_attrs = sum(len(schema.expanded_attrs(e))
|
||||
for e in schema.elements.values())
|
||||
print(f'{sys.argv[1]}: '
|
||||
f'{len(schema.elements)} elements, {len(schema.groups)} groups, '
|
||||
f'{len(schema.enums)} enums, {n_attrs} attributes')
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
+2247
File diff suppressed because it is too large
Load Diff
@@ -17,4 +17,7 @@ if(Python3_FOUND)
|
||||
add_test(NAME doc_test
|
||||
COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/doc_test.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
add_test(NAME mjcf_schema_test
|
||||
COMMAND Python3::Interpreter ${CMAKE_CURRENT_SOURCE_DIR}/mjcf_schema_test.py
|
||||
WORKING_DIRECTORY ${CMAKE_SOURCE_DIR})
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
# Copyright 2026 DeepMind Technologies Limited
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ==============================================================================
|
||||
"""Tests for the MJCF schema definition language parser."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest as googletest
|
||||
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
_REPO_ROOT = os.path.dirname(os.path.dirname(_SCRIPT_DIR))
|
||||
sys.path.insert(0, os.path.join(_REPO_ROOT, 'doc', 'generate'))
|
||||
import mjcf_schema
|
||||
|
||||
GOOD = '''
|
||||
enum geomtype : mjtGeom { # geom shapes
|
||||
plane = mjGEOM_PLANE
|
||||
sphere = mjGEOM_SPHERE
|
||||
"2d" = mjGEOM_PLANE
|
||||
}
|
||||
|
||||
enum onoff {
|
||||
false = 0
|
||||
true = 1
|
||||
}
|
||||
|
||||
group orientation variant { # at most one spelling
|
||||
quat : double[4] = {1, 0, 0, 0}
|
||||
axisangle : double[4] # (x, y, z, angle)
|
||||
euler : double[3]
|
||||
}
|
||||
|
||||
group posed {
|
||||
pos : double[3] = {0, 0, 0}
|
||||
use orientation
|
||||
}
|
||||
|
||||
element defaults {
|
||||
class : id<defaults> # name of this class
|
||||
}
|
||||
|
||||
element geom : mjsGeom { # geometric entity
|
||||
use posed
|
||||
name : id<geom> # element name
|
||||
class : ref<defaults> (field=classname) # defaults class
|
||||
type : enum<geomtype> = sphere # geom shape
|
||||
condim : int = 3
|
||||
size : double[0..3] # type-specific size
|
||||
friction : double[1..3] = {1, 0.005, 0.0001} # slide, roll, spin
|
||||
solref : double[0..mjNREF]
|
||||
eulerseq : string = "xyz" (pattern="[xyzXYZ]{3}")
|
||||
margin : double = 0 (nodefault)
|
||||
file : string (required)
|
||||
user : double[] # user data
|
||||
child geom * # nested geoms
|
||||
child defaults R
|
||||
}
|
||||
'''
|
||||
|
||||
|
||||
class ParserTest(googletest.TestCase):
|
||||
|
||||
def parse(self, text):
|
||||
return mjcf_schema.parse_string(text)
|
||||
|
||||
def error(self, text):
|
||||
with self.assertRaises(mjcf_schema.SchemaError) as ctx:
|
||||
self.parse(text)
|
||||
return str(ctx.exception)
|
||||
|
||||
def test_good_schema_parses(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertEqual(set(schema.enums), {'geomtype', 'onoff'})
|
||||
self.assertEqual(set(schema.groups), {'orientation', 'posed'})
|
||||
self.assertEqual(set(schema.elements), {'geom', 'defaults'})
|
||||
|
||||
def test_enum(self):
|
||||
schema = self.parse(GOOD)
|
||||
enum = schema.enums['geomtype']
|
||||
self.assertEqual(enum.ctype, 'mjtGeom')
|
||||
self.assertEqual(enum.items[0], ('plane', 'mjGEOM_PLANE'))
|
||||
self.assertEqual(enum.items[2], ('2d', 'mjGEOM_PLANE'))
|
||||
self.assertEqual(enum.doc, 'geom shapes')
|
||||
self.assertIsNone(schema.enums['onoff'].ctype)
|
||||
self.assertEqual(schema.enums['onoff'].items[0], ('false', '0'))
|
||||
|
||||
def test_groups_and_expansion(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertTrue(schema.groups['orientation'].variant)
|
||||
self.assertFalse(schema.groups['posed'].variant)
|
||||
names = [a.name for a in schema.expanded_attrs(schema.elements['geom'])]
|
||||
# posed expands to pos + orientation members, in order, before own attrs.
|
||||
self.assertEqual(names[:4], ['pos', 'quat', 'axisangle', 'euler'])
|
||||
self.assertIn('friction', names)
|
||||
|
||||
def test_attr_types_and_arity(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['condim'].arity, mjcf_schema.Arity(1, 1))
|
||||
self.assertEqual(attrs['quat'].arity, mjcf_schema.Arity(4, 4))
|
||||
self.assertEqual(attrs['size'].arity, mjcf_schema.Arity(0, 3))
|
||||
self.assertEqual(attrs['friction'].arity, mjcf_schema.Arity(1, 3))
|
||||
self.assertEqual(attrs['solref'].arity, mjcf_schema.Arity(0, 'mjNREF'))
|
||||
self.assertEqual(attrs['user'].arity, mjcf_schema.Arity(0, None))
|
||||
self.assertEqual(attrs['type'].type, 'enum')
|
||||
self.assertEqual(attrs['type'].target, 'geomtype')
|
||||
self.assertEqual(attrs['class'].type, 'ref')
|
||||
self.assertEqual(attrs['class'].target, 'defaults')
|
||||
self.assertEqual(attrs['name'].type, 'id')
|
||||
self.assertEqual(attrs['name'].target, 'geom')
|
||||
|
||||
def test_defaults(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['friction'].default, (1, 0.005, 0.0001))
|
||||
self.assertEqual(attrs['condim'].default, 3.0)
|
||||
self.assertEqual(attrs['type'].default, 'sphere')
|
||||
self.assertEqual(attrs['eulerseq'].default, 'xyz')
|
||||
self.assertIsNone(attrs['size'].default)
|
||||
|
||||
def test_facets(self):
|
||||
schema = self.parse(GOOD)
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['class'].facets, {'field': 'classname'})
|
||||
self.assertEqual(attrs['eulerseq'].facets, {'pattern': '[xyzXYZ]{3}'})
|
||||
self.assertEqual(attrs['margin'].facets, {'nodefault': True})
|
||||
self.assertEqual(attrs['file'].facets, {'required': True})
|
||||
|
||||
def test_children(self):
|
||||
schema = self.parse(GOOD)
|
||||
children = schema.elements['geom'].children()
|
||||
self.assertEqual([(c.name, c.card) for c in children],
|
||||
[('geom', '*'), ('defaults', 'R')])
|
||||
|
||||
def test_docs(self):
|
||||
schema = self.parse(GOOD)
|
||||
self.assertEqual(schema.elements['geom'].doc, 'geometric entity')
|
||||
attrs = {a.name: a for a in schema.expanded_attrs(schema.elements['geom'])}
|
||||
self.assertEqual(attrs['friction'].doc, 'slide, roll, spin')
|
||||
self.assertEqual(attrs['axisangle'].doc, '(x, y, z, angle)')
|
||||
self.assertIsNone(attrs['condim'].doc)
|
||||
|
||||
|
||||
class ErrorTest(googletest.TestCase):
|
||||
|
||||
def error(self, text):
|
||||
with self.assertRaises(mjcf_schema.SchemaError) as ctx:
|
||||
mjcf_schema.parse_string(text)
|
||||
return str(ctx.exception)
|
||||
|
||||
def test_error_has_line_number(self):
|
||||
message = self.error('element geom {\n size ; double\n}')
|
||||
self.assertIn('<string>:2:', message)
|
||||
|
||||
def test_duplicate_element(self):
|
||||
message = self.error('element geom {}\nelement geom {}')
|
||||
self.assertIn('duplicate element', message)
|
||||
|
||||
def test_duplicate_attr(self):
|
||||
message = self.error('element geom {\n a : int\n a : double\n}')
|
||||
self.assertIn("duplicate attribute 'a'", message)
|
||||
|
||||
def test_duplicate_attr_via_use(self):
|
||||
message = self.error('group g {\n a : int\n}\n'
|
||||
'element geom {\n use g\n a : double\n}')
|
||||
self.assertIn("duplicate attribute 'a'", message)
|
||||
|
||||
def test_dangling_enum(self):
|
||||
message = self.error('element geom {\n type : enum<nosuch>\n}')
|
||||
self.assertIn("undeclared enum 'nosuch'", message)
|
||||
|
||||
def test_dangling_ref(self):
|
||||
message = self.error('element geom {\n mesh : ref<nosuch>\n}')
|
||||
self.assertIn("namespace 'nosuch'", message)
|
||||
|
||||
def test_ref_resolved_by_id_elsewhere(self):
|
||||
mjcf_schema.parse_string(
|
||||
'element mesh {\n name : id<mesh>\n}\n'
|
||||
'element geom {\n mesh : ref<mesh>\n}')
|
||||
|
||||
def test_id_with_default(self):
|
||||
message = self.error('element geom {\n name : id<geom> = "x"\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_dangling_use(self):
|
||||
message = self.error('element geom {\n use nosuch\n}')
|
||||
self.assertIn("undeclared group 'nosuch'", message)
|
||||
|
||||
def test_dangling_child(self):
|
||||
message = self.error('element geom {\n child nosuch *\n}')
|
||||
self.assertIn("undeclared element 'nosuch'", message)
|
||||
|
||||
def test_use_cycle(self):
|
||||
message = self.error('group a {\n use b\n}\ngroup b {\n use a\n}')
|
||||
self.assertIn('cycle', message)
|
||||
|
||||
def test_default_too_long(self):
|
||||
message = self.error('element geom {\n size : double[3] = {1, 2, 3, 4}\n}')
|
||||
self.assertIn('at most 3', message)
|
||||
|
||||
def test_default_too_short(self):
|
||||
message = self.error('element geom {\n size : double[3] = {1, 2}\n}')
|
||||
self.assertIn('at least 3', message)
|
||||
|
||||
def test_vector_default_on_scalar(self):
|
||||
message = self.error('element geom {\n mass : double = {1, 2}\n}')
|
||||
self.assertIn('vector default for scalar', message)
|
||||
|
||||
def test_enum_default_not_a_keyword(self):
|
||||
message = self.error('enum e {\n a = 0\n}\n'
|
||||
'element geom {\n t : enum<e> = b\n}')
|
||||
self.assertIn('not a keyword', message)
|
||||
|
||||
def test_unknown_facet(self):
|
||||
message = self.error('element geom {\n a : int (frobnicate)\n}')
|
||||
self.assertIn("unknown facet 'frobnicate'", message)
|
||||
|
||||
def test_required_with_default(self):
|
||||
message = self.error('element geom {\n a : int = 1 (required)\n}')
|
||||
self.assertIn('required and has a default', message)
|
||||
|
||||
def test_variant_with_required(self):
|
||||
message = self.error('group g variant {\n a : int (required)\n}\n'
|
||||
'element geom {\n use g\n}')
|
||||
self.assertIn('may not be required', message)
|
||||
|
||||
def test_variant_with_use(self):
|
||||
message = self.error('group inner {\n a : int\n}\n'
|
||||
'group g variant {\n use inner\n}')
|
||||
self.assertIn("may not contain 'use'", message)
|
||||
|
||||
def test_duplicate_enum_keyword(self):
|
||||
message = self.error('enum e {\n a = 0\n a = 1\n}')
|
||||
self.assertIn('duplicate enum keyword', message)
|
||||
|
||||
def test_duplicate_child(self):
|
||||
message = self.error('element a {}\n'
|
||||
'element geom {\n child a *\n child a ?\n}')
|
||||
self.assertIn("duplicate child 'a'", message)
|
||||
|
||||
def test_empty_enum(self):
|
||||
message = self.error('enum e {\n}')
|
||||
self.assertIn('is empty', message)
|
||||
|
||||
def test_child_in_group(self):
|
||||
message = self.error('group g {\n child geom *\n}')
|
||||
self.assertIn('not allowed in a group', message)
|
||||
|
||||
def test_decreasing_arity(self):
|
||||
message = self.error('element geom {\n a : double[3..2]\n}')
|
||||
self.assertIn('not increasing', message)
|
||||
|
||||
def test_constraints(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element connect {\n'
|
||||
' site1 : ref<site>\n site2 : ref<site>\n'
|
||||
' body1 : string\n anchor : double[3]\n'
|
||||
' exclusive site1+site2 body1+anchor # semantics cannot mix\n'
|
||||
' oneof site1+site2 body1+anchor\n'
|
||||
' requires site1 site2\n'
|
||||
'}\n'
|
||||
'element site {\n name : id<site>\n}')
|
||||
cons = schema.elements['connect'].constraints()
|
||||
self.assertEqual([c.kind for c in cons],
|
||||
['exclusive', 'oneof', 'requires'])
|
||||
self.assertEqual(cons[0].bundles,
|
||||
[('site1', 'site2'), ('body1', 'anchor')])
|
||||
self.assertEqual(cons[0].doc, 'semantics cannot mix')
|
||||
self.assertEqual(cons[2].bundles, [('site1',), ('site2',)])
|
||||
|
||||
def test_constraint_unknown_attr(self):
|
||||
message = self.error('element a {\n x : int\n exclusive x nosuch\n}')
|
||||
self.assertIn("unknown attribute 'nosuch'", message)
|
||||
|
||||
def test_requires_arity(self):
|
||||
message = self.error(
|
||||
'element a {\n x : int\n y : int\n z : int\n'
|
||||
' requires x y+z\n}')
|
||||
self.assertIn('exactly two attributes', message)
|
||||
|
||||
def test_flags_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'enum camout : mjtCamOutBit {\n rgb = mjCAMOUT_RGB\n}\n'
|
||||
'element camera {\n output : flags<camout>\n}')
|
||||
attr = schema.elements['camera'].members[0]
|
||||
self.assertEqual(attr.type, 'flags')
|
||||
self.assertEqual(attr.target, 'camout')
|
||||
|
||||
def test_min_max_positive_facets(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element size {\n'
|
||||
' nkey : int (min=-1)\n'
|
||||
' group : int (min=0, max=5)\n'
|
||||
' znear : float (positive)\n'
|
||||
'}')
|
||||
attrs = {a.name: a for a in schema.elements['size'].members}
|
||||
self.assertEqual(attrs['nkey'].facets['min'], -1.0)
|
||||
self.assertEqual(attrs['group'].facets['max'], 5.0)
|
||||
self.assertTrue(attrs['znear'].facets['positive'])
|
||||
|
||||
def test_min_on_string_rejected(self):
|
||||
message = self.error('element a {\n s : string (min=0)\n}')
|
||||
self.assertIn('requires a numeric attribute', message)
|
||||
|
||||
def test_const_member(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element touch : mjsSensor {\n'
|
||||
' set type = mjSENS_TOUCH # sensor type from tag\n'
|
||||
' set objtype = mjOBJ_SITE\n'
|
||||
' a : int\n}')
|
||||
consts = schema.elements['touch'].consts()
|
||||
self.assertEqual([(c.field, c.value) for c in consts],
|
||||
[('type', 'mjSENS_TOUCH'), ('objtype', 'mjOBJ_SITE')])
|
||||
self.assertEqual(consts[0].doc, 'sensor type from tag')
|
||||
|
||||
def test_const_in_group_rejected(self):
|
||||
message = self.error('group g {\n set type = mjSENS_TOUCH\n}')
|
||||
self.assertIn('not allowed in a group', message)
|
||||
|
||||
def test_element_facets(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element body {}\n'
|
||||
'element eq_joint : mjsEquality (xml=joint) {\n'
|
||||
' polycoef : double[5]\n}\n'
|
||||
'element frame (alias=body) {}\n')
|
||||
self.assertEqual(schema.elements['eq_joint'].xml_name(), 'joint')
|
||||
self.assertEqual(schema.elements['body'].xml_name(), 'body')
|
||||
self.assertEqual(schema.elements['frame'].facets, {'alias': 'body'})
|
||||
|
||||
def test_element_field_facet(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element global : mjVisual (field=global) {\n fovy : double\n}')
|
||||
self.assertEqual(schema.elements['global'].facets['field'], 'global')
|
||||
|
||||
def test_element_unknown_facet(self):
|
||||
message = self.error('element geom (required) {}')
|
||||
self.assertIn("unknown facet 'required'", message)
|
||||
|
||||
def test_element_dangling_alias(self):
|
||||
message = self.error('element frame (alias=nosuch) {}')
|
||||
self.assertIn("undeclared element 'nosuch'", message)
|
||||
|
||||
def test_bool_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element compiler {\n autolimits : bool = true\n}')
|
||||
attr = schema.elements['compiler'].members[0]
|
||||
self.assertEqual(attr.type, 'bool')
|
||||
self.assertEqual(attr.default, 'true')
|
||||
|
||||
def test_bool_bad_default(self):
|
||||
message = self.error('element compiler {\n autolimits : bool = maybe\n}')
|
||||
self.assertIn('must be true or false', message)
|
||||
|
||||
def test_bool_vector_rejected(self):
|
||||
message = self.error('element compiler {\n a : bool[2]\n}')
|
||||
self.assertIn('may not be a vector', message)
|
||||
|
||||
def test_file_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element mesh {\n file : file (required) # mesh file\n}')
|
||||
attr = schema.elements['mesh'].members[0]
|
||||
self.assertEqual(attr.type, 'file')
|
||||
self.assertTrue(attr.arity.is_scalar())
|
||||
|
||||
def test_file_vector_rejected(self):
|
||||
message = self.error('element mesh {\n file : file[3]\n}')
|
||||
self.assertIn('may not be a vector', message)
|
||||
|
||||
def test_pattern_on_numeric(self):
|
||||
message = self.error('element geom {\n a : int (pattern="x")\n}')
|
||||
self.assertIn("'pattern' requires a text attribute", message)
|
||||
|
||||
def test_chars_type(self):
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element compiler {\n eulerseq : chars[3] (pattern="[xyz]{3}")\n}')
|
||||
attr = schema.elements['compiler'].members[0]
|
||||
self.assertEqual(attr.type, 'chars')
|
||||
self.assertEqual((attr.arity.lo, attr.arity.hi), (3, 3))
|
||||
|
||||
def test_chars_unbounded_rejected(self):
|
||||
message = self.error('element compiler {\n a : chars[]\n}')
|
||||
self.assertIn('must declare a bounded length', message)
|
||||
|
||||
def test_chars_with_default_rejected(self):
|
||||
message = self.error('element compiler {\n a : chars[3] = "xyz"\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_ref_with_default(self):
|
||||
message = self.error('element a {\n name : id<a>\n}\n'
|
||||
'element geom {\n r : ref<a> = a\n}')
|
||||
self.assertIn('may not have a default', message)
|
||||
|
||||
def test_string_lexing_range_vs_float(self):
|
||||
# 0..3 must lex as a range, not the floats '0.' and '.3'.
|
||||
schema = mjcf_schema.parse_string(
|
||||
'element geom {\n a : double[0..3] = {0.5, .25, 1e-3}\n}')
|
||||
attr = schema.elements['geom'].members[0]
|
||||
self.assertEqual(attr.arity, mjcf_schema.Arity(0, 3))
|
||||
self.assertEqual(attr.default, (0.5, 0.25, 0.001))
|
||||
|
||||
def test_min_max_integer_facet(self):
|
||||
# Integer values in facets dictionary must be accepted as numeric.
|
||||
attr = mjcf_schema.Attr(
|
||||
name='group', type='int', target=None,
|
||||
arity=mjcf_schema.Arity(1, 1), default=None,
|
||||
facets={'min': 0, 'max': 5}, doc=None, line=1)
|
||||
element = mjcf_schema.Element(
|
||||
name='geom', spec=None, facets={}, members=[attr], doc=None, line=1)
|
||||
schema = mjcf_schema.Schema(
|
||||
enums={}, groups={}, elements={'geom': element}, path='<test>')
|
||||
mjcf_schema._validate(schema)
|
||||
|
||||
def test_min_greater_than_max(self):
|
||||
message = self.error(
|
||||
'element geom {\n a : double (min=10, max=5)\n}')
|
||||
self.assertIn("facet 'min' cannot be greater than 'max'", message)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
googletest.main()
|
||||
Reference in New Issue
Block a user