Skip to content

Commit 9e71717

Browse files
authored
[CDF-27564] 🙉Test build+deploy for v2 commands. (#2800)
# Description Note this is stacked on two branches #2796 , and the branch #2792 -> #2797 . Adds a unit test that builds and deploy the complete orgs. Found a few bugs through the testing: 1. CDF Project not set in `lineage.yaml` when running `cdf build`. 2. Unwrapping build variables were not done correctly for iteration cases. 3. Lineage serialized as relative paths, but requires absolute paths causing failure when loading. 4. Graphql was found in an incorrect way. ## Bump - [ ] Patch - [x] Skip
1 parent 3a49e89 commit 9e71717

File tree

15 files changed

+1656
-51
lines changed

15 files changed

+1656
-51
lines changed

‎cognite_toolkit/_cdf_tk/commands/build_v2/_module_parser.py‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,7 @@ def parse(cls, build: BuildSourceFiles) -> BuildSource:
4444
module_sources: list[ModuleSource] = []
4545
for module in selected_modules:
4646
source = source_by_module_id[module]
47-
module_specific_variables: dict[int | None, list[BuildVariable]] = defaultdict(list)
48-
for path in [module, *module.parents]:
49-
if path_variables := build_variables.get(path):
50-
for iteration, variables in path_variables.items():
51-
module_specific_variables[iteration].extend(variables)
47+
module_specific_variables = cls._as_module_variables(build_variables, module)
5248

5349
if module_specific_variables:
5450
for iteration, variables in module_specific_variables.items():
@@ -70,6 +66,28 @@ def parse(cls, build: BuildSourceFiles) -> BuildSource:
7066
orphan_yaml_files=orphan_yaml_files,
7167
)
7268

