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"