Repository: cognitedata/neat
Pull request #1044: [NEAT-827] 🚄 Template extension method
Author: login: doctrino
Created: 2025-03-12T03:36:52Z | Merged: 2025-03-31T10:35:11Z

# Description

## To Risk Reviewer
**RISK REVIEWER**; Note  that this method is marked as experimental, that flag will not be removed without further checks. Furthermore, note that 519 lines of this PR is YAML used in testing. Final remark, this is made to have a clean git history for easy review.

This method is part of the extending CogniteCore process. 
### Input
![image](https://github.com/user-attachments/assets/3e8e5487-91e2-40b3-8baf-fbf3973f497a)

### Action
![image](https://github.com/user-attachments/assets/08bad5f4-d875-4902-bf04-1122b8032250)
### Output
![image](https://github.com/user-attachments/assets/9ddb3a1e-cce8-4d31-a225-fcb0886083f3)

## Bump

- [ ] Patch
- [x] Minor
- [ ] Skip

## Changelog
### Added

- The method `neat.template.extension("my_spreadsheet.xlsx")` this adds all properties from Cognite concepts that have been referenced through the implements column. 



Diff:
diff --git a/cognite/neat/_alpha.py b/cognite/neat/_alpha.py
index 85b95eae2d..836805cb0b 100644
--- a/cognite/neat/_alpha.py
+++ b/cognite/neat/_alpha.py
@@ -22,3 +22,4 @@ class ExperimentalFlags:
     aml_read = ExperimentalFeatureWarning("aml_read")
     csv_read = ExperimentalFeatureWarning("csv_read")
     to_ontology = ExperimentalFeatureWarning("to_ontology")
+    extension = ExperimentalFeatureWarning("extension")
diff --git a/cognite/neat/_rules/importers/_dms2rules.py b/cognite/neat/_rules/importers/_dms2rules.py
index 20f8bddf23..4a9251213a 100644
--- a/cognite/neat/_rules/importers/_dms2rules.py
+++ b/cognite/neat/_rules/importers/_dms2rules.py
@@ -1,8 +1,9 @@
+import warnings
 from collections import defaultdict
 from collections.abc import Collection, Iterable, Sequence
 from datetime import datetime
 from pathlib import Path
-from typing import Literal, cast
+from typing import Literal
 
 from cognite.client import data_modeling as dm
 from cognite.client.data_classes.data_modeling import DataModelId, DataModelIdentifier
@@ -16,8 +17,13 @@
 from cognite.client.data_classes.data_modeling.views import (
     MultiEdgeConnectionApply,
     MultiReverseDirectRelationApply,
+    ReverseDirectRelation,
+    SingleEdgeConnection,
     SingleEdgeConnectionApply,
+    SingleReverseDirectRelation,
     SingleReverseDirectRelationApply,
+    View,
+    ViewProperty,
     ViewPropertyApply,
 )
 from cognite.client.utils import ms_to_datetime
@@ -28,11 +34,13 @@
 from cognite.neat._issues.errors import (
     FileTypeUnexpectedError,
     NeatValueError,
+    PropertyTypeNotSupportedError,
     ResourceMissingIdentifierError,
     ResourceRetrievalError,
 )
 from cognite.neat._issues.warnings import (
     MissingCogniteClientWarning,
+    NeatValueWarning,
     PropertyNotFoundWarning,
     PropertyTypeNotSupportedWarning,
     ResourceNotFoundWarning,
@@ -45,7 +53,7 @@
     DMSInputRules,
     DMSSchema,
 )
-from cognite.neat._rules.models.data_types import DataType, Enum
+from cognite.neat._rules.models.data_types import DataType, Enum, String
 from cognite.neat._rules.models.dms import (
     DMSInputContainer,
     DMSInputEnum,
@@ -63,6 +71,10 @@
     ReverseConnectionEntity,
     ViewEntity,
 )
+from cognite.neat._rules.models.information import (
+    InformationInputClass,
+    InformationInputProperty,
+)
 
 
 class DMSImporter(BaseImporter[DMSInputRules]):
@@ -332,19 +344,27 @@ def _create_dms_property(
             )
             return None
 
-        value_type = self._get_value_type(prop, view_entity, prop_id, enum_collection_by_container_property)
+        container_property = (
+            self._get_container_property_definition(prop) if isinstance(prop, dm.MappedPropertyApply) else None
+        )
+        value_type = self._get_value_type(prop, container_property, enum_collection_by_container_property)
         if value_type is None:
+            self.issue_list.append(
+                PropertyTypeNotSupportedWarning(view_entity.as_id(), "view", prop_id, type(prop).__name__)
+            )
             return None
+        if isinstance(value_type, ViewEntity) and value_type.as_id() not in self._all_views_by_id:
+            self.issue_list.append(ResourceUnknownWarning(prop.source, "view", view_entity.as_id(), "view"))
 
         return DMSInputProperty(
             description=prop.description,
             name=prop.name,
             connection=self._get_connection_type(prop),
             value_type=str(value_type),
-            min_count=self._get_min_count(prop),
-            max_count=self._get_max_count(prop),
+            min_count=self._get_min_count(prop, container_property),
+            max_count=self._get_max_count(prop, container_property),
             immutable=self._get_immutable(prop),
-            default=self._get_default(prop),
+            default=self._get_default(prop, container_property),
             container=(
                 str(ContainerEntity.from_id(prop.container)) if isinstance(prop, dm.MappedPropertyApply) else None
             ),
@@ -357,7 +377,7 @@ def _create_dms_property(
             constraint=self._get_constraint(prop, prop_id),
         )
 
-    def _container_prop_unsafe(self, prop: dm.MappedPropertyApply) -> dm.ContainerProperty:
+    def _get_container_property_definition(self, prop: dm.MappedPropertyApply) -> dm.ContainerProperty:
         """This method assumes you have already checked that the container with property exists."""
         return self._all_containers_by_id[prop.container].properties[prop.container_property_identifier]
 
@@ -372,40 +392,48 @@ def _get_connection_type(
         elif isinstance(prop, SingleReverseDirectRelationApply | MultiReverseDirectRelationApply):
             return ReverseConnectionEntity(property=prop.through.property)
         elif isinstance(prop, dm.MappedPropertyApply) and isinstance(
-            self._container_prop_unsafe(prop).type, dm.DirectRelation
+            self._get_container_property_definition(prop).type, dm.DirectRelation
         ):
             return "direct"
         else:
             return None
 
+    @classmethod
     def _get_value_type(
-        self,
-        prop: ViewPropertyApply,
-        view_entity: ViewEntity,
-        prop_id: str,
-        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str],
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        container_property: dm.ContainerProperty | None = None,
+        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str] | None = None,
     ) -> DataType | ViewEntity | DMSUnknownEntity | None:
         if isinstance(
             prop,
             SingleEdgeConnectionApply
             | MultiEdgeConnectionApply
             | SingleReverseDirectRelationApply
-            | MultiReverseDirectRelationApply,
+            | MultiReverseDirectRelationApply
+            | SingleEdgeConnection
+            | dm.MultiEdgeConnection
+            | SingleReverseDirectRelation
+            | dm.MultiReverseDirectRelation,
         ):
             return ViewEntity.from_id(prop.source)
-        elif isinstance(prop, dm.MappedPropertyApply):
-            container_prop = self._container_prop_unsafe(cast(dm.MappedPropertyApply, prop))
-            if isinstance(container_prop.type, dm.DirectRelation):
+        elif isinstance(prop, dm.MappedPropertyApply | dm.MappedProperty):
+            if isinstance(prop, dm.MappedPropertyApply):
+                if container_property is None:
+                    raise ValueError("container property must be provided when prop is a MappedProperty")
+                prop_type = container_property.type
+            else:
+                prop_type = prop.type
+            if isinstance(prop_type, dm.DirectRelation):
                 if prop.source is None:
                     return DMSUnknownEntity()
-                elif prop.source not in self._all_views_by_id:
-                    self.issue_list.append(ResourceUnknownWarning(prop.source, "view", view_entity.as_id(), "view"))
-                    return ViewEntity.from_id(prop.source)
                 else:
                     return ViewEntity.from_id(prop.source)
-            elif isinstance(container_prop.type, PropertyTypeWithUnit) and container_prop.type.unit:
-                return DataType.load(f"{container_prop.type._type}(unit={container_prop.type.unit.external_id})")
-            elif isinstance(container_prop.type, DMSEnum):
+            elif isinstance(prop_type, PropertyTypeWithUnit) and prop_type.unit:
+                return DataType.load(f"{prop_type._type}(unit={prop_type.unit.external_id})")
+            elif isinstance(prop_type, DMSEnum):
+                if enum_collection_by_container_property is None:
+                    return String()
                 collection = enum_collection_by_container_property.get(
                     (prop.container, prop.container_property_identifier)
                 )
@@ -415,30 +443,46 @@ def _get_value_type(
                         f"BUG in Neat: Enum for {prop.container}.{prop.container_property_identifier} not found."
                     )
 
-                return Enum(collection=ClassEntity(suffix=collection), unknownValue=container_prop.type.unknown_value)
+                return Enum(collection=ClassEntity(suffix=collection), unknownValue=prop_type.unknown_value)
             else:
-                return DataType.load(container_prop.type._type)
+                return DataType.load(prop_type._type)
         else:
-            self.issue_list.append(
-                PropertyTypeNotSupportedWarning[dm.ViewId](view_entity.as_id(), "view", prop_id, type(prop).__name__)
-            )
             return None
 
-    def _get_min_count(self, prop: ViewPropertyApply) -> int | None:
+    @classmethod
+    def _get_min_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        container_property: dm.ContainerProperty | None = None,
+    ) -> int | None:
         if isinstance(prop, dm.MappedPropertyApply):
-            return int(not self._container_prop_unsafe(prop).nullable)
+            if container_property is None:
+                raise ValueError("container_property must be provided when prop is a MappedPropertyApply")
+            return int(not container_property.nullable)
+        elif isinstance(prop, dm.MappedProperty):
+            return int(not prop.nullable)
         else:
             return None
 
     def _get_immutable(self, prop: ViewPropertyApply) -> bool | None:
         if isinstance(prop, dm.MappedPropertyApply):
-            return self._container_prop_unsafe(prop).immutable
+            return self._get_container_property_definition(prop).immutable
         else:
             return None
 
-    def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            prop_type = self._container_prop_unsafe(prop).type
+    @classmethod
+    def _get_max_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        container_property: dm.ContainerProperty | None = None,
+    ) -> int | float | None:
+        if isinstance(prop, dm.MappedPropertyApply | dm.MappedProperty):
+            if isinstance(prop, dm.MappedPropertyApply):
+                if container_property is None:
+                    raise ValueError("get_container must be provided when prop is a MappedPropertyApply")
+                prop_type = container_property.type
+            else:
+                prop_type = prop.type
             if isinstance(prop_type, ListablePropertyType):
                 if prop_type.is_list is False:
                     return 1
@@ -450,17 +494,41 @@ def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
                     return DMS_PRIMITIVE_LIST_DEFAULT_LIMIT
             else:
                 return 1
-        elif isinstance(prop, MultiEdgeConnectionApply | MultiReverseDirectRelationApply):
+        elif isinstance(
+            prop,
+            MultiEdgeConnectionApply
+            | MultiReverseDirectRelationApply
+            | dm.MultiEdgeConnection
+            | dm.MultiReverseDirectRelation,
+        ):
             return float("inf")
-        elif isinstance(prop, SingleEdgeConnectionApply | SingleReverseDirectRelationApply):
+        elif isinstance(
+            prop,
+            SingleEdgeConnectionApply
+            | SingleReverseDirectRelationApply
+            | SingleEdgeConnection
+            | SingleReverseDirectRelation,
+        ):
             return 1
         else:
-            # Unknown type.
+            warnings.warn(
+                NeatValueWarning(f"Unknown property type {type(prop)}. Assuming max count is inf"), stacklevel=2
+            )
             return None
 
-    def _get_default(self, prop: ViewPropertyApply) -> str | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            default = self._container_prop_unsafe(prop).default_value
+    @classmethod
+    def _get_default(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        container_property: dm.ContainerProperty | None = None,
+    ) -> str | None:
+        if isinstance(prop, dm.MappedPropertyApply | dm.MappedProperty):
+            if isinstance(prop, dm.MappedPropertyApply):
+                if container_property is None:
+                    raise ValueError("container_property must be provided when prop is a MappedPropertyApply")
+                default = container_property.default_value
+            else:
+                default = prop.default_value
             if default is not None:
                 return str(default)
         return None
@@ -559,3 +627,43 @@ def _create_enum_collections(
                         )
                     )
         return enum_by_container_property