69+
@classmethod
70+
def _as_module_variables(
71+
cls, build_variables: dict[Path, dict[int | None, list[BuildVariable]]], module: Path
72+
) -> dict[int | None, list[BuildVariable]]:
73+
module_specific_variables: dict[int | None, list[BuildVariable]] = defaultdict(list)
74+
for path in [module, *module.parents]:
75+
if path_variables := build_variables.get(path):
76+
for iteration, variables in path_variables.items():
77+
module_specific_variables[iteration].extend(variables)
78+
if len(module_specific_variables) <= 1 or None not in module_specific_variables:
79+
return module_specific_variables
80+
81+
# There is multiple iterations with shared variables
82+
shared_variables = module_specific_variables[None]
83+
variables_by_iteration: dict[int | None, list[BuildVariable]] = defaultdict(list)
84+
for iteration, variables in module_specific_variables.items():
85+
if iteration is None:
86+
continue
87+
variables_by_iteration[iteration].extend(variables)
88+
variables_by_iteration[iteration].extend(shared_variables)
89+
return variables_by_iteration
90+
7391
@classmethod
7492
def _find_modules(
7593
cls, yaml_files: list[RelativeFilePath], organization_dir: Path

‎cognite_toolkit/_cdf_tk/commands/build_v2/build_v2.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ def build(self, parameters: BuildParameters, client: ToolkitClient | None = None
109109

110110
self._display_build_folder(build_folder, parameters.config_yaml_name or "", console, parameters.verbose)
111111

112-
self._write_results(build_folder)
112+
self._write_results(build_folder, client.config.project if client else None)
113113

114114
# Todo: Some mixpanel tracking.
115115
return build_folder
@@ -889,7 +889,7 @@ def _display_build_folder(
889889

890890
return None
891891

892-
def _write_results(self, build: BuildFolder) -> None:
892+
def _write_results(self, build: BuildFolder, cdf_project: str | None = None) -> None:
893893
"""Write build results including lineage information and insights to the build folder."""
894894

895895
insight_file = build.build_dir / "insights.csv"
@@ -899,5 +899,5 @@ def _write_results(self, build: BuildFolder) -> None:
899899
safe_write(insight_file, insight_file_content)
900900

901901
lineage_file = build.build_dir / BuildLineage.filename
902-
lineage = BuildLineage.from_build(build).to_yaml()
902+
lineage = BuildLineage.from_build(build, cdf_project).to_yaml()
903903
safe_write(lineage_file, lineage)

‎cognite_toolkit/_cdf_tk/commands/build_v2/data_classes/_lineage.py‎

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,6 @@ class ResourceLineageItem(_BaseLineageModel):
4343
type: ResourceType
4444
built_file: AbsoluteFilePath
4545

46-
@field_serializer("source_file", "built_file", when_used="json")
47-
def serialize_paths(self, value: Path, info: SerializationInfo) -> str:
48-
"""Serialize absolute paths to strings."""
49-
organization_dir = info.context.get("organization_dir") if info.context else None
50-
if organization_dir and value.is_relative_to(organization_dir):
51-
return value.relative_to(organization_dir).as_posix()
52-
return value.as_posix()
53-
5446

5547
class ModuleLineageItem(_BaseLineageModel):
5648
"""Tracks a module through the build process."""
@@ -84,30 +76,22 @@ def status(self) -> str:
8476
else:
8577
return "FAILED: Unknown reason"
8678

87-
@field_serializer("module_path", when_used="json")
88-
def serialize_module_path(self, value: Path, info: SerializationInfo) -> str:
89-
"""Serialize module path to relative path."""
90-
organization_dir = info.context.get("organization_dir") if info.context else None
91-
if organization_dir and value.is_relative_to(organization_dir):
92-
return str(value.relative_to(organization_dir))
93-
return str(value)
94-
9579
@classmethod
9680
def from_built_module(cls, module: BuiltModule) -> "ModuleLineageItem":
9781
"""Construct lineage item from built module."""
9882
resource_lineage = []
9983
for resource in module.resources:
10084
resource_lineage.append(
10185
ResourceLineageItem(
102-
source_file=resource.source_path,
86+
source_file=resource.source_path.resolve(),
10387
source_hash=resource.source_hash,
104-
built_file=resource.build_path,
88+
built_file=resource.build_path.resolve(),
10589
type=resource.type,
10690
)
10791
)
10892
return cls(
10993
module_id=module.module_id.id.as_posix(),
110-
module_path=module.module_id.path,
94+
module_path=module.module_id.path.resolve(),
11195
resource_lineage=resource_lineage,
11296
insights_summary=module.all_insights.summary,
11397
)
@@ -150,7 +134,7 @@ def serialize_duration(self, value: float | None, info: SerializationInfo) -> fl
150134
return round(value, 2) if value is not None else None
151135

152136
@classmethod
153-
def from_build(cls, build: BuildFolder) -> "BuildLineage":
137+
def from_build(cls, build: BuildFolder, cdf_project: str | None = None) -> "BuildLineage":
154138
"""Construct lineage from build output folder."""
155139

156140
module_lineage = [ModuleLineageItem.from_built_module(module) for module in build.built_modules]
@@ -172,6 +156,7 @@ def from_build(cls, build: BuildFolder) -> "BuildLineage":
172156
module_lineage=module_lineage,
173157
modules_summary=modules_summary,
174158
insights_summary=insights_summary,
159+
cdf_project=cdf_project,
175160
)
176161

177162
def to_yaml(self) -> str:

‎cognite_toolkit/_cdf_tk/cruds/_resource_cruds/datamodel.py‎

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1372,7 +1372,8 @@ def get_extra_files(cls, filepath: Path, identifier: DataModelId, item: dict[str
13721372
13731373
This includes a required .graphql file with the schema.
13741374
"""
1375-
graphql_file = filepath.with_suffix(".graphql")
1375+
graphql_file = cls._get_graphql_file(filepath)
1376+
13761377
if not graphql_file.is_file():
13771378
yield FailedReadExtra(
13781379
code="NOT-EXISTING",
@@ -1391,6 +1392,19 @@ def get_extra_files(cls, filepath: Path, identifier: DataModelId, item: dict[str
13911392
description="GraphQL schema",
13921393
)
13931394

1395+
@classmethod
1396+
def _get_graphql_file(cls, filepath: Path) -> Path:
1397+
filestem = filepath.stem
1398+
if filestem.lower().endswith(cls.kind.lower()):
1399+
filestem = filestem[: -len(cls.kind)].removesuffix(".").rstrip()
1400+
1401+
graphql_file = filepath.parent / f"{filestem}.graphql"
1402+
if not graphql_file.is_file() and filepath.with_suffix(".graphql").exists():
1403+
# Fallback
1404+
graphql_file = filepath.with_suffix(".graphql")
1405+
1406+
return graphql_file
1407+
13941408
@classmethod
13951409
def safe_read(cls, filepath: Path | str) -> str:
13961410
# The version is a string, but the user often writes it as an int.
@@ -1413,7 +1427,7 @@ def load_resource_file(
14131427
for item in raw_list:
14141428
model_id = self.get_id(item)
14151429
# Find the GraphQL files adjacent to the DML files
1416-
graphql_file = filepath.with_suffix(".graphql")
1430+
graphql_file = self._get_graphql_file(filepath)
14171431
if not graphql_file.is_file():
14181432
raise ToolkitFileNotFoundError(
14191433
f"Failed to find GraphQL file. Expected {graphql_file.name} adjacent to {filepath.as_posix()}"

‎cognite_toolkit/_cdf_tk/cruds/_resource_cruds/file.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,6 @@
1111
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
14-
15-
1614
from collections.abc import Hashable, Iterable, Sequence
1715
from datetime import date, datetime
1816
from pathlib import Path
@@ -162,7 +160,9 @@ def load_resource_file(
162160
for item in raw_files:
163161
source_file = item.pop("$FILEPATH", None)
164162
if source_file is None:
165-
if candidate := next((filepath.parent.rglob(f"{stem}*")), None):
163+
if candidate := next(
164+
(file for file in filepath.parent.glob(f"{stem}*") if file != filepath and file.stem == stem), None
165+
):
166166
source_file = candidate
167167
elif isinstance(name := item.get("name"), str) and (filepath.parent / name).exists():
168168
source_file = filepath.parent / name
@@ -336,7 +336,9 @@ def load_resource_file(
336336
for item in raw_files:
337337
source_file = item.pop("$FILEPATH", None)
338338
if source_file is None:
339-
if candidate := next((filepath.parent.rglob(f"{stem}*")), None):
339+
if candidate := next(
340+
(file for file in filepath.parent.glob(f"{stem}*") if file != filepath and file.stem == stem), None
341+
):
340342
source_file = candidate
341343
elif isinstance(name := item.get("name"), str) and (filepath.parent / name).exists():
342344
source_file = filepath.parent / name
@@ -360,7 +362,7 @@ def load_resource_file(
360362

361363
def load_resource(self, resource: dict[str, Any], is_dry_run: bool = False) -> CogniteFileRequest:
362364
request = super().load_resource(resource, is_dry_run)
363-
request.filepath = self._filepath_by_node_id[self.get_id(resource)]
365+
request.filepath = self._filepath_by_node_id.get(self.get_id(resource))
364366
return request
365367

366368
def dump_resource(self, resource: CogniteFileResponse, local: dict[str, Any] | None = None) -> dict[str, Any]:

‎cognite_toolkit/_cdf_tk/utils/hashing.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import hashlib
22
import json
3+
import zipfile
34
from pathlib import Path
45
from typing import Any
56

@@ -47,6 +48,8 @@ def calculate_secure_hash(item: dict[str, Any], shorten: bool = False) -> str:
4748
def calculate_hash(content: str | bytes | Path, shorten: bool = False) -> str:
4849
sha256_hash = hashlib.sha256()
4950
if isinstance(content, Path):
51+
if content.suffix == ".zip" and zipfile.is_zipfile(content):
52+
return calculate_zipfile_hash(content, shorten=shorten)
5053
# Get rid of Windows line endings to make the hash consistent across platforms.
5154
content = content.read_bytes().replace(b"\r\n", b"\n")
5255
elif isinstance(content, str):
@@ -56,3 +59,12 @@ def calculate_hash(content: str | bytes | Path, shorten: bool = False) -> str:
5659
if shorten:
5760
return calculated[:8]
5861
return calculated
62+
63+
64+
def calculate_zipfile_hash(filepath: Path, shorten: bool = False) -> str:
65+
"""Calculate a hash of a zip file based on its contents, ignoring zip metadata.
66+
67+
It reads the contents directly from the zip file without extracting,
68+
which ensures consistent hashing across platforms.
69+
"""
70+
return "zipfil-hash-not-supported"

‎tests/test_unit/approval_client/client.py‎

Lines changed: 56 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import builtins
34
import hashlib
45
import io
56
import itertools
@@ -51,7 +52,7 @@
5152
VersionedDataModelingId,
5253
View,
5354
)
54-
from cognite.client.data_classes.data_modeling.ids import DataModelIdentifier, InstanceId
55+
from cognite.client.data_classes.data_modeling.ids import DataModelIdentifier
5556
from cognite.client.data_classes.functions import FunctionsStatus
5657
from cognite.client.data_classes.iam import CreatedSession, GroupWrite, ProjectSpec, TokenInspection
5758
from cognite.client.utils._text import to_camel_case
@@ -60,14 +61,15 @@
6061

6162
from cognite_toolkit._cdf_tk.client import ToolkitClient, ToolkitClientConfig
6263
from cognite_toolkit._cdf_tk.client._resource_base import RequestResource, ResponseResource
63-
from cognite_toolkit._cdf_tk.client.identifiers import ExternalId, InternalId
64+
from cognite_toolkit._cdf_tk.client.identifiers import ExternalId, InstanceId, InternalId
65+
from cognite_toolkit._cdf_tk.client.resource_classes.cognite_file import CogniteFileRequest
6466
from cognite_toolkit._cdf_tk.client.resource_classes.data_modeling import (
6567
InstanceDefinition,
6668
InstanceRequest,
6769
NodeId,
6870
)
6971
from cognite_toolkit._cdf_tk.client.resource_classes.data_modeling._instance import InstanceSlimDefinition
70-
from cognite_toolkit._cdf_tk.client.resource_classes.filemetadata import FileMetadataResponse
72+
from cognite_toolkit._cdf_tk.client.resource_classes.filemetadata import FileMetadataRequest, FileMetadataResponse
7173
from cognite_toolkit._cdf_tk.client.resource_classes.hosted_extractor_source._base import SourceRequestDefinition
7274
from cognite_toolkit._cdf_tk.client.resource_classes.project import ProjectStatus, ProjectStatusList
7375
from cognite_toolkit._cdf_tk.client.resource_classes.raw import RAWDatabaseResponse, RAWTableResponse
@@ -77,6 +79,7 @@
7779
from cognite_toolkit._cdf_tk.cruds import FileCRUD
7880
from cognite_toolkit._cdf_tk.utils import calculate_hash
7981
from cognite_toolkit._cdf_tk.utils.auth import CLIENT_NAME
82+
from tests.constants import CDF_PROJECT
8083

8184
from .config import API_RESOURCES
8285
from .data_classes import APIResource, AuthGroupCalls
@@ -163,7 +166,7 @@ def __init__(self, mock_client: ToolkitClientMock, allow_reverse_lookup: bool =
163166
credentials.client_secret = "toolkit-client-secret"
164167
credentials.token_url = "https://toolkit.auth.com/oauth/token"
165168
credentials.scopes = ["ttps://pytest-field.cognitedata.com/.default"]
166-
project = "test_project"
169+
project = CDF_PROJECT
167170
self.mock_client.config = ToolkitClientConfig(
168171
client_name=CLIENT_NAME,
169172
project=project,
@@ -763,6 +766,34 @@ def create_instances_pydantic(items: Sequence[InstanceRequest]) -> list[Instance
763766
for item in items
764767
]
765768

769+
def create_cognite_file(items: Sequence[CogniteFileRequest]) -> list[InstanceSlimDefinition]:
770+
created_resources[resource_cls.__name__].extend(items)
771+
return [
772+
InstanceSlimDefinition(
773+
instance_type="node",
774+
version=1,
775+
was_modified=True,
776+
space=item.space,
777+
external_id=item.external_id,
778+
created_time=1,
779+
last_updated_time=2,
780+
)
781+
for item in items
782+
]
783+
784+
def create_filemetadata_v2(items: Sequence[FileMetadataRequest], **_) -> list[FileMetadataResponse]:
785+
created_resources[resource_cls.__name__].extend(items)
786+
return [
787+
FileMetadataResponse(
788+
**item.dump(),
789+
created_time=1,
790+
last_updated_time=2,
791+
id=LookUpAPIMock.create_id(item.external_id or "unknown"),
792+
uploaded=True,
793+
)
794+
for item in items
795+
]
796+
766797
available_create_methods = {
767798
fn.__name__: fn
768799
for fn in [
@@ -783,6 +814,8 @@ def create_instances_pydantic(items: Sequence[InstanceRequest]) -> list[Instance
783814
create,
784815
create_hosted_extractor_source,
785816
create_instances_pydantic,
817+
create_cognite_file,
818+
create_filemetadata_v2,
786819
]
787820
}
788821
if mock_method not in available_create_methods:
@@ -934,14 +967,27 @@ def list_raw_table(db_name: str, limit: int | None = None) -> list[RAWTableRespo
934967

935968
def retrieve_filemetadata(
936969
items: Sequence[InternalId | ExternalId | InstanceId], ignore_unknown_ids: bool = False
937-
) -> list[FileMetadataResponse]:
938-
if len(items) == 1 and isinstance(items[0], InternalId):
939-
return [
970+
) -> builtins.list[FileMetadataResponse]:
971+
filemetadata_responses: builtins.list[FileMetadataResponse] = []
972+
for item in items:
973+
if isinstance(item, InternalId):
974+
id = item.id
975+
external_id = str(id)
976+
elif isinstance(item, ExternalId):
977+
id = LookUpAPIMock.create_id(item.external_id or "unknown")
978+
external_id = item.external_id or "unknown"
979+
elif isinstance(item, InstanceId):
980+
id = LookUpAPIMock.create_id(item.instance_id.external_id)
981+
external_id = item.instance_id.external_id
982+
else:
983+
external_id = "unknown"
984+
id = 37
985+
filemetadata_responses.append(
940986
FileMetadataResponse(
941-
id=items[0].id, uploaded=True, created_time=0, last_updated_time=1, name="file"
987+
id=id, uploaded=True, created_time=0, last_updated_time=1, external_id=external_id, name="file"
942988
)
943-
]
944-
return []
989+
)
990+
return filemetadata_responses
945991

946992
available_retrieve_methods = {
947993
fn.__name__: fn

‎tests/test_unit/approval_client/config.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -803,7 +803,7 @@
803803
resource_cls=CogniteFileResponse,
804804
_write_cls=CogniteFileRequest,
805805
methods={
806-
"create": [Method(api_class_method="create", mock_class_method="create")],
806+
"create": [Method(api_class_method="create", mock_class_method="create_cognite_file")],
807807
"retrieve": [
808808
Method(api_class_method="retrieve", mock_class_method="retrieve"),
809809
],
@@ -881,7 +881,10 @@
881881
resource_cls=FileMetadataResponse,
882882
_write_cls=FileMetadataRequest,
883883
methods={
884-
"create": [Method(api_class_method="create", mock_class_method="create")],
884+
"create": [
885+
Method(api_class_method="create", mock_class_method="create_filemetadata_v2"),
886+
Method(api_class_method="update", mock_class_method="create_filemetadata_v2"),
887+
],
885888
"retrieve": [
886889
Method(api_class_method="retrieve", mock_class_method="retrieve_filemetadata"),
887890
],

0 commit comments

Comments
 (0)