From e6127198a473e2296bdbf59b7b7df0319caae588 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:05:02 +0000 Subject: [PATCH 1/3] Let editors accept snake_case model constructor arguments The documented Python API builds models with snake_case names, such as InternalElement(name="Motor"). Pydantic accepts that at runtime through populate_by_name, but type checkers name a model's __init__ parameters after the alias of the assigned Field. pyright, and therefore Pylance in VS Code, rejected every snake_case argument and demanded the PascalCase alias instead: a 13-line sample using the documented API produced 37 false errors. Move the 105 CAEX aliases in models.py into Annotated metadata, which type checkers ignore, and keep the defaults on the right-hand side: name: Annotated[str, Field(alias="Name")] unit: Annotated[str | None, Field(alias="Unit")] = None The rewrite was mechanical. Verified that the runtime model is unchanged: every field's alias, validation and serialization alias, annotation, required flag, default, default factory, and description is identical to main for all models, the generated JSON Schema is byte-identical, and all tests pass. On the sample, pyright now reports only the two deliberate mistakes (a missing required name and a misspelled keyword), as mypy already did. Errors on the SDK's own source drop from 163 to 58 under pyright and from 167 to 86 under mypy, with mypy call-arg errors going from 84 to 0. A test keeps aliases out of assigned Fields, and the design notes explain the convention for future fields. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VzX3UpZysYhwy1ZMJNcVvq --- CHANGELOG.md | 7 + README.md | 4 +- docs/sdk-design.md | 16 ++ src/automationml/models.py | 463 +++++++++++++++++------------------ tests/test_json_roundtrip.py | 23 +- 5 files changed, 271 insertions(+), 242 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4fd77a7..e25ddcd 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, diff --git a/README.md b/README.md index e7406ea..5c41cef 100644 --- a/README.md +++ b/README.md @@ -294,7 +294,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/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/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" From a387c4a58bab52bf7a74f68666c4f0d3d202374d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:07:54 +0000 Subject: [PATCH 2/3] Add builders for the four kinds of class library The builders covered only the instance side. Authoring a library meant constructing models directly, and the obvious attempt fails: SystemUnitClassLib(system_unit_classes=[SystemUnitClass(...)]) raises a Pydantic type error because libraries hold the hierarchical SystemUnitFamily, RoleFamily, and InterfaceFamily models. The README tutorial had to explain that distinction to beginners. Add system_unit_class, role_class, interface_class, and attribute_type, plus a *_lib builder for each, following the existing conventions: attributes accept a mapping or a list of attribute() results (now one shared helper, also used by internal_element), role_paths become SupportedRoleClass entries just as internal_element turns them into RoleRequirements, children holds nested InternalElements, and classes nests further classes in the library tree. Tests build a document from builders alone and check that every kind of reference resolves with no validation issues, that a nested class inherits from its base when instantiated, and the exact AML JSON emitted. The README tutorial now uses the builders, and every snippet still runs on the base install. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VzX3UpZysYhwy1ZMJNcVvq --- CHANGELOG.md | 6 ++ README.md | 19 ++-- src/automationml/__init__.py | 16 ++++ src/automationml/builders.py | 178 +++++++++++++++++++++++++++++++++-- tests/test_builders.py | 116 +++++++++++++++++++++++ 5 files changed, 316 insertions(+), 19 deletions(-) create mode 100644 tests/test_builders.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e25ddcd..be81cc2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,12 @@ and this project follows [Semantic Versioning](https://semver.org/). ### Added +- 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 5c41cef..a19354e 100644 --- a/README.md +++ b/README.md @@ -216,19 +216,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 +236,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 diff --git a/src/automationml/__init__.py b/src/automationml/__init__.py index c6ad95b..d46b350 100644 --- a/src/automationml/__init__.py +++ b/src/automationml/__init__.py @@ -2,12 +2,20 @@ 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 ( @@ -135,18 +143,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/builders.py b/src/automationml/builders.py index 0480a3e..6f94a3e 100644 --- a/src/automationml/builders.py +++ b/src/automationml/builders.py @@ -7,14 +7,25 @@ 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( *, @@ -90,31 +101,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 +134,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/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", + } + ], + } + ], + } From b039d2e7bc327e5ff1a0536ca169a0ea6854c5c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 17 Oct 2026 22:11:37 +0000 Subject: [PATCH 3/3] Name the extra to install, add AMLParseError, and expose __version__ Three rough edges a newcomer hits early: - Using OPC UA or RDF conversion on a base install failed with a bare "No module named 'lxml'" (or 'rdflib'), with no hint that an extra exists. The modules that need those packages now raise MissingDependencyError, a ModuleNotFoundError subclass, whose message names the command, for example: python -m pip install "automationml[opcua]" - A malformed or non-CAEX XML file raised LegacyConversionError, even for CAEX 3.0 input that has nothing to do with legacy conversion. The three "not a readable CAEX document" cases (unparsable XML, wrong root, unsupported schema version) now raise AMLParseError. It subclasses LegacyConversionError so existing handlers keep working. CAEX 2.15 schema and upgrade failures are unchanged. - The package had no __version__, and source_document() recorded a hard-coded OriginVersion of "0.1.0" while the package is 0.1.0a1. The version now lives once in automationml/_version.py; hatchling reads it through [tool.hatch.version], the package exports it, and source_document() uses it by default. The base AutomationMLError and CAEXValidationError are now importable from the top-level package as well. Verified on a real base install without lxml or rdflib that both conversions raise the new message. The built wheel's metadata reports 0.1.0a1 and twine check passes. The TestPyPI rename step still matches pyproject.toml. All tests pass, every README and OPC UA guide snippet runs, the learning notebooks execute, and the OPC UA release gate stays at 121/121. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01VzX3UpZysYhwy1ZMJNcVvq --- CHANGELOG.md | 13 ++++++ README.md | 5 +- pyproject.toml | 5 +- src/automationml/__init__.py | 10 ++++ src/automationml/_version.py | 7 +++ src/automationml/builders.py | 3 +- src/automationml/exceptions.py | 19 ++++++++ src/automationml/legacy.py | 8 ++-- src/automationml/opcua.py | 8 +++- src/automationml/opcua_nodeset.py | 8 +++- src/automationml/rdf.py | 14 ++++-- src/automationml/rdf_mapping.py | 10 +++- tests/test_errors.py | 76 +++++++++++++++++++++++++++++++ 13 files changed, 171 insertions(+), 15 deletions(-) create mode 100644 src/automationml/_version.py create mode 100644 tests/test_errors.py diff --git a/CHANGELOG.md b/CHANGELOG.md index be81cc2..20e8725 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,19 @@ 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 diff --git a/README.md b/README.md index a19354e..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" } ], 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 d46b350..3d9a472 100644 --- a/src/automationml/__init__.py +++ b/src/automationml/__init__.py @@ -1,5 +1,6 @@ """AutomationML CAEX 3.0 JSON-first SDK.""" +from ._version import __version__ from .builders import ( attribute, attribute_type, @@ -59,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 @@ -86,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", @@ -120,6 +129,7 @@ "MergePolicy", "MergeResult", "MigrationIssue", + "MissingDependencyError", "NominalScaledType", "OrdinalScaledType", "ReferenceIndex", 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 6f94a3e..3cef8dc 100644 --- a/src/automationml/builders.py +++ b/src/automationml/builders.py @@ -5,6 +5,7 @@ from datetime import datetime, timezone from typing import Iterable, Mapping +from ._version import __version__ from .models import ( Attribute, AttributeType, @@ -31,7 +32,7 @@ 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.""" 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/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_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