diff --git a/CHANGELOG.md b/CHANGELOG.md
index 4fd77a7..20e8725 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -25,6 +25,13 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Changed
+- Editors and type checkers now accept the documented `snake_case`
+ constructor arguments, such as `InternalElement(name="Motor")`. The CAEX
+ aliases moved into `Annotated[...]` field metadata, which pyright and
+ Pylance ignore when naming `__init__` parameters. Previously pyright
+ reported every such argument as unknown and the alias as missing. Runtime
+ behavior, field metadata, AML JSON/XML output, and the generated JSON Schema
+ are unchanged.
- The README is now a beginner-oriented introduction: what AutomationML is, the
core CAEX concepts, installation including extras, and a step-by-step
tutorial whose every snippet runs on the base install. The OPC UA usage,
@@ -58,6 +65,25 @@ and this project follows [Semantic Versioning](https://semver.org/).
### Added
+- `automationml.__version__`, defined once in `automationml/_version.py`,
+ which the build also reads. `source_document()` and `caex_file()` now record
+ it as `OriginVersion` instead of a hard-coded `0.1.0`.
+- `MissingDependencyError`, a `ModuleNotFoundError` subclass raised when OPC
+ UA or RDF conversion is used without its extra. Its message names the
+ `pip install "automationml[...]"` command, replacing a bare
+ `No module named 'lxml'`.
+- `AMLParseError` for AML XML that is not a readable CAEX 2.15 or 3.0
+ document: malformed XML, a non-`CAEXFile` root, or an unsupported schema
+ version. It subclasses `LegacyConversionError`, which was raised before, so
+ existing handlers keep working.
+- `AutomationMLError` and `CAEXValidationError` are now importable from the
+ top-level package.
+- Builders for all four class-library kinds: `system_unit_class`,
+ `system_unit_class_lib`, `role_class`, `role_class_lib`, `interface_class`,
+ `interface_class_lib`, `attribute_type`, and `attribute_type_lib`. They
+ return the hierarchical `*Family` models that libraries hold, so authors no
+ longer need to know that a library rejects a plain `SystemUnitClass` or
+ `RoleClass`.
- Extension-safe, namespace-aware mixed XML content in `AdditionalInformation`
with a documented AML JSON `$xml` representation and generated schema.
- Immutable `CAEXQuery` snapshots with ID/path lookup, reverse references,
diff --git a/README.md b/README.md
index e7406ea..f691705 100644
--- a/README.md
+++ b/README.md
@@ -109,6 +109,9 @@ brackets, for example `python -m pip install -e ".[opcua,rdf]"`:
| `learning` | Jupyter, for the [learning notebooks](learning/) |
| `dev` | Everything needed to run the test suite |
+If you use a feature without its extra, the SDK raises a
+`MissingDependencyError` that names the command to install it.
+
## Tutorial: your first plant model
This walkthrough uses only the base installation. Run the snippets in order in
@@ -148,7 +151,7 @@ The printed AML JSON uses the official AutomationML names:
{
"OriginName": "AutomationML Python SDK",
"OriginID": "automationml-python",
- "OriginVersion": "0.1.0",
+ "OriginVersion": "0.1.0a1",
"LastWritingDateTime": "2026-10-17T12:00:00Z"
}
],
@@ -216,19 +219,17 @@ error unresolved-system-unit-class-reference
### 4. Fix it with a class library
-Add the missing library with a `Motor` template. Libraries hold
-`SystemUnitFamily` entries, which are SystemUnitClasses that may contain
-sub-classes:
+Add the missing library with a `Motor` template:
```python
-from automationml import SystemUnitClassLib, SystemUnitFamily
+from automationml import system_unit_class, system_unit_class_lib
doc.system_unit_class_libs.append(
- SystemUnitClassLib(
- name="MyComponents",
- system_unit_classes=[
- SystemUnitFamily(
- name="Motor",
+ system_unit_class_lib(
+ "MyComponents",
+ classes=[
+ system_unit_class(
+ "Motor",
attributes=[attribute("speed", unit="rpm", data_type="xs:double")],
)
],
@@ -238,6 +239,9 @@ doc.system_unit_class_libs.append(
assert doc.caex_validation_issues() == [] # the reference resolves now
```
+`role_class_lib`, `interface_class_lib`, and `attribute_type_lib` build the
+other three kinds of library in the same way.
+
### 5. Create objects from the template
```python
@@ -294,7 +298,9 @@ official CAEX names:
All models are [Pydantic](https://docs.pydantic.dev/) models, so you can also
build them from dictionaries that use either kind of name, and they check your
-values as you assign them.
+values as you assign them. Editors and type checkers such as VS Code (Pylance),
+pyright, and mypy understand the `snake_case` names, so you get autocompletion
+and warnings for typos or missing required fields.
## What else can it do?
diff --git a/docs/sdk-design.md b/docs/sdk-design.md
index 11d477f..7198bba 100644
--- a/docs/sdk-design.md
+++ b/docs/sdk-design.md
@@ -43,6 +43,22 @@ and the SDK serializes:
}
```
+The same holds for type checkers. Model fields declare their CAEX alias inside
+`Annotated` and keep the default on the right-hand side:
+
+```python
+name: Annotated[str, Field(alias="Name")]
+attributes: Annotated[list[Attribute], Field(alias="Attribute")] = Field(
+ default_factory=list
+)
+```
+
+Type checkers name a Pydantic model's `__init__` parameters after the alias of
+the assigned `Field`, but ignore `Field` metadata inside `Annotated`. Written
+this way, editors accept `InternalElement(name=...)`, while Pydantic still
+validates and serializes the `Name` alias. A plain `= Field(alias="Name")`
+would make pyright and Pylance reject the `snake_case` name.
+
## XML contract
XML is an adapter, not the primary model. The XML layer:
diff --git a/pyproject.toml b/pyproject.toml
index b4f1070..9afeded 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "automationml"
-version = "0.1.0a1"
+dynamic = ["version"]
description = "JSON-first Python SDK for AutomationML CAEX 3.0 documents."
readme = "README.md"
requires-python = ">=3.11"
@@ -75,6 +75,9 @@ Documentation = "https://github.com/AutomationML/automationml-python/tree/main/d
Issues = "https://github.com/AutomationML/automationml-python/issues"
Changelog = "https://github.com/AutomationML/automationml-python/blob/main/CHANGELOG.md"
+[tool.hatch.version]
+path = "src/automationml/_version.py"
+
[tool.hatch.build.targets.wheel]
packages = ["src/automationml"]
diff --git a/src/automationml/__init__.py b/src/automationml/__init__.py
index c6ad95b..3d9a472 100644
--- a/src/automationml/__init__.py
+++ b/src/automationml/__init__.py
@@ -1,13 +1,22 @@
"""AutomationML CAEX 3.0 JSON-first SDK."""
+from ._version import __version__
from .builders import (
attribute,
+ attribute_type,
+ attribute_type_lib,
caex_file,
external_interface,
instance_hierarchy,
+ interface_class,
+ interface_class_lib,
internal_element,
+ role_class,
+ role_class_lib,
role_requirement,
source_document,
+ system_unit_class,
+ system_unit_class_lib,
)
from .io import dump_json, dump_xml, load_json, load_xml
from .models import (
@@ -51,10 +60,14 @@
)
from .changes import ChangeOperation, ChangeSet, DocumentEdit, EditResult, diff_documents
from .exceptions import (
+ AMLParseError,
AmbiguousReferenceError,
+ AutomationMLError,
+ CAEXValidationError,
ChangeSetError,
LegacyConversionError,
LegacyConversionWarning,
+ MissingDependencyError,
ReferenceNotFoundError,
)
from .legacy import AMLImportResult, MigrationIssue
@@ -78,19 +91,23 @@
from .validation import CAEXIssue, ReferenceIndex, ReferenceTarget, validate_caex_document
__all__ = [
+ "__version__",
"AdditionalInformation",
"AMLImportResult",
+ "AMLParseError",
"AmbiguousReferenceError",
"Attribute",
"AttributeNameMapping",
"AttributeType",
"AttributeTypeLib",
"AttributeValueRequirement",
+ "AutomationMLError",
"CAEXBasicObject",
"CAEXFile",
"CAEXIssue",
"CAEXObject",
"CAEXQuery",
+ "CAEXValidationError",
"ChangeOperation",
"ChangeSet",
"ChangeSetError",
@@ -112,6 +129,7 @@
"MergePolicy",
"MergeResult",
"MigrationIssue",
+ "MissingDependencyError",
"NominalScaledType",
"OrdinalScaledType",
"ReferenceIndex",
@@ -135,18 +153,26 @@
"XmlExtensionNode",
"XmlExtensionPayload",
"attribute",
+ "attribute_type",
+ "attribute_type_lib",
"caex_file",
"dump_json",
"dump_xml",
"diff_documents",
"external_interface",
"instance_hierarchy",
+ "interface_class",
+ "interface_class_lib",
"internal_element",
"load_json",
"load_xml",
"merge_documents",
+ "role_class",
+ "role_class_lib",
"role_requirement",
"source_document",
+ "system_unit_class",
+ "system_unit_class_lib",
"resolve_and_merge",
"resolve_external_references",
"validate_caex_document",
diff --git a/src/automationml/_version.py b/src/automationml/_version.py
new file mode 100644
index 0000000..5520d35
--- /dev/null
+++ b/src/automationml/_version.py
@@ -0,0 +1,7 @@
+"""Version of the AutomationML Python SDK.
+
+This is the single source of the package version: hatchling reads it at build
+time (see ``[tool.hatch.version]`` in ``pyproject.toml``).
+"""
+
+__version__ = "0.1.0a1"
diff --git a/src/automationml/builders.py b/src/automationml/builders.py
index 0480a3e..3cef8dc 100644
--- a/src/automationml/builders.py
+++ b/src/automationml/builders.py
@@ -5,22 +5,34 @@
from datetime import datetime, timezone
from typing import Iterable, Mapping
+from ._version import __version__
from .models import (
Attribute,
+ AttributeType,
+ AttributeTypeLib,
CAEXFile,
InstanceHierarchy,
InterfaceClass,
+ InterfaceClassLib,
+ InterfaceFamily,
InternalElement,
+ RoleClassLib,
+ RoleFamily,
RoleRequirements,
SourceDocumentInformation,
+ SupportedRoleClass,
+ SystemUnitClassLib,
+ SystemUnitFamily,
)
+AttributeInput = Mapping[str, str | int | float | bool] | Iterable[Attribute] | None
+
def source_document(
*,
origin_name: str = "AutomationML Python SDK",
origin_id: str = "automationml-python",
- origin_version: str = "0.1.0",
+ origin_version: str = __version__,
last_writing_date_time: datetime | None = None,
) -> SourceDocumentInformation:
"""Create CAEX source document metadata."""
@@ -90,31 +102,31 @@ def role_requirement(ref_base_role_class_path: str) -> RoleRequirements:
return RoleRequirements(ref_base_role_class_path=ref_base_role_class_path)
+def _attribute_list(attributes: AttributeInput) -> list[Attribute]:
+ if attributes is None:
+ return []
+ if isinstance(attributes, Mapping):
+ return [attribute(key, value) for key, value in attributes.items()]
+ return list(attributes)
+
+
def internal_element(
name: str,
*,
id: str | None = None,
ref_base_system_unit_path: str | None = None,
- attributes: Mapping[str, str | int | float | bool] | Iterable[Attribute] | None = None,
+ attributes: AttributeInput = None,
role_paths: Iterable[str] | None = None,
children: Iterable[InternalElement] | None = None,
external_interfaces: Iterable[InterfaceClass] | None = None,
) -> InternalElement:
"""Create an InternalElement with common nested collections."""
- attr_list: list[Attribute]
- if attributes is None:
- attr_list = []
- elif isinstance(attributes, Mapping):
- attr_list = [attribute(key, value) for key, value in attributes.items()]
- else:
- attr_list = list(attributes)
-
return InternalElement(
id=id,
name=name,
ref_base_system_unit_path=ref_base_system_unit_path,
- attributes=attr_list,
+ attributes=_attribute_list(attributes),
role_requirements=[
role_requirement(role_path) for role_path in (role_paths or [])
],
@@ -123,6 +135,153 @@ def internal_element(
)
+def system_unit_class(
+ name: str,
+ *,
+ id: str | None = None,
+ ref_base_class_path: str | None = None,
+ attributes: AttributeInput = None,
+ role_paths: Iterable[str] | None = None,
+ children: Iterable[InternalElement] | None = None,
+ external_interfaces: Iterable[InterfaceClass] | None = None,
+ classes: Iterable[SystemUnitFamily] | None = None,
+) -> SystemUnitFamily:
+ """Create a SystemUnitClass, a reusable template for InternalElements.
+
+ ``ref_base_class_path`` names a class to inherit from, ``role_paths``
+ lists the RoleClasses the class supports, ``children`` holds its nested
+ InternalElements, and ``classes`` nests further SystemUnitClasses below
+ it in the library tree.
+ """
+
+ return SystemUnitFamily(
+ id=id,
+ name=name,
+ ref_base_class_path=ref_base_class_path,
+ attributes=_attribute_list(attributes),
+ supported_role_classes=[
+ SupportedRoleClass(ref_role_class_path=role_path)
+ for role_path in (role_paths or [])
+ ],
+ internal_elements=list(children or []),
+ external_interfaces=list(external_interfaces or []),
+ system_unit_classes=list(classes or []),
+ )
+
+
+def system_unit_class_lib(
+ name: str,
+ *,
+ classes: Iterable[SystemUnitFamily] | None = None,
+ id: str | None = None,
+) -> SystemUnitClassLib:
+ """Create a SystemUnitClassLib holding SystemUnitClasses."""
+
+ return SystemUnitClassLib(id=id, name=name, system_unit_classes=list(classes or []))
+
+
+def role_class(
+ name: str,
+ *,
+ id: str | None = None,
+ ref_base_class_path: str | None = None,
+ attributes: AttributeInput = None,
+ external_interfaces: Iterable[InterfaceClass] | None = None,
+ classes: Iterable[RoleFamily] | None = None,
+) -> RoleFamily:
+ """Create a RoleClass, a vendor-neutral description of what an object is.
+
+ ``classes`` nests further RoleClasses below it in the library tree.
+ """
+
+ return RoleFamily(
+ id=id,
+ name=name,
+ ref_base_class_path=ref_base_class_path,
+ attributes=_attribute_list(attributes),
+ external_interfaces=list(external_interfaces or []),
+ role_classes=list(classes or []),
+ )
+
+
+def role_class_lib(
+ name: str,
+ *,
+ classes: Iterable[RoleFamily] | None = None,
+ id: str | None = None,
+) -> RoleClassLib:
+ """Create a RoleClassLib holding RoleClasses."""
+
+ return RoleClassLib(id=id, name=name, role_classes=list(classes or []))
+
+
+def interface_class(
+ name: str,
+ *,
+ id: str | None = None,
+ ref_base_class_path: str | None = None,
+ attributes: AttributeInput = None,
+ classes: Iterable[InterfaceFamily] | None = None,
+) -> InterfaceFamily:
+ """Create an InterfaceClass, a reusable type for ExternalInterfaces.
+
+ ``classes`` nests further InterfaceClasses below it in the library tree.
+ """
+
+ return InterfaceFamily(
+ id=id,
+ name=name,
+ ref_base_class_path=ref_base_class_path,
+ attributes=_attribute_list(attributes),
+ interface_classes=list(classes or []),
+ )
+
+
+def interface_class_lib(
+ name: str,
+ *,
+ classes: Iterable[InterfaceFamily] | None = None,
+ id: str | None = None,
+) -> InterfaceClassLib:
+ """Create an InterfaceClassLib holding InterfaceClasses."""
+
+ return InterfaceClassLib(id=id, name=name, interface_classes=list(classes or []))
+
+
+def attribute_type(
+ name: str,
+ value: str | int | float | bool | None = None,
+ *,
+ data_type: str | None = None,
+ unit: str | None = None,
+ id: str | None = None,
+) -> AttributeType:
+ """Create an AttributeType, a reusable attribute definition."""
+
+ return AttributeType(
+ id=id,
+ name=name,
+ value=None if value is None else str(value),
+ attribute_data_type=data_type,
+ unit=unit,
+ )
+
+
+def attribute_type_lib(
+ name: str,
+ *,
+ attribute_types: Iterable[AttributeType] | None = None,
+ id: str | None = None,
+) -> AttributeTypeLib:
+ """Create an AttributeTypeLib holding AttributeTypes."""
+
+ return AttributeTypeLib(
+ id=id,
+ name=name,
+ attribute_types=list(attribute_types or []),
+ )
+
+
def instance_hierarchy(
name: str,
*,
diff --git a/src/automationml/exceptions.py b/src/automationml/exceptions.py
index da71834..7257c56 100644
--- a/src/automationml/exceptions.py
+++ b/src/automationml/exceptions.py
@@ -25,5 +25,24 @@ class LegacyConversionError(ValueError, AutomationMLError):
"""Raised when CAEX 2.15 cannot be upgraded without data loss."""
+class AMLParseError(LegacyConversionError):
+ """Raised when AML XML is not a readable CAEX 2.15 or CAEX 3.0 document.
+
+ It subclasses ``LegacyConversionError``, which earlier versions raised for
+ the same input, so existing handlers keep working.
+ """
+
+
+class MissingDependencyError(ModuleNotFoundError, AutomationMLError):
+ """Raised when an optional feature is used without its extra installed."""
+
+ def __init__(self, feature: str, module: str, extra: str) -> None:
+ super().__init__(
+ f"{feature} needs the optional dependency {module!r}. Install it with: "
+ f'python -m pip install "automationml[{extra}]"',
+ name=module,
+ )
+
+
class ChangeSetError(ValueError, AutomationMLError):
"""Raised when a document change set is invalid or targets the wrong base."""
diff --git a/src/automationml/legacy.py b/src/automationml/legacy.py
index 9b4d22c..8349f83 100644
--- a/src/automationml/legacy.py
+++ b/src/automationml/legacy.py
@@ -14,7 +14,7 @@
import xmlschema
-from .exceptions import LegacyConversionError, LegacyConversionWarning
+from .exceptions import AMLParseError, LegacyConversionError, LegacyConversionWarning
from .models import CAEXFile, InternalElement, RoleRequirements, SourceDocumentInformation
CAEX_NS = "http://www.dke.de/CAEX"
@@ -50,9 +50,9 @@ def import_aml_xml(
try:
root = parse_xml_element(raw)
except (ET.ParseError, ValueError) as exc:
- raise LegacyConversionError(f"Invalid AML XML: {exc}") from exc
+ raise AMLParseError(f"Invalid AML XML: {exc}") from exc
if _local(root.tag) != "CAEXFile":
- raise LegacyConversionError(f"Expected CAEXFile root, got {_local(root.tag)!r}")
+ raise AMLParseError(f"Expected CAEXFile root, got {_local(root.tag)!r}")
version = root.get("SchemaVersion", "")
namespace = _namespace(root.tag)
@@ -65,7 +65,7 @@ def import_aml_xml(
converted=False,
)
if version != "2.15":
- raise LegacyConversionError(
+ raise AMLParseError(
f"Unsupported CAEX schema version {version!r}; expected 2.15 or 3.0"
)
diff --git a/src/automationml/models.py b/src/automationml/models.py
index 6fef85c..864713e 100644
--- a/src/automationml/models.py
+++ b/src/automationml/models.py
@@ -10,7 +10,7 @@
import json
from datetime import date, datetime
from enum import Enum
-from typing import TYPE_CHECKING, Any, Callable, Self
+from typing import TYPE_CHECKING, Annotated, Any, Callable, Self
from pydantic import BaseModel, ConfigDict, Field, model_validator
@@ -125,16 +125,12 @@ def from_aml_json(cls, data: str | bytes) -> Self:
class TextElement(AmlModel):
"""Simple CAEX text element with an optional ChangeMode attribute."""
- value: str = Field(
- default="",
- alias="value",
- description="Text content of the XML element.",
- )
- change_mode: ChangeMode = Field(
- default=ChangeMode.STATE,
- alias="ChangeMode",
- description="Optional CAEX change state.",
- )
+ value: Annotated[
+ str, Field(alias="value", description="Text content of the XML element.")
+ ] = ""
+ change_mode: Annotated[
+ ChangeMode, Field(alias="ChangeMode", description="Optional CAEX change state.")
+ ] = ChangeMode.STATE
class XmlExtensionNode(AmlModel):
@@ -146,18 +142,26 @@ class XmlExtensionNode(AmlModel):
ordering without preserving insignificant lexical formatting.
"""
- name: str = Field(alias="Name")
- attributes: dict[str, str] = Field(default_factory=dict, alias="Attributes")
- text: str | None = Field(default=None, alias="Text")
- tail: str | None = Field(default=None, alias="Tail")
- children: list[XmlExtensionNode] = Field(default_factory=list, alias="Children")
+ name: Annotated[str, Field(alias="Name")]
+ attributes: Annotated[
+ dict[str, str], Field(alias="Attributes")
+ ] = Field(default_factory=dict)
+ text: Annotated[str | None, Field(alias="Text")] = None
+ tail: Annotated[str | None, Field(alias="Tail")] = None
+ children: Annotated[
+ list[XmlExtensionNode], Field(alias="Children")
+ ] = Field(default_factory=list)
class XmlExtensionPayload(AmlModel):
"""Non-trivial XML content carried by ``AdditionalInformation``."""
- attributes: dict[str, str] = Field(default_factory=dict, alias="Attributes")
- children: list[XmlExtensionNode] = Field(default_factory=list, alias="Children")
+ attributes: Annotated[
+ dict[str, str], Field(alias="Attributes")
+ ] = Field(default_factory=dict)
+ children: Annotated[
+ list[XmlExtensionNode], Field(alias="Children")
+ ] = Field(default_factory=list)
class AdditionalInformation(TextElement):
@@ -174,161 +178,148 @@ class AdditionalInformation(TextElement):
use_enum_values=True,
)
- aml_version: str | None = Field(default=None, alias="AutomationMLVersion")
- document_versions: str | None = Field(default=None, alias="DocumentVersions")
- xml: XmlExtensionPayload | None = Field(default=None, alias="$xml")
+ aml_version: Annotated[str | None, Field(alias="AutomationMLVersion")] = None
+ document_versions: Annotated[str | None, Field(alias="DocumentVersions")] = None
+ xml: Annotated[XmlExtensionPayload | None, Field(alias="$xml")] = None
class SourceObjectInformation(TextElement):
"""Source object metadata attached to a CAEX object."""
- origin_id: str | None = Field(default=None, alias="OriginID")
- source_obj_id: str | None = Field(default=None, alias="SourceObjID")
+ origin_id: Annotated[str | None, Field(alias="OriginID")] = None
+ source_obj_id: Annotated[str | None, Field(alias="SourceObjID")] = None
class Revision(AmlModel):
"""Revision entry in a CAEX header."""
- change_mode: ChangeMode = Field(default=ChangeMode.STATE, alias="ChangeMode")
- revision_date: datetime | None = Field(default=None, alias="RevisionDate")
- old_version: str | None = Field(default=None, alias="OldVersion")
- new_version: str | None = Field(default=None, alias="NewVersion")
- author_name: str | None = Field(default=None, alias="AuthorName")
- comment: str | None = Field(default=None, alias="Comment")
+ change_mode: Annotated[ChangeMode, Field(alias="ChangeMode")] = ChangeMode.STATE
+ revision_date: Annotated[datetime | None, Field(alias="RevisionDate")] = None
+ old_version: Annotated[str | None, Field(alias="OldVersion")] = None
+ new_version: Annotated[str | None, Field(alias="NewVersion")] = None
+ author_name: Annotated[str | None, Field(alias="AuthorName")] = None
+ comment: Annotated[str | None, Field(alias="Comment")] = None
class CAEXBasicObject(AmlModel):
"""Base CAEX object carrying header data and change state."""
- description: TextElement | None = Field(
- default=None,
- alias="Description",
- description="Textual description for CAEX objects.",
- )
- version: TextElement | None = Field(
- default=None,
- alias="Version",
- description="Organizational version information.",
- )
- revision: list[Revision] = Field(
- default_factory=list,
- alias="Revision",
- description="Revision history entries.",
- )
- copyright: TextElement | None = Field(default=None, alias="Copyright")
- additional_information: list[AdditionalInformation] = Field(
- default_factory=list,
- alias="AdditionalInformation",
- )
- source_object_information: list[SourceObjectInformation] = Field(
- default_factory=list,
- alias="SourceObjectInformation",
- )
- change_mode: ChangeMode = Field(
- default=ChangeMode.STATE,
- alias="ChangeMode",
- description="Optional CAEX change state.",
- )
+ description: Annotated[
+ TextElement | None,
+ Field(alias="Description", description="Textual description for CAEX objects."),
+ ] = None
+ version: Annotated[
+ TextElement | None,
+ Field(alias="Version", description="Organizational version information."),
+ ] = None
+ revision: Annotated[
+ list[Revision], Field(alias="Revision", description="Revision history entries.")
+ ] = Field(default_factory=list)
+ copyright: Annotated[TextElement | None, Field(alias="Copyright")] = None
+ additional_information: Annotated[
+ list[AdditionalInformation], Field(alias="AdditionalInformation")
+ ] = Field(default_factory=list)
+ source_object_information: Annotated[
+ list[SourceObjectInformation], Field(alias="SourceObjectInformation")
+ ] = Field(default_factory=list)
+ change_mode: Annotated[
+ ChangeMode, Field(alias="ChangeMode", description="Optional CAEX change state.")
+ ] = ChangeMode.STATE
class CAEXObject(CAEXBasicObject):
"""Named CAEX object with an optional identifier."""
- id: str | None = Field(
- default=None,
- alias="ID",
- description="Optional unique identifier of the CAEX object.",
- )
- name: str = Field(
- alias="Name",
- description="Required CAEX object name.",
- )
+ id: Annotated[
+ str | None,
+ Field(alias="ID", description="Optional unique identifier of the CAEX object."),
+ ] = None
+ name: Annotated[str, Field(alias="Name", description="Required CAEX object name.")]
class SourceDocumentInformation(AmlModel):
"""Information about a source document that contributed to the CAEX file."""
- origin_name: str | None = Field(default=None, alias="OriginName")
- origin_id: str | None = Field(default=None, alias="OriginID")
- origin_vendor: str | None = Field(default=None, alias="OriginVendor")
- origin_vendor_url: str | None = Field(default=None, alias="OriginVendorURL")
- origin_version: str | None = Field(default=None, alias="OriginVersion")
- origin_release: str | None = Field(default=None, alias="OriginRelease")
- last_writing_date_time: datetime | None = Field(
- default=None,
- alias="LastWritingDateTime",
- )
- origin_project_title: str | None = Field(default=None, alias="OriginProjectTitle")
- origin_project_id: str | None = Field(default=None, alias="OriginProjectID")
+ origin_name: Annotated[str | None, Field(alias="OriginName")] = None
+ origin_id: Annotated[str | None, Field(alias="OriginID")] = None
+ origin_vendor: Annotated[str | None, Field(alias="OriginVendor")] = None
+ origin_vendor_url: Annotated[str | None, Field(alias="OriginVendorURL")] = None
+ origin_version: Annotated[str | None, Field(alias="OriginVersion")] = None
+ origin_release: Annotated[str | None, Field(alias="OriginRelease")] = None
+ last_writing_date_time: Annotated[
+ datetime | None, Field(alias="LastWritingDateTime")
+ ] = None
+ origin_project_title: Annotated[
+ str | None, Field(alias="OriginProjectTitle")
+ ] = None
+ origin_project_id: Annotated[str | None, Field(alias="OriginProjectID")] = None
class ExternalReference(CAEXBasicObject):
"""Alias definition for an external CAEX file."""
- path: str = Field(alias="Path")
- file_alias: str = Field(alias="Alias")
+ path: Annotated[str, Field(alias="Path")]
+ file_alias: Annotated[str, Field(alias="Alias")]
class AttributeNameMapping(CAEXBasicObject):
"""Mapping between system-unit and role attributes."""
- system_unit_attribute_name: str = Field(alias="SystemUnitAttributeName")
- role_attribute_name: str = Field(alias="RoleAttributeName")
+ system_unit_attribute_name: Annotated[str, Field(alias="SystemUnitAttributeName")]
+ role_attribute_name: Annotated[str, Field(alias="RoleAttributeName")]
class InterfaceIDMapping(CAEXBasicObject):
"""Mapping between system-unit and role interface IDs."""
- system_unit_interface_id: str = Field(alias="SystemUnitInterfaceID")
- role_interface_id: str = Field(alias="RoleInterfaceID")
+ system_unit_interface_id: Annotated[str, Field(alias="SystemUnitInterfaceID")]
+ role_interface_id: Annotated[str, Field(alias="RoleInterfaceID")]
class MappingObject(CAEXBasicObject):
"""Host object for CAEX attribute and interface mappings."""
- attribute_name_mapping: list[AttributeNameMapping] = Field(
- default_factory=list,
- alias="AttributeNameMapping",
- )
- interface_id_mapping: list[InterfaceIDMapping] = Field(
- default_factory=list,
- alias="InterfaceIDMapping",
- )
+ attribute_name_mapping: Annotated[
+ list[AttributeNameMapping], Field(alias="AttributeNameMapping")
+ ] = Field(default_factory=list)
+ interface_id_mapping: Annotated[
+ list[InterfaceIDMapping], Field(alias="InterfaceIDMapping")
+ ] = Field(default_factory=list)
class OrdinalScaledType(AmlModel):
"""Ordinal value constraint."""
- required_max_value: str | None = Field(default=None, alias="RequiredMaxValue")
- required_value: str | None = Field(default=None, alias="RequiredValue")
- required_min_value: str | None = Field(default=None, alias="RequiredMinValue")
+ required_max_value: Annotated[str | None, Field(alias="RequiredMaxValue")] = None
+ required_value: Annotated[str | None, Field(alias="RequiredValue")] = None
+ required_min_value: Annotated[str | None, Field(alias="RequiredMinValue")] = None
class NominalScaledType(AmlModel):
"""Nominal value constraint."""
- required_values: list[str] = Field(default_factory=list, alias="RequiredValue")
+ required_values: Annotated[
+ list[str], Field(alias="RequiredValue")
+ ] = Field(default_factory=list)
class UnknownType(AmlModel):
"""Free-form value requirement."""
- requirements: str | None = Field(default=None, alias="Requirements")
+ requirements: Annotated[str | None, Field(alias="Requirements")] = None
class AttributeValueRequirement(CAEXBasicObject):
"""Constraint for an attribute value."""
- name: str = Field(alias="Name")
- ordinal_scaled_type: OrdinalScaledType | None = Field(
- default=None,
- alias="OrdinalScaledType",
- )
- nominal_scaled_type: NominalScaledType | None = Field(
- default=None,
- alias="NominalScaledType",
- )
- unknown_type: UnknownType | None = Field(default=None, alias="UnknownType")
+ name: Annotated[str, Field(alias="Name")]
+ ordinal_scaled_type: Annotated[
+ OrdinalScaledType | None, Field(alias="OrdinalScaledType")
+ ] = None
+ nominal_scaled_type: Annotated[
+ NominalScaledType | None, Field(alias="NominalScaledType")
+ ] = None
+ unknown_type: Annotated[UnknownType | None, Field(alias="UnknownType")] = None
@model_validator(mode="after")
def _warn_about_multiple_constraint_shapes(self) -> Self:
@@ -348,228 +339,220 @@ def _warn_about_multiple_constraint_shapes(self) -> Self:
class RefSemantic(CAEXBasicObject):
"""Semantic reference for an attribute."""
- corresponding_attribute_path: str = Field(alias="CorrespondingAttributePath")
+ corresponding_attribute_path: Annotated[
+ str, Field(alias="CorrespondingAttributePath")
+ ]
class Attribute(CAEXObject):
"""CAEX attribute instance."""
- default_value: str | None = Field(default=None, alias="DefaultValue")
- value: str | None = Field(default=None, alias="Value")
- ref_semantic: list[RefSemantic] = Field(
- default_factory=list,
- alias="RefSemantic",
- )
- constraint: list[AttributeValueRequirement] = Field(
- default_factory=list,
- alias="Constraint",
- )
- attributes: list[Attribute] = Field(default_factory=list, alias="Attribute")
- unit: str | None = Field(default=None, alias="Unit")
- attribute_data_type: str | None = Field(default=None, alias="AttributeDataType")
- ref_attribute_type: str | None = Field(default=None, alias="RefAttributeType")
+ default_value: Annotated[str | None, Field(alias="DefaultValue")] = None
+ value: Annotated[str | None, Field(alias="Value")] = None
+ ref_semantic: Annotated[
+ list[RefSemantic], Field(alias="RefSemantic")
+ ] = Field(default_factory=list)
+ constraint: Annotated[
+ list[AttributeValueRequirement], Field(alias="Constraint")
+ ] = Field(default_factory=list)
+ attributes: Annotated[
+ list[Attribute], Field(alias="Attribute")
+ ] = Field(default_factory=list)
+ unit: Annotated[str | None, Field(alias="Unit")] = None
+ attribute_data_type: Annotated[str | None, Field(alias="AttributeDataType")] = None
+ ref_attribute_type: Annotated[str | None, Field(alias="RefAttributeType")] = None
class AttributeType(Attribute):
"""Attribute type definition inside an AttributeTypeLib."""
- attribute_types: list[AttributeType] = Field(
- default_factory=list,
- alias="AttributeType",
- )
+ attribute_types: Annotated[
+ list[AttributeType], Field(alias="AttributeType")
+ ] = Field(default_factory=list)
class InterfaceClass(CAEXObject):
"""Interface class or external interface."""
- attributes: list[Attribute] = Field(default_factory=list, alias="Attribute")
- external_interfaces: list[InterfaceClass] = Field(
- default_factory=list,
- alias="ExternalInterface",
- )
- ref_base_class_path: str | None = Field(default=None, alias="RefBaseClassPath")
+ attributes: Annotated[
+ list[Attribute], Field(alias="Attribute")
+ ] = Field(default_factory=list)
+ external_interfaces: Annotated[
+ list[InterfaceClass], Field(alias="ExternalInterface")
+ ] = Field(default_factory=list)
+ ref_base_class_path: Annotated[str | None, Field(alias="RefBaseClassPath")] = None
class InterfaceFamily(InterfaceClass):
"""Hierarchical interface class definition."""
- interface_classes: list[InterfaceFamily] = Field(
- default_factory=list,
- alias="InterfaceClass",
- )
+ interface_classes: Annotated[
+ list[InterfaceFamily], Field(alias="InterfaceClass")
+ ] = Field(default_factory=list)
class RoleClass(CAEXObject):
"""Role class definition."""
- attributes: list[Attribute] = Field(default_factory=list, alias="Attribute")
- external_interfaces: list[InterfaceClass] = Field(
- default_factory=list,
- alias="ExternalInterface",
- )
- ref_base_class_path: str | None = Field(default=None, alias="RefBaseClassPath")
+ attributes: Annotated[
+ list[Attribute], Field(alias="Attribute")
+ ] = Field(default_factory=list)
+ external_interfaces: Annotated[
+ list[InterfaceClass], Field(alias="ExternalInterface")
+ ] = Field(default_factory=list)
+ ref_base_class_path: Annotated[str | None, Field(alias="RefBaseClassPath")] = None
class RoleFamily(RoleClass):
"""Hierarchical role class definition."""
- role_classes: list[RoleFamily] = Field(default_factory=list, alias="RoleClass")
+ role_classes: Annotated[
+ list[RoleFamily], Field(alias="RoleClass")
+ ] = Field(default_factory=list)
class SupportedRoleClass(CAEXBasicObject):
"""Role class supported by a system unit class."""
- ref_role_class_path: str = Field(alias="RefRoleClassPath")
- mapping_object: MappingObject | None = Field(default=None, alias="MappingObject")
+ ref_role_class_path: Annotated[str, Field(alias="RefRoleClassPath")]
+ mapping_object: Annotated[MappingObject | None, Field(alias="MappingObject")] = None
class InternalLink(CAEXObject):
"""Relationship between internal interfaces."""
- ref_partner_side_a: str = Field(alias="RefPartnerSideA")
- ref_partner_side_b: str = Field(alias="RefPartnerSideB")
+ ref_partner_side_a: Annotated[str, Field(alias="RefPartnerSideA")]
+ ref_partner_side_b: Annotated[str, Field(alias="RefPartnerSideB")]
class SystemUnitClass(CAEXObject):
"""System unit class definition."""
- attributes: list[Attribute] = Field(default_factory=list, alias="Attribute")
- external_interfaces: list[InterfaceClass] = Field(
- default_factory=list,
- alias="ExternalInterface",
- )
- internal_elements: list[InternalElement] = Field(
- default_factory=list,
- alias="InternalElement",
- )
- supported_role_classes: list[SupportedRoleClass] = Field(
- default_factory=list,
- alias="SupportedRoleClass",
- )
- internal_links: list[InternalLink] = Field(
- default_factory=list,
- alias="InternalLink",
- )
+ attributes: Annotated[
+ list[Attribute], Field(alias="Attribute")
+ ] = Field(default_factory=list)
+ external_interfaces: Annotated[
+ list[InterfaceClass], Field(alias="ExternalInterface")
+ ] = Field(default_factory=list)
+ internal_elements: Annotated[
+ list[InternalElement], Field(alias="InternalElement")
+ ] = Field(default_factory=list)
+ supported_role_classes: Annotated[
+ list[SupportedRoleClass], Field(alias="SupportedRoleClass")
+ ] = Field(default_factory=list)
+ internal_links: Annotated[
+ list[InternalLink], Field(alias="InternalLink")
+ ] = Field(default_factory=list)
class RoleRequirements(CAEXBasicObject):
"""Role requirements attached to an internal element."""
- attributes: list[Attribute] = Field(default_factory=list, alias="Attribute")
- external_interfaces: list[InterfaceClass] = Field(
- default_factory=list,
- alias="ExternalInterface",
- )
- mapping_object: MappingObject | None = Field(default=None, alias="MappingObject")
- ref_base_role_class_path: str = Field(alias="RefBaseRoleClassPath")
+ attributes: Annotated[
+ list[Attribute], Field(alias="Attribute")
+ ] = Field(default_factory=list)
+ external_interfaces: Annotated[
+ list[InterfaceClass], Field(alias="ExternalInterface")
+ ] = Field(default_factory=list)
+ mapping_object: Annotated[MappingObject | None, Field(alias="MappingObject")] = None
+ ref_base_role_class_path: Annotated[str, Field(alias="RefBaseRoleClassPath")]
class InternalElement(SystemUnitClass):
"""Hierarchical object instance in an InstanceHierarchy or SystemUnitClass."""
- role_requirements: list[RoleRequirements] = Field(
- default_factory=list,
- alias="RoleRequirements",
- )
- ref_base_system_unit_path: str | None = Field(
- default=None,
- alias="RefBaseSystemUnitPath",
- )
+ role_requirements: Annotated[
+ list[RoleRequirements], Field(alias="RoleRequirements")
+ ] = Field(default_factory=list)
+ ref_base_system_unit_path: Annotated[
+ str | None, Field(alias="RefBaseSystemUnitPath")
+ ] = None
class SystemUnitFamily(SystemUnitClass):
"""Hierarchical SystemUnitClass tree entry."""
- system_unit_classes: list[SystemUnitFamily] = Field(
- default_factory=list,
- alias="SystemUnitClass",
- )
- ref_base_class_path: str | None = Field(default=None, alias="RefBaseClassPath")
+ system_unit_classes: Annotated[
+ list[SystemUnitFamily], Field(alias="SystemUnitClass")
+ ] = Field(default_factory=list)
+ ref_base_class_path: Annotated[str | None, Field(alias="RefBaseClassPath")] = None
class InstanceHierarchy(CAEXObject):
"""Root element for a system hierarchy of object instances."""
- internal_elements: list[InternalElement] = Field(
- default_factory=list,
- alias="InternalElement",
- )
+ internal_elements: Annotated[
+ list[InternalElement], Field(alias="InternalElement")
+ ] = Field(default_factory=list)
class InterfaceClassLib(CAEXObject):
"""Container for interface class definitions."""
- interface_classes: list[InterfaceFamily] = Field(
- default_factory=list,
- alias="InterfaceClass",
- )
+ interface_classes: Annotated[
+ list[InterfaceFamily], Field(alias="InterfaceClass")
+ ] = Field(default_factory=list)
class RoleClassLib(CAEXObject):
"""Container for role class definitions."""
- role_classes: list[RoleFamily] = Field(default_factory=list, alias="RoleClass")
+ role_classes: Annotated[
+ list[RoleFamily], Field(alias="RoleClass")
+ ] = Field(default_factory=list)
class SystemUnitClassLib(CAEXObject):
"""Container for system unit class definitions."""
- system_unit_classes: list[SystemUnitFamily] = Field(
- default_factory=list,
- alias="SystemUnitClass",
- )
+ system_unit_classes: Annotated[
+ list[SystemUnitFamily], Field(alias="SystemUnitClass")
+ ] = Field(default_factory=list)
class AttributeTypeLib(CAEXObject):
"""Container for attribute type definitions."""
- attribute_types: list[AttributeType] = Field(
- default_factory=list,
- alias="AttributeType",
- )
+ attribute_types: Annotated[
+ list[AttributeType], Field(alias="AttributeType")
+ ] = Field(default_factory=list)
class CAEXFile(CAEXBasicObject):
"""Root AutomationML CAEX 3.0 document."""
- superior_standard_versions: list[str] = Field(
- default_factory=list,
- alias="SuperiorStandardVersion",
- )
- source_document_information: list[SourceDocumentInformation] = Field(
- default_factory=list,
- alias="SourceDocumentInformation",
- )
- external_references: list[ExternalReference] = Field(
- default_factory=list,
- alias="ExternalReference",
- )
- instance_hierarchies: list[InstanceHierarchy] = Field(
- default_factory=list,
- alias="InstanceHierarchy",
- )
- interface_class_libs: list[InterfaceClassLib] = Field(
- default_factory=list,
- alias="InterfaceClassLib",
- )
- role_class_libs: list[RoleClassLib] = Field(
- default_factory=list,
- alias="RoleClassLib",
- )
- system_unit_class_libs: list[SystemUnitClassLib] = Field(
- default_factory=list,
- alias="SystemUnitClassLib",
- )
- attribute_type_libs: list[AttributeTypeLib] = Field(
- default_factory=list,
- alias="AttributeTypeLib",
- )
- schema_version: str = Field(
- default="3.0",
- alias="SchemaVersion",
- description="CAEX schema version. CAEX 3.0 documents use fixed value 3.0.",
- )
- file_name: str = Field(alias="FileName")
+ superior_standard_versions: Annotated[
+ list[str], Field(alias="SuperiorStandardVersion")
+ ] = Field(default_factory=list)
+ source_document_information: Annotated[
+ list[SourceDocumentInformation], Field(alias="SourceDocumentInformation")
+ ] = Field(default_factory=list)
+ external_references: Annotated[
+ list[ExternalReference], Field(alias="ExternalReference")
+ ] = Field(default_factory=list)
+ instance_hierarchies: Annotated[
+ list[InstanceHierarchy], Field(alias="InstanceHierarchy")
+ ] = Field(default_factory=list)
+ interface_class_libs: Annotated[
+ list[InterfaceClassLib], Field(alias="InterfaceClassLib")
+ ] = Field(default_factory=list)
+ role_class_libs: Annotated[
+ list[RoleClassLib], Field(alias="RoleClassLib")
+ ] = Field(default_factory=list)
+ system_unit_class_libs: Annotated[
+ list[SystemUnitClassLib], Field(alias="SystemUnitClassLib")
+ ] = Field(default_factory=list)
+ attribute_type_libs: Annotated[
+ list[AttributeTypeLib], Field(alias="AttributeTypeLib")
+ ] = Field(default_factory=list)
+ schema_version: Annotated[
+ str,
+ Field(
+ alias="SchemaVersion",
+ description="CAEX schema version. CAEX 3.0 documents use fixed value 3.0.",
+ ),
+ ] = "3.0"
+ file_name: Annotated[str, Field(alias="FileName")]
def reference_index(self) -> ReferenceIndex:
"""Return an index for resolving in-document AML paths and IDs."""
diff --git a/src/automationml/opcua.py b/src/automationml/opcua.py
index 9830278..10eda09 100644
--- a/src/automationml/opcua.py
+++ b/src/automationml/opcua.py
@@ -18,9 +18,15 @@
from pathlib import Path
from typing import Literal
-from lxml import etree
from pydantic import BaseModel, ConfigDict, JsonValue, computed_field
+from .exceptions import MissingDependencyError
+
+try:
+ from lxml import etree
+except ModuleNotFoundError as error:
+ raise MissingDependencyError("OPC UA conversion", "lxml", "opcua") from error
+
from .models import CAEXFile
from .opcua_mapping import (
DEFAULT_MAPPING_PROFILE,
diff --git a/src/automationml/opcua_nodeset.py b/src/automationml/opcua_nodeset.py
index 2be8e74..9eee06c 100644
--- a/src/automationml/opcua_nodeset.py
+++ b/src/automationml/opcua_nodeset.py
@@ -14,7 +14,6 @@
from typing import TYPE_CHECKING, Literal, Self
from urllib.parse import urlparse
-from lxml import etree
from pydantic import (
BaseModel,
ConfigDict,
@@ -26,6 +25,13 @@
model_validator,
)
+from .exceptions import MissingDependencyError
+
+try:
+ from lxml import etree
+except ModuleNotFoundError as error:
+ raise MissingDependencyError("OPC UA conversion", "lxml", "opcua") from error
+
if TYPE_CHECKING:
from .models import CAEXFile
from .opcua import UANodeSetRoundTripResult
diff --git a/src/automationml/rdf.py b/src/automationml/rdf.py
index 79fa3f7..78b75f8 100644
--- a/src/automationml/rdf.py
+++ b/src/automationml/rdf.py
@@ -32,10 +32,16 @@
from urllib.parse import quote
from pydantic import BaseModel, ConfigDict, JsonValue, computed_field
-from rdflib import OWL, RDF, RDFS, XSD, Graph
-from rdflib import Literal as RDFLiteral
-from rdflib import Namespace, URIRef
-from rdflib.collection import Collection
+
+from .exceptions import MissingDependencyError
+
+try:
+ from rdflib import OWL, RDF, RDFS, XSD, Graph
+ from rdflib import Literal as RDFLiteral
+ from rdflib import Namespace, URIRef
+ from rdflib.collection import Collection
+except ModuleNotFoundError as error:
+ raise MissingDependencyError("RDF/Turtle conversion", "rdflib", "rdf") from error
from .builders import (
attribute as build_attribute,
diff --git a/src/automationml/rdf_mapping.py b/src/automationml/rdf_mapping.py
index 09717c5..9d3933b 100644
--- a/src/automationml/rdf_mapping.py
+++ b/src/automationml/rdf_mapping.py
@@ -22,8 +22,14 @@
from uuid import NAMESPACE_URL, uuid5
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
-from rdflib import XSD
-from rdflib.term import URIRef
+
+from .exceptions import MissingDependencyError
+
+try:
+ from rdflib import XSD
+ from rdflib.term import URIRef
+except ModuleNotFoundError as error:
+ raise MissingDependencyError("RDF/Turtle conversion", "rdflib", "rdf") from error
class RDFMappingUnsupported(ValueError):
diff --git a/tests/test_builders.py b/tests/test_builders.py
new file mode 100644
index 0000000..8af0728
--- /dev/null
+++ b/tests/test_builders.py
@@ -0,0 +1,116 @@
+from automationml import (
+ RoleFamily,
+ SystemUnitFamily,
+ attribute,
+ attribute_type,
+ attribute_type_lib,
+ caex_file,
+ external_interface,
+ instance_hierarchy,
+ interface_class,
+ interface_class_lib,
+ role_class,
+ role_class_lib,
+ role_requirement,
+ system_unit_class,
+ system_unit_class_lib,
+)
+
+
+def _plant():
+ document = caex_file("builders.aml")
+ document.attribute_type_libs.append(
+ attribute_type_lib(
+ "Types",
+ attribute_types=[
+ attribute_type("Speed", data_type="xs:double", unit="rpm")
+ ],
+ )
+ )
+ document.interface_class_libs.append(
+ interface_class_lib(
+ "Interfaces",
+ classes=[interface_class("Port", classes=[interface_class("PowerPort")])],
+ )
+ )
+ document.role_class_libs.append(
+ role_class_lib("Roles", classes=[role_class("Drive")])
+ )
+ document.system_unit_class_libs.append(
+ system_unit_class_lib(
+ "Components",
+ classes=[
+ system_unit_class(
+ "Motor",
+ attributes=[attribute("speed", ref_attribute_type="Types/Speed")],
+ role_paths=["Roles/Drive"],
+ external_interfaces=[
+ external_interface(
+ "Power", ref_base_class_path="Interfaces/Port/PowerPort"
+ )
+ ],
+ classes=[
+ system_unit_class(
+ "ServoMotor",
+ ref_base_class_path="Components/Motor",
+ attributes={"torque": 4},
+ )
+ ],
+ )
+ ],
+ )
+ )
+ motor = document.instantiate_system_unit_class("Components/Motor", name="Motor-1")
+ motor.role_requirements.append(role_requirement("Roles/Drive"))
+ document.instance_hierarchies.append(
+ instance_hierarchy("Plant", internal_elements=[motor])
+ )
+ return document
+
+
+def test_class_library_builders_produce_resolvable_libraries():
+ document = _plant()
+
+ assert document.caex_validation_issues() == []
+ motor_class = document.system_unit_class_libs[0].system_unit_classes[0]
+ assert isinstance(motor_class, SystemUnitFamily)
+ assert isinstance(document.role_class_libs[0].role_classes[0], RoleFamily)
+ assert document.query().find_by_path(
+ "Components/Motor/ServoMotor", kind="SystemUnitClass"
+ ) is not None
+
+
+def test_nested_class_inherits_from_its_base_when_instantiated():
+ document = _plant()
+
+ servo = document.instantiate_system_unit_class(
+ "Components/Motor/ServoMotor", name="Servo-1"
+ )
+
+ assert [item.name for item in servo.attributes] == ["speed", "torque"]
+ assert [item.name for item in servo.external_interfaces] == ["Power"]
+
+
+def test_class_library_builders_emit_canonical_aml_json():
+ library = _plant().to_aml_dict()["SystemUnitClassLib"][0]
+
+ assert library == {
+ "Name": "Components",
+ "SystemUnitClass": [
+ {
+ "Name": "Motor",
+ "Attribute": [{"Name": "speed", "RefAttributeType": "Types/Speed"}],
+ "ExternalInterface": [
+ {"Name": "Power", "RefBaseClassPath": "Interfaces/Port/PowerPort"}
+ ],
+ "SupportedRoleClass": [{"RefRoleClassPath": "Roles/Drive"}],
+ "SystemUnitClass": [
+ {
+ "Name": "ServoMotor",
+ "Attribute": [{"Name": "torque", "Value": "4"}],
+ "RefBaseClassPath": "Components/Motor",
+ }
+ ],
+ }
+ ],
+ }
diff --git a/tests/test_errors.py b/tests/test_errors.py
new file mode 100644
index 0000000..700a6ad
--- /dev/null
+++ b/tests/test_errors.py
@@ -0,0 +1,76 @@
+import sys
+
+import pytest
+
+import automationml
+from automationml import (
+ AMLParseError,
+ AutomationMLError,
+ CAEXFile,
+ LegacyConversionError,
+ MissingDependencyError,
+ caex_file,
+)
+
+
+def test_source_document_records_the_sdk_version():
+ document = caex_file("plant.aml")
+
+ assert document.source_document_information[0].origin_version == (
+ automationml.__version__
+ )
+
+
+@pytest.mark.parametrize(
+ "data",
+ [
+ b"",
+ b'',
+ ],
+ ids=["malformed", "wrong-root", "unsupported-version"],
+)
+def test_unreadable_aml_xml_raises_parse_error(data):
+ with pytest.raises(AMLParseError) as excinfo:
+ CAEXFile.from_aml_xml(data)
+
+ # Earlier versions raised LegacyConversionError; existing handlers still work.
+ assert isinstance(excinfo.value, LegacyConversionError)
+ assert isinstance(excinfo.value, AutomationMLError)
+
+
+@pytest.mark.parametrize(
+ ("dependency", "extra", "modules", "convert"),
+ [
+ (
+ "lxml",
+ "opcua",
+ (
+ "automationml.opcua",
+ "automationml.opcua_nodeset",
+ "automationml.opcua_python",
+ ),
+ lambda document: document.to_opcua_nodeset_xml(),
+ ),
+ (
+ "rdflib",
+ "rdf",
+ ("automationml.rdf", "automationml.rdf_mapping"),
+ lambda document: document.to_ttl(),
+ ),
+ ],
+ ids=["opcua", "rdf"],
+)
+def test_missing_optional_dependency_names_the_extra(
+ monkeypatch, dependency, extra, modules, convert
+):
+ monkeypatch.setitem(sys.modules, dependency, None)
+ for module in modules:
+ monkeypatch.delitem(sys.modules, module, raising=False)
+
+ expected = rf"automationml\[{extra}\]"
+ with pytest.raises(MissingDependencyError, match=expected) as excinfo:
+ convert(caex_file("plant.aml"))
+
+ assert isinstance(excinfo.value, ModuleNotFoundError)
+ assert excinfo.value.name == dependency
diff --git a/tests/test_json_roundtrip.py b/tests/test_json_roundtrip.py
index 98acd61..70c50ce 100644
--- a/tests/test_json_roundtrip.py
+++ b/tests/test_json_roundtrip.py
@@ -1,7 +1,15 @@
import json
+import re
from pathlib import Path
-from automationml import CAEXFile, attribute, caex_file, instance_hierarchy, internal_element
+from automationml import (
+ CAEXFile,
+ InternalElement,
+ attribute,
+ caex_file,
+ instance_hierarchy,
+ internal_element,
+)
FIXTURE = Path(__file__).parent / "fixtures" / "minimal_caex.json"
@@ -36,3 +44,16 @@ def test_builder_api_keeps_python_clean_and_json_canonical():
"Value"
] == "1500"
assert "ChangeMode" not in json.dumps(payload)
+
+
+def test_model_aliases_stay_out_of_view_of_type_checkers():
+ # Type checkers name a model's __init__ parameters after the alias of the
+ # Field assigned to each attribute, but ignore Field metadata inside
+ # Annotated. Keeping the CAEX aliases in Annotated lets editors accept the
+ # documented snake_case names, such as InternalElement(name=...).
+ source = (Path(__file__).parents[1] / "src/automationml/models.py").read_text(
+ encoding="utf-8"
+ )
+
+ assert not re.search(r"=\s*Field\([^)]*\balias=", source)
+ assert InternalElement(name="Motor").to_aml_dict()["Name"] == "Motor"