Repository: cognitedata/cognite-sdk-python

Issue:
partial hierarchy with asset linked to existing asset by parent_id
**System information (please complete the following information):**
 - OS: Ubuntu
 - Python Version: python3.10
 - SDK Version: 6.6.0

**Describe the bug**
According to the documentation it is allowed to create “partial hierarchies” ([docs](https://cognite-sdk-python.readthedocs-hosted.com/en/latest/core_data_model.html#create-asset-hierarchy)), i.e which depend on existing assets.

It is said explicitly, that "asset with specified parent_id are considered valid" which is not true in the current version of sdk

**To Reproduce**
Runnable code reproducing the error.
```
from cognite.client.data_classes import (
    Asset,
    AssetHierarchy,
)

id_of_potentially_existing_asset = 123
asset = Asset(parent_id=id_of_potentially_existing_asset, name="new asset", external_id="test.external.id")
hierarchy = AssetHierarchy([asset])
print(hierarchy.is_valid())  # returns False
```
**Expected behavior**
should pass validation


**Additional context**

The piece of code below doesn’t take into account an option when “parent_id was provided (and is from existing asset, so not part of the partial hierarchy) and parent_external_id was not provided”

https://github.com/cognitedata/cognite-sdk-python/blame/449109b0325d0f180983a31e7ecb6e6ae97784c7/cognite/client/data_classes/assets.py#L681-L701

```
            if (pxid := asset.parent_external_id) is (pid := asset.parent_id) is None:
                roots.append(asset)
            elif pxid is not None and pid is not None:
                unsure_parents.append(asset)  # Both parent links are given
            elif pxid not in xid_count:
                orphans.append(asset)  # Only parent XID given, but not part of assets given
```



Discussion:
Thanks for the bug report, will look into it asap!

Resolving patch:
diff --git a/cognite/client/_api/assets.py b/cognite/client/_api/assets.py
--- a/cognite/client/_api/assets.py
+++ b/cognite/client/_api/assets.py
@@ -474,10 +474,9 @@ def create_hierarchy(
             3. Any assets have an ambiguous parent link (category: ``unsure_parents``)
             4. Any group of assets form a cycle, e.g. A->B->A (category: ``cycles``)
 
-        It is worth noting that validation is done "offline", i.e. existing assets in CDF are not inspected. This means:
-
-            1. All assets linking a parent by ID are assumed valid
-            2. All orphan assets are assumed valid. "Orphan" means the parent is not part of the given assets (category: ``orphans``)
+        As part of validation there is a fifth category that is ignored when using this method (for backwards compatability) and that
+        is orphan assets. These are assets linking a parent by an identifier that is not present among the given assets, and as such,
+        might contain links we are unable to vet ahead of insertion. These are thus assumed to be valid, but may fail.
 
         Tip:
             The different categories specified above corresponds to the name of the attribute you might access on the raised error to
diff --git a/cognite/client/_version.py b/cognite/client/_version.py
--- a/cognite/client/_version.py
+++ b/cognite/client/_version.py
@@ -1,4 +1,4 @@
 from __future__ import annotations
 
-__version__ = "6.8.0"
+__version__ = "6.8.1"
 __api_subversion__ = "V20220125"
diff --git a/cognite/client/data_classes/assets.py b/cognite/client/data_classes/assets.py
--- a/cognite/client/data_classes/assets.py
+++ b/cognite/client/data_classes/assets.py
@@ -691,12 +691,20 @@ def _inspect_attributes(self) -> Tuple[List[Asset], List[Asset], List[Asset], Li
             if xid_count[xid] > 1:
                 duplicates[xid].append(asset)
 
-            if (pxid := asset.parent_external_id) is (pid := asset.parent_id) is None:
+            has_parent_id = asset.parent_id is not None
+            has_parent_xid = (parent_xid := asset.parent_external_id) is not None
+
+            if not has_parent_xid and not has_parent_id:
                 roots.append(asset)
-            elif pxid is not None and pid is not None:
-                unsure_parents.append(asset)  # Both parent links are given
-            elif pxid not in xid_count:
-                orphans.append(asset)  # Only parent XID given, but not part of assets given
+
+            elif has_parent_xid:
+                if has_parent_id:  # Both parent links are given
+                    unsure_parents.append(asset)
+
+                elif parent_xid not in xid_count:  # Only parent XID given, but not part of assets given
+                    orphans.append(asset)
+
+            # Only case left is when parent is only given by ID, which we assume is valid
 
         return roots, orphans, invalid, unsure_parents, dict(duplicates)
 


Test patch:
diff --git a/tests/tests_integration/test_api/test_assets.py b/tests/tests_integration/test_api/test_assets.py
--- a/tests/tests_integration/test_api/test_assets.py
+++ b/tests/tests_integration/test_api/test_assets.py
@@ -233,6 +233,7 @@ def test_filter_by_geo_location(self, cognite_client):
 
 def generate_orphan_assets(n_id, n_xid, sample_from):
     # Orphans only: We link all assets to an existing asset (some by ID, others by XID):
+    # Note however that orphans linking with parent ID are ignored!
     s = random_string(20)
     id_assets = [
         Asset(name="a", external_id=f"child-by-id-{i}-{s}", parent_id=parent.id)
@@ -329,7 +330,7 @@ def test_orphans__blocked_if_passed_as_asset_hierarchy_instance(self, cognite_cl
         hierarchy_fails = AssetHierarchy(assets, ignore_orphans=False)
         hierarchy_succeeds = AssetHierarchy(assets, ignore_orphans=True)
 
-        with pytest.raises(CogniteAssetHierarchyError, match=r"^Asset hierarchy is not valid. Issue\(s\): 4 orphans$"):
+        with pytest.raises(CogniteAssetHierarchyError, match=r"^Asset hierarchy is not valid. Issue\(s\): 2 orphans$"):
             cognite_client.assets.create_hierarchy(hierarchy_fails, upsert=False)
 
         with create_hierarchy_with_cleanup(cognite_client, hierarchy_succeeds) as created:
diff --git a/tests/tests_unit/test_data_classes/test_assets.py b/tests/tests_unit/test_data_classes/test_assets.py
--- a/tests/tests_unit/test_data_classes/test_assets.py
+++ b/tests/tests_unit/test_data_classes/test_assets.py
@@ -1,6 +1,7 @@
 import io
 import itertools
 import random
+import string
 import textwrap
 import time
 from contextlib import redirect_stdout
@@ -240,10 +241,10 @@ class TestAssetHierarchy:
         ),
     )
     def test_validate_asset_hierarchy___invalid_assets(self, exc_type, asset):
-        ahv = AssetHierarchy([asset]).validate(on_error="ignore")
-        assert len(ahv.invalid) == 1
+        hierarchy = AssetHierarchy([asset]).validate(on_error="ignore")
+        assert len(hierarchy.invalid) == 1
         with pytest.raises(exc_type, match=r"Issue\(s\): 1 invalid$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     @pytest.mark.parametrize("exc_type", (AssertionError, CogniteAssetHierarchyError))
     def test_validate_asset_hierarchy__orphans_given_ignore_false(self, exc_type):
@@ -251,19 +252,35 @@ def test_validate_asset_hierarchy__orphans_given_ignore_false(self, exc_type):
             Asset(name="a", parent_external_id="1", external_id="2"),
             Asset(name="a", parent_external_id="2", external_id="3"),
         ]
-        ahv = AssetHierarchy(assets, ignore_orphans=False).validate(on_error="ignore")
-        assert len(ahv.orphans) == 1
+        hierarchy = AssetHierarchy(assets, ignore_orphans=False).validate(on_error="ignore")
+        assert len(hierarchy.orphans) == 1
         with pytest.raises(exc_type, match=r"Issue\(s\): 1 orphans$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
+
+    @pytest.mark.parametrize("n", [1, 2, 3, 10])
+    def test_validate_asset_hierarchy__orphans_given_ignore_false__all_parent_external_id(self, n):
+        assets = [Asset(name=c, external_id=c, parent_external_id="foo") for c in string.ascii_letters[:n]]
+        hierarchy = AssetHierarchy(assets, ignore_orphans=False).validate(on_error="ignore")
+        assert len(assets) == len(hierarchy.orphans) == n
+        with pytest.raises(CogniteAssetHierarchyError, match=rf"Issue\(s\): {n} orphans$"):
+            hierarchy.is_valid(on_error="raise")
+
+    @pytest.mark.parametrize("n", [0, 1, 2, 3, 10])
+    def test_validate_asset_hierarchy__orphans_given_ignore_false__all_parent_id(self, n):
+        assets = [Asset(name=c, external_id=c, parent_id=ord(c)) for c in string.ascii_letters[:n]]
+        hierarchy = AssetHierarchy(assets, ignore_orphans=False).validate(on_error="ignore")
+        # Parent ID links are never considered orphans (offline validation impossible as ID cant be set):
+        assert len(hierarchy.orphans) == 0
+        hierarchy.is_valid(on_error="raise")
 
     def test_validate_asset_hierarchy__orphans_given_ignore_true(self):
         assets = [
             Asset(name="a", parent_external_id="1", external_id="2"),
             Asset(name="a", parent_external_id="2", external_id="3"),
         ]
-        ahv = AssetHierarchy(assets, ignore_orphans=True).validate(on_error="ignore")
-        assert len(ahv.orphans) == 1  # note: still marked as orphans, but no issues are raised:
-        assert ahv.is_valid(on_error="raise") is True
+        hierarchy = AssetHierarchy(assets, ignore_orphans=True).validate(on_error="ignore")
+        assert len(hierarchy.orphans) == 1  # note: still marked as orphans, but no issues are raised:
+        assert hierarchy.is_valid(on_error="raise") is True
 
     @pytest.mark.parametrize("exc_type", (AssertionError, CogniteAssetHierarchyError))
     def test_validate_asset_hierarchy_asset_has_parent_id_and_parent_ref_id(self, exc_type):
@@ -271,19 +288,19 @@ def test_validate_asset_hierarchy_asset_has_parent_id_and_parent_ref_id(self, ex
             Asset(name="a", external_id="1"),
             Asset(name="a", parent_external_id="1", parent_id=1, external_id="2"),
         ]
-        ahv = AssetHierarchy(assets).validate(on_error="ignore")
-        assert len(ahv.unsure_parents) == 1
+        hierarchy = AssetHierarchy(assets).validate(on_error="ignore")
+        assert len(hierarchy.unsure_parents) == 1
         with pytest.raises(exc_type, match=r"Issue\(s\): 1 unsure_parents$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     @pytest.mark.parametrize("exc_type", (AssertionError, CogniteAssetHierarchyError))
     def test_validate_asset_hierarchy_duplicate_ref_ids(self, exc_type):
         assets = [Asset(name="a", external_id="1"), Asset(name="a", parent_external_id="1", external_id="1")]
-        ahv = AssetHierarchy(assets).validate(on_error="ignore")
-        assert list(ahv.duplicates) == ["1"]
-        assert sum(len(assets) for assets in ahv.duplicates.values()) == 2
+        hierarchy = AssetHierarchy(assets).validate(on_error="ignore")
+        assert list(hierarchy.duplicates) == ["1"]
+        assert sum(len(assets) for assets in hierarchy.duplicates.values()) == 2
         with pytest.raises(exc_type, match=r"Issue\(s\): 2 duplicates$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     @pytest.mark.parametrize("exc_type", (AssertionError, CogniteAssetHierarchyError))
     def test_validate_asset_hierarchy_circular_dependency(self, exc_type):
@@ -292,34 +309,34 @@ def test_validate_asset_hierarchy_circular_dependency(self, exc_type):
             Asset(name="a", external_id="2", parent_external_id="1"),
             Asset(name="a", external_id="3", parent_external_id="2"),
         ]
-        ahv = AssetHierarchy(assets).validate(on_error="ignore")
-        assert len(ahv.cycles) == 1
+        hierarchy = AssetHierarchy(assets).validate(on_error="ignore")
+        assert len(hierarchy.cycles) == 1
         with pytest.raises(exc_type, match=r"Issue\(s\): 1 cycles$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     @pytest.mark.parametrize("exc_type", (AssertionError, CogniteAssetHierarchyError))
     def test_validate_asset_hierarchy_self_dependency(self, exc_type):
         # Shortest cycle possible is self->self:
         assets = [Asset(name="a", external_id="2", parent_external_id="2")]
-        ahv = AssetHierarchy(assets).validate(on_error="ignore")
-        assert len(ahv.cycles) == 1
+        hierarchy = AssetHierarchy(assets).validate(on_error="ignore")
+        assert len(hierarchy.cycles) == 1
         with pytest.raises(exc_type, match=r"Issue\(s\): 1 cycles$"):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     def test_validate_asset_hierarchy__everything_is_wrong(self):
-        ahv = AssetHierarchy(basic_issue_assets()).validate(on_error="ignore")
-        assert ahv.invalid and ahv.orphans and ahv.unsure_parents and ahv.duplicates
+        hierarchy = AssetHierarchy(basic_issue_assets()).validate(on_error="ignore")
+        assert hierarchy.invalid and hierarchy.orphans and hierarchy.unsure_parents and hierarchy.duplicates
         with pytest.raises(CogniteAssetHierarchyError, match="^Unable to run cycle-check before"):
-            ahv.cycles
+            hierarchy.cycles
         with pytest.raises(
             CogniteAssetHierarchyError, match=r"Issue\(s\): 3 duplicates, 1 invalid, 1 unsure_parents, 2 orphans$"
         ):
-            ahv.is_valid(on_error="raise")
+            hierarchy.is_valid(on_error="raise")
 
     def test_validate_asset_hierarchy__cycles(self):
-        ahv = AssetHierarchy(cycles_issue_assets()).validate(on_error="ignore")
-        assert not ahv.is_valid()
-        found_cycles = set(map(frozenset, ahv.cycles))
+        hierarchy = AssetHierarchy(cycles_issue_assets()).validate(on_error="ignore")
+        assert not hierarchy.is_valid()
+        found_cycles = set(map(frozenset, hierarchy.cycles))
         exp_cycles = {frozenset(f"{s}{i}" for i in range(n**2)) for n, s in enumerate("abcdefg", 2)}
         assert found_cycles == exp_cycles