+
+    @classmethod
+    def as_information_input_property(
+        cls, entity: ClassEntity, prop_id: str, view_property: ViewProperty
+    ) -> InformationInputProperty:
+        if not isinstance(view_property, dm.MappedProperty | dm.EdgeConnection | ReverseDirectRelation):
+            raise PropertyTypeNotSupportedError(
+                dm.ViewId(str(entity.prefix), str(entity.suffix), entity.version),
+                "view",
+                prop_id,
+                type(view_property).__name__,
+            )
+
+        value_type = cls._get_value_type(view_property)
+        if value_type is None:
+            raise NeatValueError(f"Failed to get value type for {entity} property {prop_id}")
+
+        return InformationInputProperty(
+            class_=entity,
+            property_=prop_id,
+            value_type=str(value_type),
+            name=view_property.name,
+            description=view_property.description,
+            min_count=cls._get_min_count(view_property),
+            max_count=cls._get_max_count(view_property),
+            default=cls._get_default(view_property),
+        )
+
+    @classmethod
+    def as_information_input_class(cls, view: View) -> InformationInputClass:
+        return InformationInputClass(
+            class_=ClassEntity(prefix=view.space, suffix=view.external_id, version=view.version),
+            name=view.name,
+            description=view.description,
+            implements=[
+                ClassEntity(prefix=parent.space, suffix=parent.external_id, version=parent.version)
+                for parent in view.implements or []
+            ]
+            or None,
+        )
diff --git a/cognite/neat/_rules/models/information/_rules_input.py b/cognite/neat/_rules/models/information/_rules_input.py
index 3c3a9545a9..5bab98eb32 100644
--- a/cognite/neat/_rules/models/information/_rules_input.py
+++ b/cognite/neat/_rules/models/information/_rules_input.py
@@ -1,6 +1,6 @@
 from dataclasses import dataclass, field
 from datetime import datetime
-from typing import Any
+from typing import Any, cast
 
 import pandas as pd
 from cognite.client import data_modeling as dm
@@ -103,6 +103,9 @@ def dump(self, default_prefix: str, **kwargs) -> dict[str, Any]:  # type: ignore
         output["Value Type"] = load_value_type(self.value_type, default_prefix)
         return output
 
+    def copy(self, update: dict[str, Any], default_prefix: str) -> "InformationInputProperty":
+        return cast(InformationInputProperty, type(self)._load({**self.dump(default_prefix), **update}))
+
 
 @dataclass
 class InformationInputClass(InputComponent[InformationClass]):
diff --git a/cognite/neat/_rules/transformers/__init__.py b/cognite/neat/_rules/transformers/__init__.py
index 23331fe78a..05f6528e77 100644
--- a/cognite/neat/_rules/transformers/__init__.py
+++ b/cognite/neat/_rules/transformers/__init__.py
@@ -1,6 +1,7 @@
 from ._base import RulesTransformer, VerifiedRulesTransformer
 from ._converters import (
     AddClassImplements,
+    AddCogniteProperties,
     ChangeViewPrefix,
     ClassicPrepareCore,
     ConversionTransformer,
@@ -29,6 +30,7 @@
 
 __all__ = [
     "AddClassImplements",
+    "AddCogniteProperties",
     "AsParentPropertyId",
     "ChangeViewPrefix",
     "ClassicPrepareCore",
diff --git a/cognite/neat/_rules/transformers/_converters.py b/cognite/neat/_rules/transformers/_converters.py
index 1910e241cb..f8ad32be25 100644
--- a/cognite/neat/_rules/transformers/_converters.py
+++ b/cognite/neat/_rules/transformers/_converters.py
@@ -6,10 +6,11 @@
 from collections.abc import Collection, Mapping
 from datetime import date, datetime
 from functools import cached_property
+from graphlib import CycleError, TopologicalSorter
 from typing import Any, ClassVar, Literal, TypeVar, cast, overload
 
 from cognite.client.data_classes import data_modeling as dms
-from cognite.client.data_classes.data_modeling import DataModelId, DataModelIdentifier, ViewId
+from cognite.client.data_classes.data_modeling import DataModelId, DataModelIdentifier, View, ViewId
 from cognite.client.utils.useful_types import SequenceNotStr
 from pydantic import ValidationError
 from rdflib import Namespace
@@ -25,6 +26,8 @@
     DMS_RESERVED_PROPERTIES,
     get_default_prefixes_and_namespaces,
 )
+from cognite.neat._issues import IssueList
+from cognite.neat._issues._factory import from_pydantic_errors
 from cognite.neat._issues.errors import NeatValueError
 from cognite.neat._issues.warnings import NeatValueWarning
 from cognite.neat._issues.warnings._models import (
@@ -60,8 +63,15 @@
     UnknownEntity,
     ViewEntity,
 )
-from cognite.neat._rules.models.information import InformationClass, InformationMetadata, InformationProperty
+from cognite.neat._rules.models.information import (
+    InformationClass,
+    InformationInputClass,
+    InformationInputProperty,
+    InformationMetadata,
+    InformationProperty,
+)
 from cognite.neat._utils.rdf_ import get_inheritance_path
+from cognite.neat._utils.spreadsheet import SpreadsheetRead
 from cognite.neat._utils.text import NamingStandardization, title, to_camel_case, to_words
 
 from ._base import RulesTransformer, T_VerifiedIn, T_VerifiedOut, VerifiedRulesTransformer
@@ -2017,3 +2027,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._get_views_by_class(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_input_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_input_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
+        properties: list[InformationInputProperty], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, dict[str, InformationInputProperty]]:
+        issues = IssueList()
+        properties_by_class: dict[ClassEntity, dict[str, InformationInputProperty]] = defaultdict(dict)
+        for prop in properties:
+            try:
+                dumped = prop.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            properties_by_class[class_entity][prop.property_] = prop
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading properties")
+        return properties_by_class
+
+    @staticmethod
+    def _get_dependencies_by_class(
+        classes: list[InformationInputClass], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, set[ClassEntity]]:
+        dependencies_by_class: dict[ClassEntity, set[ClassEntity]] = {}
+        issues = IssueList()
+        for raw in classes:
+            try:
+                dumped = raw.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            implements = cast(list[ClassEntity] | None, dumped["Implements"])
+            dependencies_by_class[class_entity] = set(implements or [])
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading classes")
+        return dependencies_by_class
+
+    @staticmethod
+    def _get_cognite_concepts(dependencies_by_class: dict[ClassEntity, set[ClassEntity]]) -> set[ClassEntity]:
+        cognite_implements_concepts = {
+            dependency
+            for dependencies in dependencies_by_class.values()
+            for dependency in dependencies
+            if dependency.prefix in COGNITE_SPACES
+        }
+        if not cognite_implements_concepts:
+            raise NeatValueError("None of the classes implement Cognite Core concepts.")
+        return cognite_implements_concepts
+
+    def _get_views_by_class(
+        self, classes: set[ClassEntity], default_space: str, default_version: str
+    ) -> dict[ClassEntity, View]:
+        view_ids = [class_.as_view_entity(default_space, default_version).as_id() for class_ in classes]
+        views = self._client.loaders.views.retrieve(view_ids, include_ancestor=True, include_connected=True)
+        return {ClassEntity(prefix=view.space, suffix=view.external_id, version=view.version): view for view in views}
diff --git a/cognite/neat/_session/_template.py b/cognite/neat/_session/_template.py
index 1535063270..91d7eb13f1 100644
--- a/cognite/neat/_session/_template.py
+++ b/cognite/neat/_session/_template.py
@@ -1,16 +1,24 @@
-from typing import Literal
+from pathlib import Path
+from typing import Any, Literal
 
 from cognite.client.data_classes.data_modeling import DataModelIdentifier
 
-from cognite.neat._issues import IssueList
+from cognite.neat._alpha import ExperimentalFlags
+from cognite.neat._issues import IssueList, catch_issues
+from cognite.neat._rules._shared import ReadRules
+from cognite.neat._rules.exporters import ExcelExporter
+from cognite.neat._rules.importers import ExcelImporter
+from cognite.neat._rules.models import InformationInputRules
 from cognite.neat._rules.models.dms import DMSValidation
 from cognite.neat._rules.transformers import (
+    AddCogniteProperties,
     IncludeReferenced,
     ToDataProductModel,
     ToEnterpriseModel,
     ToSolutionModel,
     VerifiedRulesTransformer,
 )
+from cognite.neat._utils.reader import NeatReader, PathReader
 
 from ._state import SessionState
 from .exceptions import NeatSessionError, session_class_wrapper
@@ -165,3 +173,52 @@ def data_product_model(
         if last_rules and not issues.has_errors:
             self._state.last_reference = last_rules
         return issues
+
+    def extension(self, io: Any, output: str | Path | None = None) -> IssueList:
+        """Creates a template for an extension of a Cognite model.
+
+        The input is a spreadsheet of a conceptual model in which the concepts are defined
+        and marked with the Cognite concept they are extending. For example, if you have a pump
+        in the Classes sheet you will see
+        ```
+        Class: Pump
+        Implements: cdf_cdm:CogniteAsset(version=v1)
+        ```
+        The output will be a spreadsheet in which all the properties from the Cognite concept model
+        is added to the spreadsheet. In the example above, the pump concept will have all
+        the properties it inherits from the CogniteAsset concept added to the Properties spreadsheet.
+
+
+        Args:
+            io: The input spreadsheet.
+            output: The output spreadsheet. If None, the output will be the same
+                as the input with `_extension` added to the name.
+        """
+        ExperimentalFlags.extension.warn()
+        reader = NeatReader.create(io)
+        path = reader.materialize_path()
+        if output is None:
+            if isinstance(reader, PathReader):
+                output_path = path.with_name(f"{path.stem}_extension{path.suffix}")
+            else:
+                # The source is not a file, for example, a URL or a stream.
+                output_path = Path.cwd() / f"{path.stem}_extension{path.suffix}"
+        else:
+            output_path = Path(output)
+
+        with catch_issues() as issues:
+            read: ReadRules[InformationInputRules] = ExcelImporter(path).to_rules()
+            if read.rules is not None:
+                # If rules are None there will be issues that are already caught.
+                if not isinstance(read.rules, InformationInputRules):
+                    raise NeatSessionError(f"The input {reader.name} must contain an InformationInputRules object. ")
+                if self._state.client is None:
+                    raise NeatSessionError("Client must be set in the session to run the extension.")
+                modified = AddCogniteProperties(self._state.client).transform(read)
+                if modified.rules is not None:
+                    # If rules are None there will be issues that are already caught.
+                    info = modified.rules.as_verified_rules()
+
+                    ExcelExporter(styling="maximal").export_to_file(info, output_path)
+        issues.action = "Created extension template"
+        return issues
diff --git a/tests/data/__init__.py b/tests/data/__init__.py
index a18bcfb6b8..6525c29014 100644
--- a/tests/data/__init__.py
+++ b/tests/data/__init__.py
@@ -44,6 +44,7 @@ class AssetCentricCDF:
 class SchemaData:
     class Conceptual:
         _conceptual = _schema_dir / "conceptual"
+        only_concepts_xlsx = _conceptual / "only_concepts.xlsx"
         info_arch_car_rules_xlsx = _conceptual / "info-arch-car-rules.xlsx"
         information_unknown_value_type_xlsx = _conceptual / "information-unknown-value-type.xlsx"
 
diff --git a/tests/data/_schema/conceptual/only_concepts.xlsx b/tests/data/_schema/conceptual/only_concepts.xlsx
new file mode 100644
index 0000000000..a9bccd7b4b
Binary files /dev/null and b/tests/data/_schema/conceptual/only_concepts.xlsx differ
diff --git a/tests/tests_integration/test_session/test_graph_flow.py b/tests/tests_integration/test_session/test_graph_flow.py
index 8397bc77c0..cf40c6892a 100644
--- a/tests/tests_integration/test_session/test_graph_flow.py
+++ b/tests/tests_integration/test_session/test_graph_flow.py
@@ -1,4 +1,5 @@
 import datetime
+from pathlib import Path
 from typing import Any
 
 import pytest
@@ -13,7 +14,7 @@
 
 from cognite.neat import NeatSession
 from cognite.neat._graph.loaders import DMSLoader
-from tests.data import GraphData
+from tests.data import GraphData, SchemaData
 
 RESERVED_PROPERTIES = frozenset(
     {
@@ -231,6 +232,21 @@ def test_aml_to_dms(self, cognite_client: CogniteClient, data_regression: DataRe
         assert len(edges) == 972
         assert len(instances) == 1945
 
+    def test_create_extension_template(
+        self, cognite_client: CogniteClient, tmp_path: Path, data_regression: DataRegressionFixture
+    ) -> None:
+        neat = NeatSession(cognite_client)
+        output_path = tmp_path / "extension_template.xlsx"
+        neat.template.extension(SchemaData.Conceptual.only_concepts_xlsx, output_path)
+        assert output_path.exists()
+        neat.read.excel(output_path)
+
+        model_str = neat.to.yaml(format="neat")
+
+        model_dict = yaml.safe_load(model_str)
+
+        data_regression.check(model_dict)
+
     @staticmethod
     def _standardize_instance(instance: InstanceApply) -> dict[str, Any]:
         if not isinstance(instance, InstanceApply):
diff --git a/tests/tests_integration/test_session/test_graph_flow/test_create_extension_template.yml b/tests/tests_integration/test_session/test_graph_flow/test_create_extension_template.yml
new file mode 100644
index 0000000000..318b31362c
--- /dev/null
+++ b/tests/tests_integration/test_session/test_graph_flow/test_create_extension_template.yml
@@ -0,0 +1,518 @@
+classes:
+- class_: cdf_cdm:Cognite360Image(version=v1)
+  implements: cdf_cdm:Cognite3DTransformation(version=v1),cdf_cdm:CogniteCubeMap(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite360Image
+- class_: cdf_cdm:Cognite360ImageAnnotation(version=v1)
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1),cdf_cdm:CogniteAnnotation(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite360ImageAnnotation
+- class_: cdf_cdm:Cognite360ImageCollection(version=v1)
+  description: Represents a logical collection of Cognite360Image instances
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:Cognite3DRevision(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite360ImageCollection
+- class_: cdf_cdm:Cognite360ImageModel(version=v1)
+  description: Navigational aid for traversing Cognite360ImageModel instances
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:Cognite3DModel(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite360ImageModel
+- class_: cdf_cdm:Cognite360ImageStation(version=v1)
+  description: A way to group images across collections. Used for creating visual
+    scan history
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite360ImageStation
+- class_: cdf_cdm:Cognite3DModel(version=v1)
+  description: Groups revisions of 3D data of various kinds together (CAD, PointCloud,
+    Image360)
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite3DModel
+- class_: cdf_cdm:Cognite3DObject(version=v1)
+  description: This is the virtual position representation of an object in the physical
+    world, connecting an asset to one or more 3D resources
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite3DObject
+- class_: cdf_cdm:Cognite3DRevision(version=v1)
+  description: Shared revision information for various 3D data types. Normally not
+    used directly, but through CognitePointCloudRevision, Image360Collection or CogniteCADRevision
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite3DRevision
+- class_: cdf_cdm:Cognite3DTransformation(version=v1)
+  description: The Cognite3DTransformation object defines a comprehensive 3D transformation,
+    enabling precise adjustments to an object's position, orientation, and size in
+    the 3D coordinate system. It allows for the translation of objects along the three
+    spatial axes, rotation around these axes using Euler angles, and scaling along
+    each axis to modify the object's dimensions. The object's transformation is defined
+    in "CDF space", a coordinate system where the positive Z axis is the up direction
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/Cognite3DTransformation
+- class_: cdf_cdm:CogniteActivity(version=v1)
+  description: Represents activities. Activities typically happen over a period and
+    have a start and end time.
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1),cdf_cdm:CogniteSchedulable(version=v1)
+  name: Activity
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteActivity
+- class_: cdf_cdm:CogniteAnnotation(version=v1)
+  description: Annotation represents contextualization results or links
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteAnnotation
+- class_: cdf_cdm:CogniteAsset(version=v1)
+  description: Assets represent systems that support industrial functions or processes.
+    Assets are often called 'functional location'.
+  implements: cdf_cdm:CogniteVisualizable(version=v1),cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1)
+  name: Asset
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteAsset
+- class_: cdf_cdm:CogniteAssetClass(version=v1)
+  description: Represents the class of an asset.
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  name: Asset class
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteAssetClass
+- class_: cdf_cdm:CogniteAssetType(version=v1)
+  description: Represents the type of an asset.
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  name: Asset type
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteAssetType
+- class_: cdf_cdm:CogniteCADModel(version=v1)
+  description: Navigational aid for traversing CogniteCADModel instances
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:Cognite3DModel(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteCADModel
+- class_: cdf_cdm:CogniteCADNode(version=v1)
+  description: Represents nodes from the 3D model that have been contextualized
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteCADNode
+- class_: cdf_cdm:CogniteCADRevision(version=v1)
+  implements: cdf_cdm:Cognite3DRevision(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteCADRevision
+- class_: cdf_cdm:CogniteCubeMap(version=v1)
+  description: The cube map holds references to 6 images in used to visually represent
+    the surrounding environment
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteCubeMap
+- class_: cdf_cdm:CogniteDescribable(version=v1)
+  description: The describable core concept is used as a standard way of holding the
+    bare minimum of information about the instance
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteDescribable
+- class_: cdf_cdm:CogniteEquipment(version=v1)
+  description: Equipment represents physical supplies or devices.
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1)
+  name: Equipment
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteEquipment
+- class_: cdf_cdm:CogniteEquipmentType(version=v1)
+  description: Represents the type of equipment.
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  name: Equipment type
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteEquipmentType
+- class_: cdf_cdm:CogniteFile(version=v1)
+  description: Represents files.
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1)
+  name: File
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteFile
+- class_: cdf_cdm:CogniteFileCategory(version=v1)
+  description: Represents the categories of files as determined by contextualization
+    or categorization.
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  name: File category
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteFileCategory
+- class_: cdf_cdm:CognitePointCloudVolume(version=v1)
+  description: PointCloud volume definition
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CognitePointCloudVolume
+- class_: cdf_cdm:CogniteSchedulable(version=v1)
+  description: CogniteSchedulable represents the metadata about when an activity (or
+    similar) starts and ends.
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteSchedulable
+- class_: cdf_cdm:CogniteSourceSystem(version=v1)
+  description: The CogniteSourceSystem core concept is used to standardize the way
+    source system is stored.
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteSourceSystem
+- class_: cdf_cdm:CogniteSourceable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteSourceable
+- class_: cdf_cdm:CogniteTimeSeries(version=v1)
+  description: Represents a series of data points in time order.
+  implements: cdf_cdm:CogniteDescribable(version=v1),cdf_cdm:CogniteSourceable(version=v1)
+  name: Time series
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteTimeSeries
+- class_: cdf_cdm:CogniteUnit(version=v1)
+  description: Represents a single unit of measurement
+  implements: cdf_cdm:CogniteDescribable(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteUnit
+- class_: cdf_cdm:CogniteVisualizable(version=v1)
+  description: CogniteVisualizable defines the standard way to reference a related
+    3D resource
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/CogniteVisualizable
+- class_: PowerGeneratingUnit
+  implements: cdf_cdm:CogniteAsset(version=v1)
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/neat_space/NeatInferredDataModel/v1/CircuitShare
+- class_: WindTurbine
+  implements: PowerGeneratingUnit
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/neat_space/NeatInferredDataModel/v1/EnergyCongestionZone
+metadata:
+  created: '2025-03-12T14:42:00.631000'
+  creator: doctrino
+  description: This is the start of building a core extension
+  external_id: MyModel
+  name: My Model
+  role: information architect
+  source_id: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1
+  space: my_space
+  updated: '2025-03-12T14:42:00.631000'
+  version: v1
+prefixes:
+  aml: https://www.automationml.org/
+  dc: http://purl.org/dc/elements/1.1/
+  dcterms: http://purl.org/dc/terms/
+  dexpi: http://sandbox.dexpi.org/rdl/
+  foaf: http://xmlns.com/foaf/0.1/
+  imf: http://ns.imfid.org/imf#
+  iodd: http://www.io-link.com/IODD/2010/10/
+  owl: http://www.w3.org/2002/07/owl#
+  pav: http://purl.org/pav/
+  prefix_16: http://entsoe.eu/CIM/SchemaExtension/3/2#
+  prefix_17: http://iec.ch/TC57/2013/CIM-schema-cim16#
+  prefix_18: http://www.pti-us.com/PTI_CIM-schema-cim16#
+  prefix_19: http://entsoe.eu/CIM/SchemaExtension/3/1#
+  prefix_20: http://iec.ch/TC57/2013/CIM-schema-cim16-info#
+  prefix_21: http://iec.ch/TC57/61970-552/ModelDescription/1#
+  qudt: https://qudt.org/vocab/unit/
+  rdf: http://www.w3.org/1999/02/22-rdf-syntax-ns#
+  rdfs: http://www.w3.org/2000/01/rdf-schema#
+  sh: http://www.w3.org/ns/shacl#
+  skos: http://www.w3.org/2004/02/skos/core#
+  xsd: http://www.w3.org/2001/XMLSchema#
+properties:
+- class_: PowerGeneratingUnit
+  description: An automatically updated list of activities related to the asset.
+  max_count: null
+  name: Activities
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/activities
+  property_: activities
+  value_type: cdf_cdm:CogniteActivity(version=v1)
+- class_: PowerGeneratingUnit
+  description: Alternative names for the node
+  max_count: 1000.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/aliases
+  property_: aliases
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: Specifies the class of the asset. It's a direct relation to CogniteAssetClass.
+  max_count: 1.0
+  min_count: 0
+  name: Asset class
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/assetClass
+  property_: assetClass
+  value_type: cdf_cdm:CogniteAssetClass(version=v1)
+- class_: PowerGeneratingUnit
+  description: An automatically updated list of assets with this asset as their parent.
+  max_count: null
+  name: Children
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/children
+  property_: children
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: PowerGeneratingUnit
+  description: Description of the instance
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/description
+  property_: description
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: An automatically updated list of equipment related to the asset.
+  max_count: null
+  name: Equipment
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/equipment
+  property_: equipment
+  value_type: cdf_cdm:CogniteEquipment(version=v1)
+- class_: PowerGeneratingUnit
+  description: An automatically updated list of files related to the asset.
+  max_count: null
+  name: Files
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/files
+  property_: files
+  value_type: cdf_cdm:CogniteFile(version=v1)
+- class_: PowerGeneratingUnit
+  description: Name of the instance
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/name
+  property_: name
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: Direct relation to an Object3D instance representing the 3D resource
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/object3D
+  property_: object3D
+  value_type: cdf_cdm:Cognite3DObject(version=v1)
+- class_: PowerGeneratingUnit
+  description: The parent of the asset.
+  max_count: 1.0
+  min_count: 0
+  name: Parent
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/parent
+  property_: parent
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: PowerGeneratingUnit
+  description: An automatically updated ordered list of this asset's ancestors, starting
+    with the root asset. Enables subtree filtering to find all assets under a parent.
+  max_count: 100.0
+  min_count: 0
+  name: Path
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/path
+  property_: path
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: PowerGeneratingUnit
+  description: The last time the path was updated for this asset.
+  max_count: 1.0
+  min_count: 0
+  name: Path last updated time
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/pathLastUpdatedTime
+  property_: pathLastUpdatedTime
+  value_type: dateTime
+- class_: PowerGeneratingUnit
+  description: An automatically updated reference to the top-level asset of the hierarchy.
+  max_count: 1.0
+  min_count: 0
+  name: Root
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/root
+  property_: root
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: PowerGeneratingUnit
+  description: Direct relation to a source system
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/source
+  property_: source
+  value_type: cdf_cdm:CogniteSourceSystem(version=v1)
+- class_: PowerGeneratingUnit
+  description: Context of the source id. For systems where the sourceId is globally
+    unique, the sourceContext is expected to not be set.
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceContext
+  property_: sourceContext
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: When the instance was created in source system (if available)
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceCreatedTime
+  property_: sourceCreatedTime
+  value_type: dateTime
+- class_: PowerGeneratingUnit
+  description: User identifier from the source system on who created the source data.
+    This identifier is not guaranteed to match the user identifiers in CDF
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceCreatedUser
+  property_: sourceCreatedUser
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: Identifier from the source system
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceId
+  property_: sourceId
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: When the instance was last updated in the source system (if available)
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceUpdatedTime
+  property_: sourceUpdatedTime
+  value_type: dateTime
+- class_: PowerGeneratingUnit
+  description: User identifier from the source system on who last updated the source
+    data. This identifier is not guaranteed to match the user identifiers in CDF
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/sourceUpdatedUser
+  property_: sourceUpdatedUser
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: Text based labels for generic use, limited to 1000
+  max_count: 1000.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/tags
+  property_: tags
+  value_type: string
+- class_: PowerGeneratingUnit
+  description: An automatically updated list of time series related to the asset.
+  max_count: null
+  name: Time series
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/timeSeries
+  property_: timeSeries
+  value_type: cdf_cdm:CogniteTimeSeries(version=v1)
+- class_: PowerGeneratingUnit
+  description: Specifies the type of the asset. It's a direct relation to CogniteAssetType.
+  max_count: 1.0
+  min_count: 0
+  name: Asset type
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/PowerGeneratingUnit/type
+  property_: type
+  value_type: cdf_cdm:CogniteAssetType(version=v1)
+- class_: WindTurbine
+  description: An automatically updated list of activities related to the asset.
+  max_count: null
+  name: Activities
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/activities
+  property_: activities
+  value_type: cdf_cdm:CogniteActivity(version=v1)
+- class_: WindTurbine
+  description: Alternative names for the node
+  max_count: 1000.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/aliases
+  property_: aliases
+  value_type: string
+- class_: WindTurbine
+  description: Specifies the class of the asset. It's a direct relation to CogniteAssetClass.
+  max_count: 1.0
+  min_count: 0
+  name: Asset class
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/assetClass
+  property_: assetClass
+  value_type: cdf_cdm:CogniteAssetClass(version=v1)
+- class_: WindTurbine
+  description: An automatically updated list of assets with this asset as their parent.
+  max_count: null
+  name: Children
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/children
+  property_: children
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: WindTurbine
+  description: Description of the instance
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/description
+  property_: description
+  value_type: string
+- class_: WindTurbine
+  description: An automatically updated list of equipment related to the asset.
+  max_count: null
+  name: Equipment
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/equipment
+  property_: equipment
+  value_type: cdf_cdm:CogniteEquipment(version=v1)
+- class_: WindTurbine
+  description: An automatically updated list of files related to the asset.
+  max_count: null
+  name: Files
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/files
+  property_: files
+  value_type: cdf_cdm:CogniteFile(version=v1)
+- class_: WindTurbine
+  description: Name of the instance
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/name
+  property_: name
+  value_type: string
+- class_: WindTurbine
+  description: Direct relation to an Object3D instance representing the 3D resource
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/object3D
+  property_: object3D
+  value_type: cdf_cdm:Cognite3DObject(version=v1)
+- class_: WindTurbine
+  description: The parent of the asset.
+  max_count: 1.0
+  min_count: 0
+  name: Parent
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/parent
+  property_: parent
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: WindTurbine
+  description: An automatically updated ordered list of this asset's ancestors, starting
+    with the root asset. Enables subtree filtering to find all assets under a parent.
+  max_count: 100.0
+  min_count: 0
+  name: Path
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/path
+  property_: path
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: WindTurbine
+  description: The last time the path was updated for this asset.
+  max_count: 1.0
+  min_count: 0
+  name: Path last updated time
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/pathLastUpdatedTime
+  property_: pathLastUpdatedTime
+  value_type: dateTime
+- class_: WindTurbine
+  description: An automatically updated reference to the top-level asset of the hierarchy.
+  max_count: 1.0
+  min_count: 0
+  name: Root
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/root
+  property_: root
+  value_type: cdf_cdm:CogniteAsset(version=v1)
+- class_: WindTurbine
+  description: Direct relation to a source system
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/source
+  property_: source
+  value_type: cdf_cdm:CogniteSourceSystem(version=v1)
+- class_: WindTurbine
+  description: Context of the source id. For systems where the sourceId is globally
+    unique, the sourceContext is expected to not be set.
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceContext
+  property_: sourceContext
+  value_type: string
+- class_: WindTurbine
+  description: When the instance was created in source system (if available)
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceCreatedTime
+  property_: sourceCreatedTime
+  value_type: dateTime
+- class_: WindTurbine
+  description: User identifier from the source system on who created the source data.
+    This identifier is not guaranteed to match the user identifiers in CDF
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceCreatedUser
+  property_: sourceCreatedUser
+  value_type: string
+- class_: WindTurbine
+  description: Identifier from the source system
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceId
+  property_: sourceId
+  value_type: string
+- class_: WindTurbine
+  description: When the instance was last updated in the source system (if available)
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceUpdatedTime
+  property_: sourceUpdatedTime
+  value_type: dateTime
+- class_: WindTurbine
+  description: User identifier from the source system on who last updated the source
+    data. This identifier is not guaranteed to match the user identifiers in CDF
+  max_count: 1.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/sourceUpdatedUser
+  property_: sourceUpdatedUser
+  value_type: string
+- class_: WindTurbine
+  description: Text based labels for generic use, limited to 1000
+  max_count: 1000.0
+  min_count: 0
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/tags
+  property_: tags
+  value_type: string
+- class_: WindTurbine
+  description: An automatically updated list of time series related to the asset.
+  max_count: null
+  name: Time series
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/timeSeries
+  property_: timeSeries
+  value_type: cdf_cdm:CogniteTimeSeries(version=v1)
+- class_: WindTurbine
+  description: Specifies the type of the asset. It's a direct relation to CogniteAssetType.
+  max_count: 1.0
+  min_count: 0
+  name: Asset type
+  neatId: http://purl.org/cognite/neat/data-model/verified/logical/my_space/MyModel/v1/WindTurbine/type
+  property_: type
+  value_type: cdf_cdm:CogniteAssetType(version=v1)
diff --git a/tests/tests_unit/test_rules/test_transformers/test_converters.py b/tests/tests_unit/test_rules/test_transformers/test_converters.py
index 74bd3f1d24..95ddd5ac17 100644
--- a/tests/tests_unit/test_rules/test_transformers/test_converters.py
+++ b/tests/tests_unit/test_rules/test_transformers/test_converters.py
@@ -1,5 +1,11 @@
+from collections import defaultdict
+from collections.abc import Sequence
+
 import pytest
+from cognite.client.data_classes.data_modeling import ViewId, ViewIdentifier, ViewList
 
+from cognite.neat._client.data_classes.schema import DMSSchema
+from cognite.neat._client.testing import monkeypatch_neat_client
 from cognite.neat._issues.errors._general import NeatValueError
 from cognite.neat._rules._shared import ReadRules
 from cognite.neat._rules.models import DMSInputRules, InformationRules
@@ -13,6 +19,7 @@
     InformationInputRules,
 )
 from cognite.neat._rules.transformers import (
+    AddCogniteProperties,
     StandardizeNaming,
     SubsetDMSRules,
     SubsetInformationRules,
@@ -115,3 +122,58 @@ def test_subset_dms_rules_fails(self, alice_rules: DMSRules) -> None:
 
         with pytest.raises(NeatValueError):
             _ = SubsetDMSRules({view}).transform(alice_rules)
+
+
+class TestAddCogniteProperties:
+    def test_add_cognite_properties(self, cognite_core_schema: DMSSchema) -> None:
+        input_rules = InformationInputRules(
+            metadata=InformationInputMetadata("my_space", "MyModel", "v1", "doctrino"),
+            properties=[],
+            classes=[
+                InformationInputClass("PowerGeneratingUnit", implements="cdf_cdm:CogniteAsset(version=v1)"),
+                InformationInputClass("WindTurbine", implements="PowerGeneratingUnit"),
+            ],
+        )
+        read_model = cognite_core_schema.as_read_model()
+        views_by_id = {view.as_id(): view for view in read_model.views}
+
+        def cognite_core(ids: ViewIdentifier | Sequence[ViewIdentifier], **kwargs) -> ViewList:
+            ids = [ids] if isinstance(ids, ViewId | tuple) else ids
+            view_ids = [ViewId.load(id_) for id_ in ids]
+
+            return ViewList([views_by_id[id_] for id_ in view_ids])
+
+        with monkeypatch_neat_client() as client:
+            client.data_modeling.views.retrieve.side_effect = cognite_core
+
+            result = AddCogniteProperties(client).transform(ReadRules(input_rules, {}))
+        assert result.rules is not None
+        actual_classes = {str(c.class_) for c in result.rules.classes}
+        expected_classes = (
+            {"PowerGeneratingUnit", "WindTurbine"}
+            | {
+                f"{view_id.space}:{view_id.external_id}(version={view_id.version})"
+                for view_id in cognite_core_schema.views.keys()
+            }
+            # These classes are not reachable from the CogniteAsset
+        ) - {
+            "cdf_cdm:CogniteDiagramAnnotation(version=v1)",
+            "cdf_cdm:CognitePointCloudModel(version=v1)",
+            "cdf_cdm:CognitePointCloudRevision(version=v1)",
+        }
+        assert actual_classes == expected_classes
+        cognite_asset = cognite_core_schema.views[ViewId("cdf_cdm", "CogniteAsset", "v1")]
+        expected_properties = set(cognite_asset.properties.keys())
+        expected_properties |= {
+            prop_id
+            for parent in cognite_asset.implements
+            for prop_id in cognite_core_schema.views[parent].properties.keys()
+        }
+
+        properties_by_class = defaultdict(set)
+        for prop in result.rules.properties:
+            properties_by_class[prop.class_.dump(prefix="my_space")].add(prop.property_)
+
+        assert set(properties_by_class.keys()) == {"PowerGeneratingUnit", "WindTurbine"}
+        assert properties_by_class["PowerGeneratingUnit"] == expected_properties
+        assert properties_by_class["WindTurbine"] == expected_properties
diff --git a/tests/tests_unit/test_rules/test_transformers/test_transformer_meta.py b/tests/tests_unit/test_rules/test_transformers/test_transformer_meta.py
index bfc5e67485..3d436e954e 100644
--- a/tests/tests_unit/test_rules/test_transformers/test_transformer_meta.py
+++ b/tests/tests_unit/test_rules/test_transformers/test_transformer_meta.py
@@ -8,6 +8,7 @@
 from cognite.neat._rules.models import DMSInputRules, DMSRules, InformationInputRules, InformationRules
 from cognite.neat._rules.transformers import (
     AddClassImplements,
+    AddCogniteProperties,
     ClassicPrepareCore,
     DropModelViews,
     IncludeReferenced,
@@ -33,7 +34,7 @@ def instantiated_transformers_cls() -> Iterable[RulesTransformer]:
             yield transformation_cls("3D")
         elif issubclass(transformation_cls, AddClassImplements):
             yield transformation_cls("Edge", "Edge")
-        elif issubclass(transformation_cls, RuleMapper | IncludeReferenced):
+        elif issubclass(transformation_cls, RuleMapper | IncludeReferenced | AddCogniteProperties):
             # Manually checked as these require NeatClient or DMSRules in the setup
             continue
         elif issubclass(transformation_cls, ClassicPrepareCore):
diff --git a/tests/tests_unit/test_utils/test_auxiliary.py b/tests/tests_unit/test_utils/test_auxiliary.py
index 272cb607f3..fa0d5b3ed5 100644
--- a/tests/tests_unit/test_utils/test_auxiliary.py
+++ b/tests/tests_unit/test_utils/test_auxiliary.py
@@ -12,6 +12,8 @@
         (
             DMSImporter,
             [
+                DMSImporter.as_information_input_class,
+                DMSImporter.as_information_input_property,
                 DMSImporter.from_data_model,
                 DMSImporter.from_data_model_id,
                 DMSImporter.from_directory,


Reviews:
user: login: nikokaoja
body: See my comments
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-27T13:41:01Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T06:53:23Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T06:53:32Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T06:54:49Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T06:57:01Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T06:58:36Z
user: login: doctrino
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T07:14:59Z
user: login: nikokaoja
state: DISMISSED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T08:35:04Z
user: login: psalaberria002
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T09:47:43Z
user: login: nikokaoja
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-03-31T10:35:09Z

Review comments:
diff_hunk: @@ -332,19 +342,26 @@ def _create_dms_property(
             )
             return None
 
-        value_type = self._get_value_type(prop, view_entity, prop_id, enum_collection_by_container_property)
+        get_container = self._container_prop_unsafe
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: I know that `self._container_prop_unsafe` is 11 months old, but name of this method is not expressive.
From what I can see this is method is returning container property definition , it is "unsafe" as you are assuming everything is in good place. Why not make it safe , and rename it to :

`self._get_container_property_definition`
created_at: 2025-03-27T11:37:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -378,34 +395,42 @@ def _get_connection_type(
         else:
             return None
 
+    @classmethod
     def _get_value_type(
-        self,
-        prop: ViewPropertyApply,
-        view_entity: ViewEntity,
-        prop_id: str,
-        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str],
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        get_container: Callable[[dm.MappedPropertyApply], dm.ContainerProperty] | None = None,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: `get_container` is callable that return container definition , in reality you are actually returning container property definition , so I would strongly suggest renaming to `get_container_property_definition` or something along this line. 
created_at: 2025-03-27T11:41:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,19 +342,26 @@ def _create_dms_property(
             )
             return None
 
-        value_type = self._get_value_type(prop, view_entity, prop_id, enum_collection_by_container_property)
+        get_container = self._container_prop_unsafe
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: Are you expecting to have multiple ways how you are getting container property definition?
If not, I do not see why `_get_value_type` should have callable arg.

I am generally against callable as args in methods, unless they are really needed, as code becomes less readable.
created_at: 2025-03-27T11:45:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -332,19 +342,26 @@ def _create_dms_property(
             )
             return None
 
-        value_type = self._get_value_type(prop, view_entity, prop_id, enum_collection_by_container_property)
+        get_container = self._container_prop_unsafe
+
+        value_type = self._get_value_type(prop, get_container, enum_collection_by_container_property)
         if value_type is None:
+            self.issue_list.append(
+                PropertyTypeNotSupportedWarning(view_entity.as_id(), "view", prop_id, type(prop).__name__)
+            )
             return None
+        if isinstance(value_type, ViewEntity) and value_type.as_id() not in self._all_views_by_id:
+            self.issue_list.append(ResourceUnknownWarning(prop.source, "view", view_entity.as_id(), "view"))
 
         return DMSInputProperty(
             description=prop.description,
             name=prop.name,
             connection=self._get_connection_type(prop),
             value_type=str(value_type),
-            min_count=self._get_min_count(prop),
-            max_count=self._get_max_count(prop),
+            min_count=self._get_min_count(prop, get_container),
+            max_count=self._get_max_count(prop, get_container),
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: Against callable in both cases
created_at: 2025-03-27T11:46:47Z
author_association: CONTRIBUTOR
diff_hunk: @@ -378,34 +395,42 @@ def _get_connection_type(
         else:
             return None
 
+    @classmethod
     def _get_value_type(
-        self,
-        prop: ViewPropertyApply,
-        view_entity: ViewEntity,
-        prop_id: str,
-        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str],
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        get_container: Callable[[dm.MappedPropertyApply], dm.ContainerProperty] | None = None,
+        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str] | None = None,
     ) -> DataType | ViewEntity | DMSUnknownEntity | None:
         if isinstance(
             prop,
             SingleEdgeConnectionApply
             | MultiEdgeConnectionApply
             | SingleReverseDirectRelationApply
-            | MultiReverseDirectRelationApply,
+            | MultiReverseDirectRelationApply
+            | SingleEdgeConnection
+            | dm.MultiEdgeConnection
+            | SingleReverseDirectRelation
+            | dm.MultiReverseDirectRelation,
         ):
             return ViewEntity.from_id(prop.source)
-        elif isinstance(prop, dm.MappedPropertyApply):
-            container_prop = self._container_prop_unsafe(cast(dm.MappedPropertyApply, prop))
-            if isinstance(container_prop.type, dm.DirectRelation):
+        elif isinstance(prop, dm.MappedPropertyApply | dm.MappedProperty):
+            if isinstance(prop, dm.MappedPropertyApply):
+                if get_container is None:
+                    raise ValueError("get_container must be provided when prop is a MappedProperty")
+                prop_type = get_container(prop).type
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: Why `get_container` is callable, when this statement requires that you are able to get definition of container property ? 

- Make `self._container_prop_unsafe` into `self._get_container_property_defintion` which is "safe" (ie. raise issue if requested property is not availalbe)
- remove `get_container` as arg of this method and simply get by `prop_type = self._get_container_property_defintion(prop).type`
created_at: 2025-03-27T11:50:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -415,18 +440,24 @@ def _get_value_type(
                         f"BUG in Neat: Enum for {prop.container}.{prop.container_property_identifier} not found."
                     )
 
-                return Enum(collection=ClassEntity(suffix=collection), unknownValue=container_prop.type.unknown_value)
+                return Enum(collection=ClassEntity(suffix=collection), unknownValue=prop_type.unknown_value)
             else:
-                return DataType.load(container_prop.type._type)
+                return DataType.load(prop_type._type)
         else:
-            self.issue_list.append(
-                PropertyTypeNotSupportedWarning[dm.ViewId](view_entity.as_id(), "view", prop_id, type(prop).__name__)
-            )
             return None
 
-    def _get_min_count(self, prop: ViewPropertyApply) -> int | None:
+    @classmethod
+    def _get_min_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        get_container: Callable[[dm.MappedPropertyApply], dm.ContainerProperty] | None = None,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: against this for same reasons as above
created_at: 2025-03-27T11:50:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -436,9 +467,19 @@ def _get_immutable(self, prop: ViewPropertyApply) -> bool | None:
         else:
             return None
 
-    def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            prop_type = self._container_prop_unsafe(prop).type
+    @classmethod
+    def _get_max_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        get_container: Callable[[dm.MappedPropertyApply], dm.ContainerProperty] | None = None,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: same as above
created_at: 2025-03-27T11:50:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -450,17 +491,39 @@ def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
                     return DMS_PRIMITIVE_LIST_DEFAULT_LIMIT
             else:
                 return 1
-        elif isinstance(prop, MultiEdgeConnectionApply | MultiReverseDirectRelationApply):
+        elif isinstance(
+            prop,
+            MultiEdgeConnectionApply
+            | MultiReverseDirectRelationApply
+            | dm.MultiEdgeConnection
+            | dm.MultiReverseDirectRelation,
+        ):
             return float("inf")
-        elif isinstance(prop, SingleEdgeConnectionApply | SingleReverseDirectRelationApply):
+        elif isinstance(
+            prop,
+            SingleEdgeConnectionApply
+            | SingleReverseDirectRelationApply
+            | SingleEdgeConnection
+            | SingleReverseDirectRelation,
+        ):
             return 1
         else:
             # Unknown type.
             return None
 
-    def _get_default(self, prop: ViewPropertyApply) -> str | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            default = self._container_prop_unsafe(prop).default_value
+    @classmethod
+    def _get_default(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        get_container: Callable[[dm.MappedPropertyApply], dm.ContainerProperty] | None = None,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: same as above, no need for callable arg
created_at: 2025-03-27T11:51:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -436,9 +467,19 @@ def _get_immutable(self, prop: ViewPropertyApply) -> bool | None:
         else:
             return None
 
-    def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            prop_type = self._container_prop_unsafe(prop).type
+    @classmethod
+    def _get_max_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: Looking at code you check if arg `prop` is of type `MappedPropertyApply`,`MappedProperty` and `ListablePropertyType` , but in definition of the method you state that `prop` takes either `ViewPropertyApply` or `ViewProperty` ?

A bit confusing , update types in method defintion to encoutner for for all types arg `prop` can take
created_at: 2025-03-27T11:56:06Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
path: cognite/neat/_rules/transformers/_converters.py
user: login: nikokaoja
body: We make separate PR to move this under `InputRulesAnalysis`
created_at: 2025-03-27T11:57:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
+        properties: list[InformationInputProperty], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, dict[str, InformationInputProperty]]:
+        issues = IssueList()
+        properties_by_class: dict[ClassEntity, dict[str, InformationInputProperty]] = defaultdict(dict)
+        for prop in properties:
+            try:
+                dumped = prop.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            properties_by_class[class_entity][prop.property_] = prop
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading properties")
+        return properties_by_class
+
+    @staticmethod
+    def _get_dependencies_by_class(
path: cognite/neat/_rules/transformers/_converters.py
user: login: nikokaoja
body: We make separate PR to move this under `InputRulesAnalysis`
created_at: 2025-03-27T11:59:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
+        properties: list[InformationInputProperty], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, dict[str, InformationInputProperty]]:
+        issues = IssueList()
+        properties_by_class: dict[ClassEntity, dict[str, InformationInputProperty]] = defaultdict(dict)
+        for prop in properties:
+            try:
+                dumped = prop.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            properties_by_class[class_entity][prop.property_] = prop
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading properties")
+        return properties_by_class
+
+    @staticmethod
+    def _get_dependencies_by_class(
+        classes: list[InformationInputClass], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, set[ClassEntity]]:
+        dependencies_by_class: dict[ClassEntity, set[ClassEntity]] = {}
+        issues = IssueList()
+        for raw in classes:
+            try:
+                dumped = raw.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            implements = cast(list[ClassEntity] | None, dumped["Implements"])
+            dependencies_by_class[class_entity] = set(implements or [])
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading classes")
+        return dependencies_by_class
+
+    @staticmethod
+    def _get_cognite_concepts(dependencies_by_class: dict[ClassEntity, set[ClassEntity]]) -> set[ClassEntity]:
path: cognite/neat/_rules/transformers/_converters.py
user: login: nikokaoja
body: This should be seprate PR in which we move this to dedicated InputRulesAnalysis class
created_at: 2025-03-27T11:59:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -559,3 +622,43 @@ def _create_enum_collections(
                         )
                     )
         return enum_by_container_property
+
+    @classmethod
+    def as_information_property(
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: ```suggestion
    def as_information_input_property(
```
created_at: 2025-03-27T13:21:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -559,3 +622,43 @@ def _create_enum_collections(
                         )
                     )
         return enum_by_container_property
+
+    @classmethod
+    def as_information_property(
+        cls, entity: ClassEntity, prop_id: str, view_property: ViewProperty
+    ) -> InformationInputProperty:
+        if not isinstance(view_property, dm.MappedProperty | dm.EdgeConnection | ReverseDirectRelation):
+            raise PropertyTypeNotSupportedError(
+                dm.ViewId(str(entity.prefix), str(entity.suffix), entity.version),
+                "view",
+                prop_id,
+                type(view_property).__name__,
+            )
+
+        value_type = cls._get_value_type(view_property)
+        if value_type is None:
+            raise NeatValueError(f"Failed to get value type for {entity} property {prop_id}")
+
+        return InformationInputProperty(
+            class_=entity,
+            property_=prop_id,
+            value_type=str(value_type),
+            name=view_property.name,
+            description=view_property.description,
+            min_count=cls._get_min_count(view_property),
+            max_count=cls._get_max_count(view_property),
+            default=cls._get_default(view_property),
+        )
+
+    @classmethod
+    def as_information_class(cls, view: View) -> InformationInputClass:
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: ```suggestion
    def as_information_input_class(cls, view: View) -> InformationInputClass:
```
created_at: 2025-03-27T13:21:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -559,3 +622,43 @@ def _create_enum_collections(
                         )
                     )
         return enum_by_container_property
+
+    @classmethod
+    def as_information_property(
+        cls, entity: ClassEntity, prop_id: str, view_property: ViewProperty
+    ) -> InformationInputProperty:
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: nikokaoja
body: ViewProperty does not hold external id of property? 
created_at: 2025-03-27T13:23:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
+        properties: list[InformationInputProperty], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, dict[str, InformationInputProperty]]:
+        issues = IssueList()
+        properties_by_class: dict[ClassEntity, dict[str, InformationInputProperty]] = defaultdict(dict)
+        for prop in properties:
+            try:
+                dumped = prop.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            properties_by_class[class_entity][prop.property_] = prop
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading properties")
+        return properties_by_class
+
+    @staticmethod
+    def _get_dependencies_by_class(
+        classes: list[InformationInputClass], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, set[ClassEntity]]:
+        dependencies_by_class: dict[ClassEntity, set[ClassEntity]] = {}
+        issues = IssueList()
+        for raw in classes:
+            try:
+                dumped = raw.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            implements = cast(list[ClassEntity] | None, dumped["Implements"])
+            dependencies_by_class[class_entity] = set(implements or [])
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading classes")
+        return dependencies_by_class
+
+    @staticmethod
+    def _get_cognite_concepts(dependencies_by_class: dict[ClassEntity, set[ClassEntity]]) -> set[ClassEntity]:
+        cognite_implements_concepts = {
+            dependency
+            for dependencies in dependencies_by_class.values()
+            for dependency in dependencies
+            if dependency.prefix in COGNITE_SPACES
+        }
+        if not cognite_implements_concepts:
+            raise NeatValueError("None of the classes implement Cognite Core concepts.")
+        return cognite_implements_concepts
+
+    def _lookup_views(
path: cognite/neat/_rules/transformers/_converters.py
user: login: nikokaoja
body: so this method is doing two things:
- convert classes to views
- try to get their definition with properties from CDF
- return dict views_definition_by_class_entity

as such the current method name is ambiguous , so I propose call this:

`get_view_definitions_by_class`
created_at: 2025-03-27T13:24:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,3 +173,52 @@ def data_product_model(
         if last_rules and not issues.has_errors:
             self._state.last_reference = last_rules
         return issues
+
+    def extension(self, io: Any, output: str | Path | None = None) -> IssueList:
+        """Creates a template for an extension of a Cognite model.
+
+        The input is a spreadsheet of a conceptual model in which the concepts are defined
+        and marked with the Cognite concept they are extending. For example, if you have a pump
+        in the Classes sheet you will see
+        ```
+        Class: Pump
+        Implements: cdf_cdm:CogniteAsset(version=v1)
+        ```
+        The output will be a spreadsheet in which all the properties from the Cognite concept model
+        is added to the spreadsheet. In the example above, the pump concept will have all
+        the properties it inherits from the CogniteAsset concept added to the Properties spreadsheet.
+
+
+        Args:
+            io: The input spreadsheet.
+            output: The output spreadsheet. If None, the output will be the same
+                as the input with `_extension` added to the name.
+        """
+        ExperimentalFlags.extension.warn()
+        reader = NeatReader.create(io)
+        path = reader.materialize_path()
+        if output is None:
+            if isinstance(reader, PathReader):
+                output_path = path.with_name(f"{path.stem}_extension{path.suffix}")
+            else:
+                # The source is not a file, for example, a URL or a stream.
+                output_path = Path.cwd() / f"{path.stem}_extension{path.suffix}"
+        else:
+            output_path = Path(output)
+
+        with catch_issues() as issues:
+            read: ReadRules[InformationInputRules] = ExcelImporter(path).to_rules()
+            if read.rules is not None:
+                # If rules are None there will be issues that are already caught.
+                if not isinstance(read.rules, InformationInputRules):
path: cognite/neat/_session/_template.py
user: login: nikokaoja
body: extend method `_raise_exception_if_condition_not_met` and call it here
created_at: 2025-03-27T13:38:49Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,3 +173,52 @@ def data_product_model(
         if last_rules and not issues.has_errors:
             self._state.last_reference = last_rules
         return issues
+
+    def extension(self, io: Any, output: str | Path | None = None) -> IssueList:
+        """Creates a template for an extension of a Cognite model.
+
+        The input is a spreadsheet of a conceptual model in which the concepts are defined
+        and marked with the Cognite concept they are extending. For example, if you have a pump
+        in the Classes sheet you will see
+        ```
+        Class: Pump
+        Implements: cdf_cdm:CogniteAsset(version=v1)
+        ```
+        The output will be a spreadsheet in which all the properties from the Cognite concept model
+        is added to the spreadsheet. In the example above, the pump concept will have all
+        the properties it inherits from the CogniteAsset concept added to the Properties spreadsheet.
+
+
+        Args:
+            io: The input spreadsheet.
+            output: The output spreadsheet. If None, the output will be the same
+                as the input with `_extension` added to the name.
+        """
+        ExperimentalFlags.extension.warn()
+        reader = NeatReader.create(io)
+        path = reader.materialize_path()
+        if output is None:
+            if isinstance(reader, PathReader):
+                output_path = path.with_name(f"{path.stem}_extension{path.suffix}")
+            else:
+                # The source is not a file, for example, a URL or a stream.
+                output_path = Path.cwd() / f"{path.stem}_extension{path.suffix}"
+        else:
+            output_path = Path(output)
+
+        with catch_issues() as issues:
+            read: ReadRules[InformationInputRules] = ExcelImporter(path).to_rules()
+            if read.rules is not None:
+                # If rules are None there will be issues that are already caught.
+                if not isinstance(read.rules, InformationInputRules):
+                    raise NeatSessionError(f"The input {reader.name} must contain an InformationInputRules object. ")
+                if self._state.client is None:
+                    raise NeatSessionError("Client must be set in the session to run the extension.")
path: cognite/neat/_session/_template.py
user: login: nikokaoja
body: call `_raise_exception_if_condition_not_met` instead of raising session error
created_at: 2025-03-27T13:39:10Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,3 +173,52 @@ def data_product_model(
         if last_rules and not issues.has_errors:
             self._state.last_reference = last_rules
         return issues
+
+    def extension(self, io: Any, output: str | Path | None = None) -> IssueList:
+        """Creates a template for an extension of a Cognite model.
+
+        The input is a spreadsheet of a conceptual model in which the concepts are defined
+        and marked with the Cognite concept they are extending. For example, if you have a pump
+        in the Classes sheet you will see
+        ```
+        Class: Pump
+        Implements: cdf_cdm:CogniteAsset(version=v1)
+        ```
+        The output will be a spreadsheet in which all the properties from the Cognite concept model
+        is added to the spreadsheet. In the example above, the pump concept will have all
+        the properties it inherits from the CogniteAsset concept added to the Properties spreadsheet.
+
+
+        Args:
+            io: The input spreadsheet.
+            output: The output spreadsheet. If None, the output will be the same
+                as the input with `_extension` added to the name.
+        """
+        ExperimentalFlags.extension.warn()
+        reader = NeatReader.create(io)
+        path = reader.materialize_path()
+        if output is None:
+            if isinstance(reader, PathReader):
+                output_path = path.with_name(f"{path.stem}_extension{path.suffix}")
+            else:
+                # The source is not a file, for example, a URL or a stream.
+                output_path = Path.cwd() / f"{path.stem}_extension{path.suffix}"
+        else:
+            output_path = Path(output)
+
+        with catch_issues() as issues:
+            read: ReadRules[InformationInputRules] = ExcelImporter(path).to_rules()
+            if read.rules is not None:
+                # If rules are None there will be issues that are already caught.
+                if not isinstance(read.rules, InformationInputRules):
path: cognite/neat/_session/_template.py
user: login: doctrino
body: The `_raise_exception_if_condition_not_met` is to guard the state of the session. This method is independent of state. The only reason it is not a staticmethod, is that it needs access to the `NeatClient`.
created_at: 2025-03-31T06:53:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -165,3 +173,52 @@ def data_product_model(
         if last_rules and not issues.has_errors:
             self._state.last_reference = last_rules
         return issues
+
+    def extension(self, io: Any, output: str | Path | None = None) -> IssueList:
+        """Creates a template for an extension of a Cognite model.
+
+        The input is a spreadsheet of a conceptual model in which the concepts are defined
+        and marked with the Cognite concept they are extending. For example, if you have a pump
+        in the Classes sheet you will see
+        ```
+        Class: Pump
+        Implements: cdf_cdm:CogniteAsset(version=v1)
+        ```
+        The output will be a spreadsheet in which all the properties from the Cognite concept model
+        is added to the spreadsheet. In the example above, the pump concept will have all
+        the properties it inherits from the CogniteAsset concept added to the Properties spreadsheet.
+
+
+        Args:
+            io: The input spreadsheet.
+            output: The output spreadsheet. If None, the output will be the same
+                as the input with `_extension` added to the name.
+        """
+        ExperimentalFlags.extension.warn()
+        reader = NeatReader.create(io)
+        path = reader.materialize_path()
+        if output is None:
+            if isinstance(reader, PathReader):
+                output_path = path.with_name(f"{path.stem}_extension{path.suffix}")
+            else:
+                # The source is not a file, for example, a URL or a stream.
+                output_path = Path.cwd() / f"{path.stem}_extension{path.suffix}"
+        else:
+            output_path = Path(output)
+
+        with catch_issues() as issues:
+            read: ReadRules[InformationInputRules] = ExcelImporter(path).to_rules()
+            if read.rules is not None:
+                # If rules are None there will be issues that are already caught.
+                if not isinstance(read.rules, InformationInputRules):
+                    raise NeatSessionError(f"The input {reader.name} must contain an InformationInputRules object. ")
+                if self._state.client is None:
+                    raise NeatSessionError("Client must be set in the session to run the extension.")
path: cognite/neat/_session/_template.py
user: login: doctrino
body: Ree above.
created_at: 2025-03-31T06:53:32Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
path: cognite/neat/_rules/transformers/_converters.py
user: login: doctrino
body: just for clarification, you suggest we make a separate PR where we introduce `InputRulesAnalysis` and move these methods under it?
created_at: 2025-03-31T06:54:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -2015,3 +2025,131 @@ def transform(self, rules: InformationRules) -> InformationRules:
             return InformationRules.model_validate(subsetted_rules)
         except ValidationError as e:
             raise NeatValueError(f"Cannot subset rules: {e}") from e
+
+
+class AddCogniteProperties(RulesTransformer[ReadRules[InformationInputRules], ReadRules[InformationInputRules]]):
+    """This transformer looks at the implements of the classes and adds all properties
+    from the parent (and ancestors) classes that are not already included in the data model.
+
+    Args:
+        client: The client is used to look up the properties of the parent classes.
+
+    """
+
+    def __init__(self, client: NeatClient) -> None:
+        self._client = client
+
+    @property
+    def description(self) -> str:
+        """Get the description of the transformer."""
+        return "Add Cognite properties for all concepts that implements a Cognite concept."
+
+    def transform(self, rules: ReadRules[InformationInputRules]) -> ReadRules[InformationInputRules]:
+        input_ = rules.rules
+        if input_ is None:
+            raise NeatValueError("Rule read failed. Cannot add cognite properties to None rules.")
+
+        default_space = input_.metadata.space
+        default_version = input_.metadata.version
+
+        dependencies_by_class = self._get_dependencies_by_class(input_.classes, rules.read_context, default_space)
+        properties_by_class = self._get_properties_by_class(input_.properties, rules.read_context, default_space)
+        cognite_implements_concepts = self._get_cognite_concepts(dependencies_by_class)
+        views_by_class_entity = self._lookup_views(cognite_implements_concepts, default_space, default_version)
+
+        for class_entity, view in views_by_class_entity.items():
+            for prop_id, view_prop in view.properties.items():
+                if prop_id in properties_by_class[class_entity]:
+                    continue
+                properties_by_class[class_entity][prop_id] = DMSImporter.as_information_property(
+                    class_entity, prop_id, view_prop
+                )
+
+        try:
+            topological_order = TopologicalSorter(dependencies_by_class).static_order()
+        except CycleError as e:
+            raise NeatValueError(f"Cycle detected in the class hierarchy: {e}") from e
+
+        new_properties: list[InformationInputProperty] = input_.properties.copy()
+        for class_entity in topological_order:
+            if class_entity not in dependencies_by_class:
+                continue
+            for parent in dependencies_by_class[class_entity]:
+                for prop in properties_by_class[parent].values():
+                    if prop.property_ not in properties_by_class[class_entity]:
+                        new_prop = prop.copy(update={"Class": class_entity}, default_prefix=default_space)
+                        new_properties.append(new_prop)
+                        properties_by_class[class_entity][prop.property_] = new_prop
+
+        new_classes: list[InformationInputClass] = input_.classes.copy()
+        existing_classes = {cls.class_ for cls in input_.classes}
+        for class_entity, view in views_by_class_entity.items():
+            if class_entity not in existing_classes:
+                new_classes.append(DMSImporter.as_information_class(view))
+                existing_classes.add(class_entity)
+
+        return ReadRules(
+            rules=InformationInputRules(
+                metadata=input_.metadata,
+                properties=new_properties,
+                classes=new_classes,
+                prefixes=input_.prefixes,
+            ),
+            read_context={},
+        )
+
+    @staticmethod
+    def _get_properties_by_class(
+        properties: list[InformationInputProperty], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, dict[str, InformationInputProperty]]:
+        issues = IssueList()
+        properties_by_class: dict[ClassEntity, dict[str, InformationInputProperty]] = defaultdict(dict)
+        for prop in properties:
+            try:
+                dumped = prop.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            properties_by_class[class_entity][prop.property_] = prop
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading properties")
+        return properties_by_class
+
+    @staticmethod
+    def _get_dependencies_by_class(
+        classes: list[InformationInputClass], read_context: dict[str, SpreadsheetRead], default_space: str
+    ) -> dict[ClassEntity, set[ClassEntity]]:
+        dependencies_by_class: dict[ClassEntity, set[ClassEntity]] = {}
+        issues = IssueList()
+        for raw in classes:
+            try:
+                dumped = raw.dump(default_prefix=default_space)
+            except ValidationError as e:
+                issues.extend(from_pydantic_errors(e.errors(), read_context))
+                continue
+            class_entity = cast(ClassEntity, dumped["Class"])
+            implements = cast(list[ClassEntity] | None, dumped["Implements"])
+            dependencies_by_class[class_entity] = set(implements or [])
+        if issues.has_errors:
+            raise issues.as_errors(operation="Reading classes")
+        return dependencies_by_class
+
+    @staticmethod
+    def _get_cognite_concepts(dependencies_by_class: dict[ClassEntity, set[ClassEntity]]) -> set[ClassEntity]:
+        cognite_implements_concepts = {
+            dependency
+            for dependencies in dependencies_by_class.values()
+            for dependency in dependencies
+            if dependency.prefix in COGNITE_SPACES
+        }
+        if not cognite_implements_concepts:
+            raise NeatValueError("None of the classes implement Cognite Core concepts.")
+        return cognite_implements_concepts
+
+    def _lookup_views(
path: cognite/neat/_rules/transformers/_converters.py
user: login: doctrino
body: I shorten it to the `get_views_by_class`. We have multiple types of definitions, View, ViewApply, And DMSView, the type hint gives the indication of which one it is.
created_at: 2025-03-31T06:57:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -559,3 +622,43 @@ def _create_enum_collections(
                         )
                     )
         return enum_by_container_property
+
+    @classmethod
+    def as_information_property(
+        cls, entity: ClassEntity, prop_id: str, view_property: ViewProperty
+    ) -> InformationInputProperty:
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: doctrino
body: Correct
created_at: 2025-03-31T06:58:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -436,9 +467,19 @@ def _get_immutable(self, prop: ViewPropertyApply) -> bool | None:
         else:
             return None
 
-    def _get_max_count(self, prop: ViewPropertyApply) -> int | float | None:
-        if isinstance(prop, dm.MappedPropertyApply):
-            prop_type = self._container_prop_unsafe(prop).type
+    @classmethod
+    def _get_max_count(
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: doctrino
body: `ViewPropertyApply | ViewProperty` are the base classes for all types that is checked in this method. If the PySDK adds a new subclass of any of these then we will hit the else block, added a warning if that happens as I think it should not break Neat.
created_at: 2025-03-31T07:14:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -372,40 +392,48 @@ def _get_connection_type(
         elif isinstance(prop, SingleReverseDirectRelationApply | MultiReverseDirectRelationApply):
             return ReverseConnectionEntity(property=prop.through.property)
         elif isinstance(prop, dm.MappedPropertyApply) and isinstance(
-            self._container_prop_unsafe(prop).type, dm.DirectRelation
+            self._get_container_property_definition(prop).type, dm.DirectRelation
         ):
             return "direct"
         else:
             return None
 
+    @classmethod
     def _get_value_type(
-        self,
-        prop: ViewPropertyApply,
-        view_entity: ViewEntity,
-        prop_id: str,
-        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str],
+        cls,
+        prop: ViewPropertyApply | ViewProperty,
+        container_property: dm.ContainerProperty | None = None,
+        enum_collection_by_container_property: dict[tuple[dm.ContainerId, str], str] | None = None,
     ) -> DataType | ViewEntity | DMSUnknownEntity | None:
         if isinstance(
             prop,
             SingleEdgeConnectionApply
             | MultiEdgeConnectionApply
             | SingleReverseDirectRelationApply
-            | MultiReverseDirectRelationApply,
+            | MultiReverseDirectRelationApply
+            | SingleEdgeConnection
+            | dm.MultiEdgeConnection
+            | SingleReverseDirectRelation
+            | dm.MultiReverseDirectRelation,
         ):
             return ViewEntity.from_id(prop.source)
-        elif isinstance(prop, dm.MappedPropertyApply):
-            container_prop = self._container_prop_unsafe(cast(dm.MappedPropertyApply, prop))
-            if isinstance(container_prop.type, dm.DirectRelation):
+        elif isinstance(prop, dm.MappedPropertyApply | dm.MappedProperty):
+            if isinstance(prop, dm.MappedPropertyApply):
+                if container_property is None:
+                    raise ValueError("container propery must be provided when prop is a MappedProperty")
path: cognite/neat/_rules/importers/_dms2rules.py
user: login: psalaberria002
body: ```suggestion
                    raise ValueError("container property must be provided when prop is a MappedProperty")
```
created_at: 2025-03-31T09:44:06Z
author_association: CONTRIBUTOR

PR comments:
user: login: github-actions[bot]
created_at: 2025-03-12T15:06:54Z
body: # ☂️ Python Coverage
> current status: ✅
## Overall Coverage
| Lines | Covered | Coverage | Threshold | Status |
| :---: | :-----: | :------: | :-------: | :----: |
| 15467 |  12425  |    80%   |    80%    |   🟢   |
## New Files
No new covered files...
## Modified Files
| File                                                   | Coverage | Status |
| :----------------------------------------------------- | :------: | :----: |
| cognite/neat/_alpha.py                                 |   100%   |   🟢   |
| cognite/neat/_rules/importers/_dms2rules.py            |    81%   |   🟢   |
| cognite/neat/_rules/models/information/_rules_input.py |    94%   |   🟢   |
| cognite/neat/_rules/transformers/__init__.py           |   100%   |   🟢   |
| cognite/neat/_rules/transformers/_converters.py        |    82%   |   🟢   |
| cognite/neat/_session/_template.py                     |    89%   |   🟢   |
| **TOTAL**                                              |  **91%** |   🟢   |


> **updated for commit: `efffeec` by [action](https://github.com/marketplace/actions/python-coverage)🐍**
author_association: NONE
user: login: psalaberria002
created_at: 2025-03-31T09:48:11Z
body: Ping me when that typo is fixed for a final approval.
author_association: CONTRIBUTOR
user: login: psalaberria002
created_at: 2025-03-31T10:24:43Z
body: :unicorn: 
author_association: CONTRIBUTOR